v0.30.0 c899179

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}
28
29var _ ports.InboxService = (*Inbox)(nil)
30
31// InboxConfig holds what the inbox needs. Every field is required.
32type InboxConfig struct {
33	Meta          ports.RepoMetaStore
34	MergeRequests ports.MergeRequestStore
35	Issues        ports.IssueStore
36	// Pending lists the issues awaiting moderation. Optional: without it the
37	// inbox has no pending section.
38	Pending ports.PendingIssueStore
39}
40
41// NewInbox builds the inbox use case.
42func NewInbox(cfg InboxConfig) *Inbox {
43	return &Inbox{meta: cfg.Meta, mrs: cfg.MergeRequests, issues: cfg.Issues, pending: cfg.Pending}
44}
45
46// Overview lists what is open, through the indexes of open items. The counts
47// and the list of repositories do not depend on the filter, the lists do.
48func (in *Inbox) Overview(ctx context.Context, p domain.Principal, filter domain.RepoName) (ports.InboxOverview, error) {
49	var out ports.InboxOverview
50	if !p.HasOwnerRights() {
51		return out, domain.ErrNotFound
52	}
53	mrs, err := in.mrs.ListOpenAll(ctx)
54	if err != nil {
55		return out, fmt.Errorf("list open merge requests: %w", err)
56	}
57	issues, err := in.issues.ListOpenAll(ctx)
58	if err != nil {
59		return out, fmt.Errorf("list open issues: %w", err)
60	}
61
62	// exists and issuesOn cache what is read of each repository.
63	issuesOn := make(map[domain.RepoName]bool)
64	exists := make(map[domain.RepoName]bool)
65	look := func(name domain.RepoName) error {
66		if _, ok := exists[name]; ok {
67			return nil
68		}
69		repo, err := lookupRepo(ctx, in.meta, name)
70		switch {
71		case errors.Is(err, domain.ErrNotFound):
72			exists[name] = false
73		case err != nil:
74			return fmt.Errorf("read repository %q: %w", name, err)
75		default:
76			exists[name] = true
77			issuesOn[name] = repo.Settings.IssuesEnabled()
78		}
79		return nil
80	}
81
82	repos := make(map[domain.RepoName]bool)
83	for _, m := range mrs {
84		if err := look(m.Repo); err != nil {
85			return out, err
86		}
87		if !exists[m.Repo] {
88			continue
89		}
90		repos[m.Repo] = true
91		out.Counts.MergeRequests++
92		if filter.IsZero() || m.Repo == filter {
93			out.MergeRequests = append(out.MergeRequests, m)
94		}
95	}
96	for _, i := range issues {
97		if err := look(i.Repo); err != nil {
98			return out, err
99		}
100		if !exists[i.Repo] || !issuesOn[i.Repo] {
101			continue
102		}
103		repos[i.Repo] = true
104		out.Counts.Issues++
105		if filter.IsZero() || i.Repo == filter {
106			out.Issues = append(out.Issues, i)
107		}
108	}
109
110	if in.pending != nil {
111		page, err := in.pending.List(ctx, domain.RepoName{}, ports.ListPendingParams{PageSize: domain.MaxPendingIssues})
112		if err != nil {
113			return out, fmt.Errorf("list pending issues: %w", err)
114		}
115		for _, pi := range page.Issues {
116			if err := look(pi.Repo); err != nil {
117				return out, err
118			}
119			if !exists[pi.Repo] || !issuesOn[pi.Repo] {
120				continue
121			}
122			pi.IPHash = nil // the hash is for bans, the page never needs it
123			out.Pending = append(out.Pending, pi)
124			repos[pi.Repo] = true
125		}
126		out.Counts.Pending = len(out.Pending)
127	}
128
129	slices.SortStableFunc(out.MergeRequests, func(a, b domain.MergeRequest) int {
130		return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
131	})
132	slices.SortStableFunc(out.Issues, func(a, b domain.Issue) int {
133		return compareRecent(a.UpdatedAt, a.CreatedAt, a.Repo, a.Number, b.UpdatedAt, b.CreatedAt, b.Repo, b.Number)
134	})
135	for r := range repos {
136		out.Repos = append(out.Repos, r)
137	}
138	slices.SortFunc(out.Repos, func(a, b domain.RepoName) int { return strings.Compare(a.String(), b.String()) })
139	return out, nil
140}
141
142// compareRecent orders by update time, most recent first (the creation time
143// stands in for a missing one), then by repository and number.
144func compareRecent(aUp, aCr time.Time, aRepo domain.RepoName, aN int64, bUp, bCr time.Time, bRepo domain.RepoName, bN int64) int {
145	if aUp.IsZero() {
146		aUp = aCr
147	}
148	if bUp.IsZero() {
149		bUp = bCr
150	}
151	if c := bUp.Compare(aUp); c != 0 {
152		return c
153	}
154	if c := strings.Compare(aRepo.String(), bRepo.String()); c != 0 {
155		return c
156	}
157	switch {
158	case aN > bN:
159		return -1
160	case aN < bN:
161		return 1
162	}
163	return 0
164}

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