v0.31.0 d5e0505

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	// ClosedBy is the merge request whose merge closed the issue.
87	ClosedBy *Link
88	// References are the merge requests naming the issue.
89	References []IssueRefView
90}
91
92// IssueRefView is a merge request naming an issue.
93type IssueRefView struct {
94	Number, Title, State, Href string
95	Closes                     bool
96}
97
98// maxNumberLookups bounds the lookups of #N references of one render.
99const maxNumberLookups = 50
100
101// numberResolver resolves #N to the page of an issue or a merge request of
102// the repository. It is meant for one render: answers are memoized and at most
103// maxNumberLookups numbers are asked, the others do not resolve.
104func (s *Server) numberResolver(ctx context.Context, repo string) func(int64) (string, domain.NumberKind, bool) {
105	if s.cfg.IssueNumbers == nil {
106		return nil
107	}
108	name, err := domain.ParseRepoName(repo)
109	if err != nil {
110		return nil
111	}
112	type answer struct {
113		href string
114		kind domain.NumberKind
115		ok   bool
116	}
117	seen := map[int64]answer{}
118	return func(n int64) (string, domain.NumberKind, bool) {
119		if a, ok := seen[n]; ok {
120			return a.href, a.kind, a.ok
121		}
122		if len(seen) >= maxNumberLookups {
123			return "", domain.NumberNone, false
124		}
125		var a answer
126		kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
127		if err == nil {
128			switch kind {
129			case domain.NumberIssue:
130				a = answer{issueHref(repo, n), kind, true}
131			case domain.NumberMergeRequest:
132				a = answer{mrHref(repo, n), kind, true}
133			}
134		}
135		seen[n] = a
136		return a.href, a.kind, a.ok
137	}
138}
139
140// IssueNewPage is the form that opens an issue.
141type IssueNewPage struct {
142	Action, Cancel string
143}
144
145// issueRepo reads the repository of an issue page: one with issues off is
146// as missing as one the caller cannot see.
147func (s *Server) issueRepo(ctx context.Context, rawName string) (*repoData, error) {
148	d, err := s.loadRepo(ctx, rawName)
149	if err != nil {
150		return nil, err
151	}
152	if !d.repo.Settings.IssuesEnabled() {
153		return nil, errNotFound
154	}
155	return d, nil
156}
157
158// externalAuthor is the label a visitor gave, as plain text, or "anonymous".
159func (s *Server) externalAuthor(label string) string {
160	if a := strings.TrimSpace(visibleText(label)); a != "" {
161		return a
162	}
163	return "anonymous"
164}
165
166func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
167	row := IssueRow{
168		Number: "#" + strconv.FormatInt(i.Number, 10), Href: issueHref(repo, i.Number),
169		Title: visibleText(i.Title.String()), State: string(i.State), Author: s.who(i.Author),
170	}
171	if i.External {
172		row.Author = s.externalAuthor(i.Author)
173	}
174	ts := i.UpdatedAt
175	if ts.IsZero() {
176		ts = i.CreatedAt
177	}
178	row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
179	return row
180}
181
182// issueList lists the issues of one state, a page at a time.
183//
184//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
185func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
186	d, err := s.issueRepo(ctx, r.PathValue("repo"))
187	if err != nil {
188		return nil, 0, err
189	}
190	state := domain.IssueStateOpen
191	switch r.URL.Query().Get("state") {
192	case "", "open":
193	case "closed":
194		state = domain.IssueStateClosed
195	default:
196		return nil, 0, errNotFound
197	}
198	token := r.URL.Query().Get("page")
199	base := issuesBase(d.name())
200	closed := state == domain.IssueStateClosed
201	link := func(page string) string {
202		q := url.Values{}
203		if closed {
204			q.Set("state", "closed")
205		}
206		if page != "" {
207			q.Set("page", page)
208		}
209		if len(q) == 0 {
210			return base
211		}
212		return base + "?" + q.Encode()
213	}
214
215	res, err := s.cfg.Issues.List(ctx, principal(ctx), d.repo.Name, ports.ListIssuesParams{
216		State: state, PageSize: pageSize, PageToken: token,
217	})
218	if err != nil {
219		return nil, 0, err
220	}
221	p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"}
222	for _, i := range res.Issues {
223		p.Rows = append(p.Rows, s.issueRow(d.name(), i))
224	}
225	if len(p.Rows) > 0 {
226		p.Rows[0].Cursor = true
227	}
228	if res.NextPageToken != "" {
229		p.Older = link(res.NextPageToken)
230	}
231	if token != "" {
232		p.Newest = link("")
233	}
234	if SignedIn(ctx) || (s.cfg.IssueForm != nil && d.repo.Settings.AcceptsAnonymousIssues()) {
235		p.NewHref = issuesBase(d.name()) + "/new"
236	}
237
238	l := s.repoLayout(ctx, d, "issues")
239	l.Title = "issues · " + d.name() + " · " + s.instance
240	l.Crumbs[0].Current = false
241	l.Crumbs = append(l.Crumbs, Crumb{Label: "issues", Current: true})
242	if token == "" {
243		p.Live = true
244		l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issues")
245	}
246	l.HasRail = true
247	return IssueList(l, p), http.StatusOK, nil
248}
249
250// issueNew is the form that opens an issue; it is the owner's.
251func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component, int, error) {
252	d, err := s.issueRepo(ctx, r.PathValue("repo"))
253	if err != nil {
254		return nil, 0, err
255	}
256	if !SignedIn(ctx) {
257		return nil, 0, errNotFound
258	}
259	l := s.repoLayout(ctx, d, "issues")
260	l.Title = "new issue · " + d.name() + " · " + s.instance
261	l.Crumbs[0].Current = false
262	l.Crumbs = append(l.Crumbs,
263		Crumb{Label: "issues", Href: issuesBase(d.name())},
264		Crumb{Label: "new", Current: true},
265	)
266	l.HasRail = true
267	return IssueNew(l, IssueNewPage{Action: issuesBase(d.name()), Cancel: issuesBase(d.name())}), http.StatusOK, nil
268}
269
270// issueShow shows an issue with its comments.
271func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
272	n, err := mrNumber(r)
273	if err != nil {
274		return nil, 0, err
275	}
276	d, err := s.issueRepo(ctx, r.PathValue("repo"))
277	if err != nil {
278		return nil, 0, err
279	}
280	res, err := s.cfg.Issues.Get(ctx, principal(ctx), d.repo.Name, n)
281	if err != nil {
282		return nil, 0, err
283	}
284	i := res.Issue
285	now := s.cfg.Now()
286	signed := SignedIn(ctx)
287	self := issueHref(d.name(), n)
288
289	p := IssuePage{
290		Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
291		State: string(i.State), Open: i.IsOpen(),
292	}
293	if p.Desc, err = s.issueBody(ctx, d, i); err != nil {
294		return nil, 0, err
295	}
296	p.ClosedBy = numberLink(d.name(), i.ClosedByMergeRequest)
297	p.References = issueRefViews(d.name(), res.References)
298	p.Opened.Name = s.who(i.Author)
299	if i.External {
300		// The label is the visitor's, not the owner's: shown as plain text.
301		p.Opened.Name = s.externalAuthor(i.Author)
302	}
303	p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt)
304	if !i.CreatedAt.IsZero() {
305		p.Opened.Abs = absolute(i.CreatedAt)
306	}
307	p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
308	for _, c := range res.Comments {
309		v := CommentView{
310			ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
311		}
312		v.When, v.WhenAt = whenOf(now, c.CreatedAt)
313		if !c.CreatedAt.IsZero() {
314			v.Abs = absolute(c.CreatedAt)
315		}
316		p.Comments = append(p.Comments, v)
317	}
318	if signed {
319		p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
320		switch i.State {
321		case domain.IssueStateOpen:
322			p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
323		case domain.IssueStateClosed:
324			p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
325		}
326	}
327
328	l := s.repoLayout(ctx, d, "issues")
329	l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
330	l.Crumbs[0].Current = false
331	l.Crumbs = append(l.Crumbs,
332		Crumb{Label: "issues", Href: issuesBase(d.name())},
333		Crumb{Label: p.Number, Href: self, Current: true},
334	)
335	p.Live = true
336	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
337	l.HasRail = true
338	return IssueShow(l, p), http.StatusOK, nil
339}
340
341// issueBody renders the description of an issue; an issue from the public form
342// was written by an anonymous visitor and gets the untrusted policy.
343func (s *Server) issueBody(ctx context.Context, d *repoData, i domain.Issue) (template.HTML, error) {
344	body := i.Body.String()
345	if strings.TrimSpace(body) == "" {
346		return "", nil
347	}
348	numbers := s.numberResolver(ctx, d.name())
349	if i.External {
350		return s.md.renderUntrusted([]byte(body), "issue-desc-", numbers)
351	}
352	return s.md.renderIssue([]byte(body), d.name(), d.repo.DefaultBranch, "issue-desc-", numbers)
353}
354
355// numberLink links merge request n of repo; nil for 0.
356func numberLink(repo string, n int64) *Link {
357	if n == 0 {
358		return nil
359	}
360	return &Link{Label: "!" + strconv.FormatInt(n, 10), Href: mrHref(repo, n)}
361}
362
363// issueRefViews are the merge requests that mention an issue.
364func issueRefViews(repo string, refs []ports.IssueReference) []IssueRefView {
365	views := make([]IssueRefView, 0, len(refs))
366	for _, ref := range refs {
367		views = append(views, IssueRefView{
368			Number: "!" + strconv.FormatInt(ref.Number, 10), Title: visibleText(ref.Title.String()),
369			State: string(ref.State), Href: mrHref(repo, ref.Number), Closes: ref.Closes,
370		})
371	}
372	return views
373}
374
375// pageOrRedirect is page(), except that a page that is not found may be the
376// page of the other kind of the number: target then names where to send the
377// visitor ("" when there is none).
378func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
379	plain := s.page(h)
380	if s.cfg.IssueNumbers == nil {
381		return plain
382	}
383	return func(w http.ResponseWriter, r *http.Request) {
384		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
385		defer cancel()
386		c, status, err := h(ctx, r)
387		if errors.Is(err, domain.ErrNotFound) {
388			to, terr := target(ctx, r)
389			switch {
390			case terr != nil && !errors.Is(terr, domain.ErrNotFound):
391				s.fail(ctx, w, r, terr)
392				return
393			case terr == nil && to != "":
394				seeOther(w, r, to)
395				return
396			}
397		}
398		if err != nil {
399			s.fail(ctx, w, r, err)
400			return
401		}
402		s.write(ctx, w, r, status, c)
403	}
404}
405
406// numberKind asks what the number of the path names.
407func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
408	n, err := mrNumber(r)
409	if err != nil {
410		return domain.NumberNone, 0, err
411	}
412	name, err := domain.ParseRepoName(r.PathValue("repo"))
413	if err != nil {
414		return domain.NumberNone, 0, err
415	}
416	kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
417	return kind, n, err
418}
419
420// issueToMR is where the number of an issue URL goes when it names a merge
421// request.
422func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
423	kind, n, err := s.numberKind(ctx, r)
424	if err != nil || kind != domain.NumberMergeRequest {
425		return "", err
426	}
427	return mrHref(r.PathValue("repo"), n), nil
428}
429
430// mrToIssue is where the number of a merge request URL goes when it names an
431// issue.
432func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
433	kind, n, err := s.numberKind(ctx, r)
434	if err != nil || kind != domain.NumberIssue {
435		return "", err
436	}
437	return issueHref(r.PathValue("repo"), n), nil
438}

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