v0.35.1 2aca1df

inbox.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"slices"
10	"strings"
11	"time"
12
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/ports"
15)
16
17// Inbox is the owner's inbox (ADR 0031): the open merge requests and issues of
18// every repository in one place. Only the owner may read it; anyone else
19// gets domain.ErrNotFound, as for a page that does not exist. The issues of a
20// repository whose issues mode is off are left out.
21type Inbox struct {
22	meta   ports.RepoMetaStore
23	mrs    ports.MergeRequestStore
24	issues ports.IssueStore
25	// pending is nil when the public form is not wired.
26	pending ports.PendingIssueStore
27	ci      ports.CIStore
28}
29
30var _ ports.InboxService = (*Inbox)(nil)
31
32// InboxConfig holds what the inbox needs; Pending and CI are optional.
33type InboxConfig struct {
34	Meta          ports.RepoMetaStore
35	MergeRequests ports.MergeRequestStore
36	Issues        ports.IssueStore
37	// Pending lists the issues awaiting moderation. Optional: without it the
38	// inbox has no pending section.
39	Pending ports.PendingIssueStore
40	CI      ports.CIStore
41}
42
43// NewInbox builds the inbox use case.
44func NewInbox(cfg InboxConfig) *Inbox {
45	return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending, ci: cfg.CI}
46}
47
48// Overview lists what is open, through the indexes of open items. The counts
49// and the list of repositories do not depend on the filter, the lists do.
50func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
51	var out ports.InboxOverview
52	if !p.HasOwnerRights() {
53		return out, domain.ErrNotFound
54	}
55	mrs, err := in.mrs.ListOpenAll(ctx)
56	if err != nil {
57		return out, fmt.Errorf("list open merge requests: %w", err)
58	}
59	issues, err := in.issues.ListOpenAll(ctx)
60	if err != nil {
61		return out, fmt.Errorf("list open issues: %w", err)
62	}
63
64	// exists and issuesOn cache what is read of each repository.
65	issuesOn := make(map[domain.RepoName]bool)
66	exists := make(map[domain.RepoName]bool)
67	look := func(name domain.RepoName) error {
68		if _, ok := exists[name]; ok {
69			return nil
70		}
71		repo, err := lookupRepo(ctx, in.meta, name)
72		switch {
73		case errors.Is(err, domain.ErrNotFound):
74			exists[name] = false
75		case err != nil:
76			return fmt.Errorf("read repository %q: %w", name, err)
77		default:
78			exists[name] = true
79			issuesOn[name] = repo.Settings.IssuesEnabled()
80		}
81		return nil
82	}
83
84	repos := make(map[domain.RepoName]bool)
85	for _, m := range mrs {
86		if err := look(m.Repo); err != nil {
87			return out, err
88		}
89		if !exists[m.Repo] {
90			continue
91		}
92		repos[m.Repo] = true
93		out.Counts.MergeRequests++
94		if filter.IsZero() || m.Repo == filter {
95			out.MergeRequests = append(out.MergeRequests, m)
96		}
97	}
98	for _, i := range issues {
99		if err := look(i.Repo); err != nil {
100			return out, err
101		}
102		if !exists[i.Repo] || !issuesOn[i.Repo] {
103			continue
104		}
105		repos[i.Repo] = true
106		out.Counts.Issues++
107		if filter.IsZero() || i.Repo == filter {
108			out.Issues = append(out.Issues, i)
109		}
110	}
111
112	if in.pending != nil {
113		page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues})
114		if err != nil {
115			return out, fmt.Errorf("list pending issues: %w", err)
116		}
117		for _, pi := range page.Issues {
118			if err := look(pi.Repo); err != nil {
119				return out, err
120			}
121			if !exists[pi.Repo] || !issuesOn[pi.Repo] {
122				continue
123			}
124			pi.IPHash = nil // the hash is for bans, the page never needs it
125			out.Pending = append(out.Pending, pi)
126			repos[pi.Repo] = true
127		}
128		out.Counts.Pending = len(out.Pending)
129	}
130
131	if in.ci != nil {
132		out.RunsEnabled = true
133		runs, err := in.ci.ListOpenRuns(ctx)
134		if err != nil {
135			return out, fmt.Errorf("list open runs: %w", err)
136		}
137		for _, run := range runs {
138			if run.Status != domain.CIQueued && run.Status != domain.CIInProgress {
139				continue
140			}
141			if err := look(run.Repo); err != nil {
142				return out, err
143			}
144			if !exists[run.Repo] {
145				continue
146			}
147			repos[run.Repo] = true
148			out.Counts.Running++
149			if filter.IsZero() || run.Repo == filter {
150				out.Runs = append(out.Runs, run)
151			}
152		}
153		slices.SortStableFunc(out.Runs, func(a, b domain.CIRun) int {
154			return compareRecent(a.CreatedAt, a.CreatedAt, a.Repo, a.ID, b.CreatedAt, b.CreatedAt, b.Repo, b.ID)
155		})
156	}
157
158	slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
159		return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
160	})
161	slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int {
162		return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
163	})
164	for r := range repos {
165		out.Repos = append(out.Repos, r)
166	}
167	slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) })
168	return out, nil
169}
170
171// compareRecent orders by update time, most recent first (the creation time
172// stands in for a missing one), then by repository and number.
173func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int {
174	if aUp.IsZero() {
175		aUp = aCr
176	}
177	if bUp.IsZero() {
178		bUp = bCr
179	}
180	if c := bUp.Compare(aUp); c != 0 {
181		return c
182	}
183	if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 {
184		return c
185	}
186	switch {
187	case aN > bN:
188		return -1
189	case aN < bN:
190		return 1
191	}
192	return 0
193}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close