v0.36.0 1fb2421

inbox.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"net/http"
8	"net/url"
9	"strconv"
10	"strings"
11	"time"
12
13	"github.com/a-h/templ"
14
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17)
18
19// The owner's inbox (ADR 0031): GET /-/inbox lists the open merge requests and
20// issues of every repository, newest activity first, with a filter by
21// repository (?repo=<name>, a plain link, so it works without scripts). Only a
22// signed-in owner sees it: for anyone else the path is as missing as any other.
23// It follows the stream "owner" with view=inbox: each event of a merge request
24// or an issue sends the counters, the two lists and the filter again.
25
26// inboxPath is the page of the inbox.
27const inboxPath = "/-/inbox"
28
29// inboxEvent is the name of the live events of the inbox.
30const inboxEvent = "inbox"
31
32// InboxRow is one open item of the inbox.
33type InboxRow struct {
34	Repo     string
35	RepoHref string
36	Number   string
37	Href     string
38	Title    string
39	When     string
40	WhenAt   string
41	Cursor   bool
42}
43
44// PendingRow is one issue awaiting moderation. Everything in it is plain text:
45// it comes from an anonymous visitor and is never rendered as Markdown.
46type PendingRow struct {
47	Repo     string
48	RepoHref string
49	Author   string
50	Title    string
51	// Preview is the start of the body, at most pendingPreviewRunes characters.
52	Preview string
53	When    string
54	WhenAt  string
55	// Approve, Reject and Ban are the URLs the buttons post to; empty without
56	// the moderation use case.
57	Approve, Reject, Ban string
58}
59
60// pendingPreviewRunes bounds the preview of a pending body.
61const pendingPreviewRunes = 300
62
63// InboxRepoLink is an entry of the repository filter.
64type InboxRepoLink struct {
65	Label   string
66	Href    string
67	Current bool
68}
69
70// InboxRunRow adds repository and start time to the Actions run display.
71type InboxRunRow struct {
72	RunRow
73	Repo, RepoHref, When, WhenAt string
74}
75
76// InboxPage is the inbox: counters, repository filter and item lists.
77type InboxPage struct {
78	Running     int
79	RunsEnabled bool
80	Runs        []InboxRunRow
81	// MergeRequests and Issues count every open item, whatever the filter.
82	MergeRequests, Issues int
83	// PendingCount counts every issue awaiting moderation.
84	PendingCount int
85	Pending      []PendingRow
86	Filter       string // the repository filtered on, "" for all
87	Repos        []InboxRepoLink
88	MRs          []InboxRow
89	IssueRows    []InboxRow
90}
91
92// inboxHref is the inbox page, filtered on repo when it is not "".
93func inboxHref(repo string) string {
94	if repo == "" {
95		return inboxPath
96	}
97	return inboxPath + "?repo=" + url.QueryEscape(repo)
98}
99
100// inboxFilter reads ?repo=. A name that is not valid is a page that is not
101// there.
102func inboxFilter(r *http.Request) (domain.RepoName, error) {
103	raw := r.URL.Query().Get("repo")
104	if raw == "" {
105		return domain.RepoName{}, nil
106	}
107	name, err := domain.ParseRepoName(raw)
108	if err != nil {
109		return domain.RepoName{}, errNotFound
110	}
111	return name, nil
112}
113
114// inboxPage builds the page from the overview.
115func (s *Server) inboxPage(ov ports.InboxOverview, filter domain.RepoName) InboxPage {
116	now := s.cfg.Now()
117	p := InboxPage{MergeRequests: ov.Counts.MergeRequests, Issues: ov.Counts.Issues, PendingCount: ov.Counts.Pending, Filter: filter.String()}
118	p.Running, p.RunsEnabled = ov.Counts.Running, ov.RunsEnabled
119	for _, run := range ov.Runs {
120		r := InboxRunRow{RunRow: s.runRow(run.Repo.String(), run, now), Repo: run.Repo.String(), RepoHref: repoHref(run.Repo.String())}
121		if run.Status == domain.CIInProgress {
122			r.State = "in progress"
123		}
124		ts := run.StartedAt
125		if ts.IsZero() {
126			ts = run.CreatedAt
127		}
128		r.When, r.WhenAt = whenOf(now, ts)
129		p.Runs = append(p.Runs, r)
130	}
131	for _, pi := range ov.Pending {
132		p.Pending = append(p.Pending, s.pendingRow(now, pi, filter))
133	}
134	row := func(repo domain.RepoName, n int64, href, title string, updated, created time.Time) InboxRow {
135		ts := updated
136		if ts.IsZero() {
137			ts = created
138		}
139		r := InboxRow{
140			Repo: repo.String(), RepoHref: repoHref(repo.String()),
141			Number: "#" + strconv.FormatInt(n, 10), Href: href, Title: visibleText(title),
142		}
143		r.When, r.WhenAt = whenOf(now, ts)
144		return r
145	}
146	for _, m := range ov.MergeRequests {
147		p.MRs = append(p.MRs, row(m.Repo, m.Number, mrHref(m.Repo.String(), m.Number), m.Title.String(), m.UpdatedAt, m.CreatedAt))
148	}
149	for _, i := range ov.Issues {
150		p.IssueRows = append(p.IssueRows, row(i.Repo, i.Number, issueHref(i.Repo.String(), i.Number), i.Title.String(), i.UpdatedAt, i.CreatedAt))
151	}
152	switch {
153	case len(p.MRs) > 0:
154		p.MRs[0].Cursor = true
155	case len(p.IssueRows) > 0:
156		p.IssueRows[0].Cursor = true
157	}
158	p.Repos = append(p.Repos, InboxRepoLink{Label: "all", Href: inboxHref(""), Current: filter.IsZero()})
159	for _, r := range ov.Repos {
160		p.Repos = append(p.Repos, InboxRepoLink{Label: r.String(), Href: inboxHref(r.String()), Current: r == filter})
161	}
162	return p
163}
164
165// pendingRow is the row of a pending issue: the texts as plain strings, the
166// invisible characters marked, the body cut.
167func (s *Server) pendingRow(now time.Time, pi domain.PendingIssue, filter domain.RepoName) PendingRow {
168	repo := pi.Repo.String()
169	row := PendingRow{
170		Repo: repo, RepoHref: repoHref(repo), Author: "anonymous",
171		Title: visibleText(pi.Title.String()),
172	}
173	if a := strings.TrimSpace(visibleText(pi.Author)); a != "" {
174		row.Author = a
175	}
176	body := strings.TrimSpace(visibleText(pi.Body.String()))
177	row.Preview = cutRunes(body, pendingPreviewRunes)
178	if len(row.Preview) < len(body) {
179		row.Preview += "…"
180	}
181	row.When, row.WhenAt = whenOf(now, pi.CreatedAt)
182	if s.cfg.Moderation != nil {
183		base := inboxPath + "/pending/" + url.PathEscape(repo) + "/" + url.PathEscape(pi.ID.String()) + "/"
184		var q string
185		if !filter.IsZero() {
186			q = "?repo=" + url.QueryEscape(filter.String())
187		}
188		row.Approve, row.Reject, row.Ban = base+"approve"+q, base+"reject"+q, base+"ban"+q
189	}
190	return row
191}
192
193// pendingBack is where a moderation action returns: the inbox, on the same
194// repository filter.
195func pendingBack(r *http.Request) string {
196	if f, err := inboxFilter(r); err == nil {
197		return inboxHref(f.String())
198	}
199	return inboxPath
200}
201
202// pendingID reads the repository and the id of the path.
203func pendingID(r *http.Request) (domain.RepoName, domain.PendingIssueID, error) {
204	name, err := domain.ParseRepoName(r.PathValue("repo"))
205	if err != nil {
206		return domain.RepoName{}, "", err
207	}
208	id, err := domain.ParsePendingIssueID(r.PathValue("id"))
209	if err != nil {
210		return domain.RepoName{}, "", domain.ErrNotFound
211	}
212	return name, id, nil
213}
214
215// pendingApprove is POST /-/inbox/pending/<repo>/<id>/approve.
216func (s *Server) pendingApprove(ctx context.Context, r *http.Request) (string, string, error) {
217	back := pendingBack(r)
218	name, id, err := pendingID(r)
219	if err != nil {
220		return "", back, err
221	}
222	if _, err := s.cfg.Moderation.Approve(ctx, principal(ctx), name, id); err != nil {
223		return "", back, err
224	}
225	return back, back, nil
226}
227
228// pendingReject is POST /-/inbox/pending/<repo>/<id>/reject, or .../ban, which
229// also bans the submitter.
230func (s *Server) pendingReject(ban bool) func(ctx context.Context, r *http.Request) (string, string, error) {
231	return func(ctx context.Context, r *http.Request) (string, string, error) {
232		back := pendingBack(r)
233		name, id, err := pendingID(r)
234		if err != nil {
235			return "", back, err
236		}
237		if err := s.cfg.Moderation.Reject(ctx, principal(ctx), name, id, ban); err != nil {
238			return "", back, err
239		}
240		return back, back, nil
241	}
242}
243
244// inbox is GET /-/inbox.
245func (s *Server) inbox(ctx context.Context, r *http.Request) (templ.Component, int, error) {
246	if !SignedIn(ctx) {
247		return nil, 0, errNotFound
248	}
249	filter, err := inboxFilter(r)
250	if err != nil {
251		return nil, 0, err
252	}
253	ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
254	if err != nil {
255		return nil, 0, err
256	}
257	l := s.base()
258	l.Title = "inbox · " + s.instance
259	l.Crumbs = []Crumb{{Label: "inbox", Current: true}}
260	l.Cmd = []CmdItem{{Kind: "go", Label: "home", Href: "/"}}
261	if s.cfg.Events != nil {
262		q := "view=inbox"
263		if !filter.IsZero() {
264			q += "&repo=" + url.QueryEscape(filter.String())
265		}
266		l.Live, l.LiveURL = true, liveURL("owner", q)
267	}
268	return Inbox(l, s.inboxPage(ov, filter)), http.StatusOK, nil
269}
270
271// inboxCount is the number of open items the home button shows, and whether
272// there is a button: only for the signed-in owner, and only when the inbox is
273// there. A failed read leaves the button without a number.
274func (s *Server) inboxCount(ctx context.Context) (n int, show bool) {
275	if s.cfg.Inbox == nil || !SignedIn(ctx) {
276		return 0, false
277	}
278	ov, err := s.cfg.Inbox.Overview(ctx, principal(ctx), domain.RepoName{})
279	if err != nil {
280		return -1, true
281	}
282	return ov.Counts.MergeRequests + ov.Counts.Issues, true
283}
284
285// inbox renders the fragments an event of a merge request, an issue or a repository changes
286// on the inbox page: the counters, the filter and the two lists.
287func (l *liveStream) inbox(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
288	switch we.Event.(type) {
289	case domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded, domain.CommentEdited, domain.CommentDeleted, domain.CommentResolved,
290		domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented, domain.IssueSubmitted, domain.IssuePendingRemoved,
291		domain.RepoUpdated, domain.RepoDeleted, domain.RunUpdated:
292	default:
293		return nil, nil
294	}
295	if l.s.cfg.Inbox == nil {
296		return nil, nil
297	}
298	var filter domain.RepoName
299	if l.filter != "" {
300		filter, _ = domain.ParseRepoName(l.filter)
301	}
302	ov, err := l.s.cfg.Inbox.Overview(ctx, principal(ctx), filter)
303	if err != nil {
304		return nil, err
305	}
306	p := l.s.inboxPage(ov, filter)
307	var out []fragment
308	for _, c := range []templ.Component{InboxCounters(p), InboxRepos(p), InboxPending(p), InboxRuns(p), InboxMRs(p), InboxIssues(p)} {
309		f, err := renderFragment(ctx, inboxEvent, c)
310		if err != nil {
311			return nil, err
312		}
313		out = append(out, f)
314	}
315	return out, nil
316}

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 4

Tags 45

↑↓ moveenter openesc close