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}