detached 83e495f

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 body := i.Body.String(); strings.TrimSpace(body) != "" {
294		var h template.HTML
295		var err error
296		if i.External {
297			// Written by an anonymous visitor: the untrusted policy.
298			h, err = s.md.renderUntrusted([]byte(body), "issue-desc-", s.numberResolver(ctx, d.name()))
299		} else {
300			h, err = s.md.renderIssue([]byte(body), d.name(), d.repo.DefaultBranch, "issue-desc-", s.numberResolver(ctx, d.name()))
301		}
302		if err != nil {
303			return nil, 0, err
304		}
305		p.Desc = h
306	}
307	if i.ClosedByMergeRequest > 0 {
308		p.ClosedBy = &Link{Label: "!" + strconv.FormatInt(i.ClosedByMergeRequest, 10), Href: mrHref(d.name(), i.ClosedByMergeRequest)}
309	}
310	for _, ref := range res.References {
311		p.References = append(p.References, IssueRefView{
312			Number: "!" + strconv.FormatInt(ref.Number, 10), Title: visibleText(ref.Title.String()),
313			State: string(ref.State), Href: mrHref(d.name(), ref.Number), Closes: ref.Closes,
314		})
315	}
316	p.Opened.Name = s.who(i.Author)
317	if i.External {
318		// The label is the visitor's, not the owner's: shown as plain text.
319		p.Opened.Name = s.externalAuthor(i.Author)
320	}
321	p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt)
322	if !i.CreatedAt.IsZero() {
323		p.Opened.Abs = absolute(i.CreatedAt)
324	}
325	p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
326	for _, c := range res.Comments {
327		v := CommentView{
328			ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
329		}
330		v.When, v.WhenAt = whenOf(now, c.CreatedAt)
331		if !c.CreatedAt.IsZero() {
332			v.Abs = absolute(c.CreatedAt)
333		}
334		p.Comments = append(p.Comments, v)
335	}
336	if signed {
337		p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
338		switch i.State {
339		case domain.IssueStateOpen:
340			p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
341		case domain.IssueStateClosed:
342			p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
343		}
344	}
345
346	l := s.repoLayout(ctx, d, "issues")
347	l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
348	l.Crumbs[0].Current = false
349	l.Crumbs = append(l.Crumbs,
350		Crumb{Label: "issues", Href: issuesBase(d.name())},
351		Crumb{Label: p.Number, Href: self, Current: true},
352	)
353	p.Live = true
354	l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
355	l.HasRail = true
356	return IssueShow(l, p), http.StatusOK, nil
357}
358
359// pageOrRedirect is page(), except that a page that is not found may be the
360// page of the other kind of the number: target then names where to send the
361// visitor ("" when there is none).
362func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
363	plain := s.page(h)
364	if s.cfg.IssueNumbers == nil {
365		return plain
366	}
367	return func(w http.ResponseWriter, r *http.Request) {
368		ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
369		defer cancel()
370		c, status, err := h(ctx, r)
371		if errors.Is(err, domain.ErrNotFound) {
372			to, terr := target(ctx, r)
373			switch {
374			case terr != nil && !errors.Is(terr, domain.ErrNotFound):
375				s.fail(ctx, w, r, terr)
376				return
377			case terr == nil && to != "":
378				seeOther(w, r, to)
379				return
380			}
381		}
382		if err != nil {
383			s.fail(ctx, w, r, err)
384			return
385		}
386		s.write(ctx, w, r, status, c)
387	}
388}
389
390// numberKind asks what the number of the path names.
391func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
392	n, err := mrNumber(r)
393	if err != nil {
394		return domain.NumberNone, 0, err
395	}
396	name, err := domain.ParseRepoName(r.PathValue("repo"))
397	if err != nil {
398		return domain.NumberNone, 0, err
399	}
400	kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
401	return kind, n, err
402}
403
404// issueToMR is where the number of an issue URL goes when it names a merge
405// request.
406func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
407	kind, n, err := s.numberKind(ctx, r)
408	if err != nil || kind != domain.NumberMergeRequest {
409		return "", err
410	}
411	return mrHref(r.PathValue("repo"), n), nil
412}
413
414// mrToIssue is where the number of a merge request URL goes when it names an
415// issue.
416func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
417	kind, n, err := s.numberKind(ctx, r)
418	if err != nil || kind != domain.NumberIssue {
419		return "", err
420	}
421	return issueHref(r.PathValue("repo"), n), nil
422}

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