detached cab7cdc

issues.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"errors"
8	"html/template"
9	"net/http"
10	"net/url"
11	"strconv"
12	"strings"
13
14	"github.com/a-h/templ"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// The issue pages (ADR 0031). They follow the merge request pages: they read
21// through IssueService as the visitor of the request, anonymous sees the
22// issues of public repositories, and only the signed-in owner gets the forms
23// (issues_post.go handles them). A repository whose issues mode is off, a
24// missing one and a private one for an anonymous visitor are the same 404.
25//
26// Routes:
27//
28//	GET  /<repo>/issues             the list, open or closed (?state=), paged (?page=)
29//	GET  /<repo>/issues/new         the form that opens one (owner)
30//	GET  /<repo>/issues/<n>         the issue with its comments
31//
32// Issues and merge requests share their numbers, so "#n" may name either: with
33// an IssueNumberResolver the page of the wrong kind redirects to the right one.
34
35// issuesBase is the URL of the issue pages of a repository.
36func issuesBase(repo string) string { return repoHref(repo, "issues") }
37
38func issueHref(repo string, n int64, sub ...string) string {
39	return repoHref(repo, append([]string{"issues", strconv.FormatInt(n, 10)}, sub...)...)
40}
41
42// IssueRow is one issue in the list.
43type IssueRow struct {
44	Number string
45	Href   string
46	Title  string
47	Author string
48	State  string
49	When   string
50	WhenAt string
51	Cursor bool
52}
53
54// IssueListPage is the list of the issues of a repository.
55type IssueListPage struct {
56	Rows []IssueRow
57	// Closed is true on the tab of the closed issues.
58	Closed               bool
59	OpenHref, ClosedHref string
60	Older, Newest        string
61	NewHref              string // the form that opens an issue; "" unless the owner
62	Live                 bool
63}
64
65// IssueActions are the buttons of the owner on an issue.
66type IssueActions struct {
67	Action string // the URL that closes or reopens
68	Label  string
69}
70
71// IssuePage is an issue with its comments.
72type IssuePage struct {
73	Number    string
74	Title     string
75	State     string
76	Open      bool
77	Opened    Person
78	Updated   string
79	UpdatedAt string
80	// Desc is the body rendered as Markdown, "" when it is empty.
81	Desc       template.HTML
82	Comments   []CommentView
83	Form       *CommentForm
84	Transition *IssueActions
85	Live       bool
86}
87
88// IssueNewPage is the form that opens an issue.
89type IssueNewPage struct {
90	Action, Cancel string
91}
92
93// issueRepo reads the repository of an issue page: one with issues off is
94// as missing as one the caller cannot see.
95func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, error) {
96	d, err := s.loadRepo(ctx, rawName)
97	if err != nil {
98		return nil, err
99	}
100	if !d.repo.Settings.IssuesEnabled() {
101		return nil, errNotFound
102	}
103	return d, nil
104}
105
106// externalAuthor is the label a visitor gave, as plain text, or "anonymous".
107func (s *Server) externalAuthor(label string) string {
108	if a := strings.TrimSpace(visibleText(label)); a != "" {
109		return a
110	}
111	return "anonymous"
112}
113
114func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
115	row := IssueRow{
116		Number: "#" + strconv.FormatInt(i.Number, 10), Href: issueHref(repo, i.Number),
117		Title: visibleText(i.Title.String()), State: string(i.State), Author: s.who(i.Author),
118	}
119	if i.External {
120		row.Author = s.externalAuthor(i.Author)
121	}
122	ts := i.UpdatedAt
123	if ts.IsZero() {
124		ts = i.CreatedAt
125	}
126	row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
127	return row
128}
129
130// issueList lists the issues of one state, a page at a time.
131//
132//hexcheck:ignore suspicious-business-logic-in-adapter renders issue-list navigation; signed-in users see New issue, while anonymous users need a configured form and the domain AcceptsAnonymousIssues predicate
133func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
134	d, err := s.issueRepo(ctx, r.PathValue("repo"))
135	if err != nil {
136		return nil, 0, err
137	}
138	state := domain.IssueStateOpen
139	switch r.URL.Query().Get("state") {
140	case "", "open":
141	case "closed":
142		state = domain.IssueStateClosed
143	default:
144		return nil, 0, errNotFound
145	}
146	token := r.URL.Query().Get("page")
147	base := issuesBase(d.name())
148	closed := state == domain.IssueStateClosed
149	link := func(page string) string {
150		q := url.Values{}
151		if closed {
152			q.Set("state", "closed")
153		}
154		if page != "" {
155			q.Set("page", page)
156		}
157		if len(q) == 0 {
158			return base
159		}
160		return base + "?" + q.Encode()
161	}
162
163	res, err := s.cfg.Issues.List(ctx, principal(ctx), d.repo.Name, ports.ListIssuesParams{
164		State: state, PageSize: pageSize, PageToken: token,
165	})
166	if err != nil {
167		return nil, 0, err
168	}
169	p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"}
170	for _, i := range res.Issues {
171		p.Rows = append(p.Rows, s.issueRow(d.name(), i))
172	}
173	if len(p.Rows) > 0 {
174		p.Rows[0].Cursor = true
175	}
176	if res.NextPageToken != "" {
177		p.Older = link(res.NextPageToken)
178	}
179	if token != "" {
180		p.Newest = link("")
181	}
182	if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.AcceptsAnonymousIssues()) {
183		p.NewHref = issuesBase(d.name()) + "/new"
184	}
185
186	l := s.repoLayout(ctx, d, "issues")
187	l.Title = "issues · " + d.name() + " · " + s.instance
188	l.Crumbs[0].Current = false
189	l.Crumbs = append(l.Crumbs, Crumb{Label: "issues", Current: true})
190	if token == "" {
191		p.Live = true
192		l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issues")
193	}
194	l.HasRail = true
195	return IssueList(l, p), http.StatusOK, nil
196}
197
198// issueNew is the form that opens an issue; it is the owner's.
199func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component, int, error) {
200	d, err := s.issueRepo(ctx, r.PathValue("repo"))
201	if err != nil {
202		return nil, 0, err
203	}
204	if !SignedIn(ctx) {
205		return nil, 0, errNotFound
206	}
207	l := s.repoLayout(ctx, d, "issues")
208	l.Title = "new issue · " + d.name() + " · " + s.instance
209	l.Crumbs[0].Current = false
210	l.Crumbs = append(l.Crumbs,
211		Crumb{Label: "issues", Href: issuesBase(d.name())},
212		Crumb{Label: "new", Current: true},
213	)
214	l.HasRail = true
215	return IssueNew(l, IssueNewPage{Action: issuesBase(d.name()), Cancel: issuesBase(d.name())}), http.StatusOK, nil
216}
217
218// issueShow shows an issue with its comments.
219func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
220	n, err := mrNumber(r)
221	if err != nil {
222		return nil, 0, err
223	}
224	d, err := s.issueRepo(ctx, r.PathValue("repo"))
225	if err != nil {
226		return nil, 0, err
227	}
228	res, err := s.cfg.Issues.Get(ctx, principal(ctx), d.repo.Name, n)
229	if err != nil {
230		return nil, 0, err
231	}
232	i := res.Issue
233	now := s.cfg.Now()
234	signed := SignedIn(ctx)
235	self := issueHref(d.name(), n)
236
237	p := IssuePage{
238		Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
239		State: string(i.State), Open: i.IsOpen(),
240	}
241	if body := i.Body.String(); strings.TrimSpace(body) != "" {
242		var h template.HTML
243		var err error
244		if i.External {
245			// Written by an anonymous visitor: the untrusted policy.
246			h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
247		} else {
248			h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
249		}
250		if err != nil {
251			return nil, 0, err
252		}
253		p.Desc = h
254	}
255	p.Opened.Name = s.who(i.Author)
256	if i.External {
257		// The label is the visitor's, not the owner's: shown as plain text.
258		p.Opened.Name = s.externalAuthor(i.Author)
259	}
260	p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt)
261	if !i.CreatedAt.IsZero() {
262		p.Opened.Abs = absolute(i.CreatedAt)
263	}
264	p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
265	for _, c := range res.Comments {
266		v := CommentView{
267			ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
268		}
269		v.When, v.WhenAt = whenOf(now, c.CreatedAt)
270		if !c.CreatedAt.IsZero() {
271			v.Abs = absolute(c.CreatedAt)
272		}
273		p.Comments = append(p.Comments, v)
274	}
275	if signed {
276		p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
277		switch i.State {
278		case domain.IssueStateOpen:
279			p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
280		case domain.IssueStateClosed:
281			p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
282		}
283	}
284
285	l := s.repoLayout(ctx, d, "issues")
286	l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
287	l.Crumbs[0].Current = false
288	l.Crumbs = append(l.Crumbs,
289		Crumb{Label: "issues", Href: issuesBase(d.name())},
290		Crumb{Label: p.Number, Href: self, Current: true},
291	)
292	p.Live = true
293	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
294	l.HasRail = true
295	return IssueShow(l, p), http.StatusOK, nil
296}
297
298// pageOrRedirect is page(), except that a page that is not found may be the
299// page of the other kind of the number: target then names where to send the
300// visitor ("" when there is none).
301func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
302	plain := s.page(h)
303	if s.cfg.IssueNumbers == nil {
304		return plain
305	}
306	return func(w http.ResponseWriter, r *http.Request) {
307		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
308		defer cancel()
309		c, status, err := h(ctx, r)
310		if errors.Is(err, domain.ErrNotFound) {
311			to, terr := target(ctx, r)
312			switch {
313			case terr != nil && !errors.Is(terr, domain.ErrNotFound):
314				s.fail(ctx, w, r, terr)
315				return
316			case terr == nil && to != "":
317				seeOther(w, r, to)
318				return
319			}
320		}
321		if err != nil {
322			s.fail(ctx, w, r, err)
323			return
324		}
325		s.write(ctx, w, r, status, c)
326	}
327}
328
329// numberKind asks what the number of the path names.
330func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
331	n, err := mrNumber(r)
332	if err != nil {
333		return domain.NumberNone, 0, err
334	}
335	name, err := domain.ParseRepoName(r.PathValue("repo"))
336	if err != nil {
337		return domain.NumberNone, 0, err
338	}
339	kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
340	return kind, n, err
341}
342
343// issueToMR is where the number of an issue URL goes when it names a merge
344// request.
345func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
346	kind, n, err := s.numberKind(ctx, r)
347	if err != nil || kind != domain.NumberMergeRequest {
348		return "", err
349	}
350	return mrHref(r.PathValue("repo"), n), nil
351}
352
353// mrToIssue is where the number of a merge request URL goes when it names an
354// issue.
355func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
356	kind, n, err := s.numberKind(ctx, r)
357	if err != nil || kind != domain.NumberIssue {
358		return "", err
359	}
360	return issueHref(r.PathValue("repo"), n), nil
361}

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 3

Tags 45

↑↓ moveenter openesc close