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.Issues.OrDefault() == domain.IssuesOff {
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 builds the list page from the use case results; whether the New issue button shows only reads the mode
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.Issues.OrDefault() == domain.IssuesPublic) {
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.
219//
220//hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here
221func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
222 n, err := mrNumber(r)
223 if err != nil {
224 return nil, 0, err
225 }
226 d, err := s.issueRepo(ctx, r.PathValue("repo"))
227 if err != nil {
228 return nil, 0, err
229 }
230 res, err := s.cfg.Issues.Get(ctx, principal(ctx), d.repo.Name, n)
231 if err != nil {
232 return nil, 0, err
233 }
234 i := res.Issue
235 now := s.cfg.Now()
236 signed := SignedIn(ctx)
237 self := issueHref(d.name(), n)
238
239 p := IssuePage{
240 Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
241 State: string(i.State), Open: i.IsOpen(),
242 }
243 if body := i.Body.String(); strings.TrimSpace(body) != "" {
244 var h template.HTML
245 var err error
246 if i.External {
247 // Written by an anonymous visitor: the untrusted policy.
248 h, err = s.md.renderUntrusted([]byte(body), "issue-desc-")
249 } else {
250 h, _, err = s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
251 }
252 if err != nil {
253 return nil, 0, err
254 }
255 p.Desc = h
256 }
257 p.Opened.Name = s.who(i.Author)
258 if i.External {
259 // The label is the visitor's, not the owner's: shown as plain text.
260 p.Opened.Name = s.externalAuthor(i.Author)
261 }
262 p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt)
263 if !i.CreatedAt.IsZero() {
264 p.Opened.Abs = absolute(i.CreatedAt)
265 }
266 p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
267 for _, c := range res.Comments {
268 v := CommentView{
269 ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
270 }
271 v.When, v.WhenAt = whenOf(now, c.CreatedAt)
272 if !c.CreatedAt.IsZero() {
273 v.Abs = absolute(c.CreatedAt)
274 }
275 p.Comments = append(p.Comments, v)
276 }
277 if signed {
278 p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue", Plain: true}
279 switch i.State {
280 case domain.IssueStateOpen:
281 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
282 case domain.IssueStateClosed:
283 p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
284 }
285 }
286
287 l := s.repoLayout(ctx, d, "issues")
288 l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
289 l.Crumbs[0].Current = false
290 l.Crumbs = append(l.Crumbs,
291 Crumb{Label: "issues", Href: issuesBase(d.name())},
292 Crumb{Label: p.Number, Href: self, Current: true},
293 )
294 p.Live = true
295 l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
296 l.HasRail = true
297 return IssueShow(l, p), http.StatusOK, nil
298}
299
300// pageOrRedirect is page(), except that a page that is not found may be the
301// page of the other kind of the number: target then names where to send the
302// visitor ("" when there is none).
303func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
304 plain := s.page(h)
305 if s.cfg.IssueNumbers == nil {
306 return plain
307 }
308 return func(w http.ResponseWriter, r *http.Request) {
309 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
310 defer cancel()
311 c, status, err := h(ctx, r)
312 if errors.Is(err, domain.ErrNotFound) {
313 to, terr := target(ctx, r)
314 switch {
315 case terr != nil && !errors.Is(terr, domain.ErrNotFound):
316 s.fail(ctx, w, r, terr)
317 return
318 case terr == nil && to != "":
319 seeOther(w, r, to)
320 return
321 }
322 }
323 if err != nil {
324 s.fail(ctx, w, r, err)
325 return
326 }
327 s.write(ctx, w, r, status, c)
328 }
329}
330
331// numberKind asks what the number of the path names.
332func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
333 n, err := mrNumber(r)
334 if err != nil {
335 return domain.NumberNone, 0, err
336 }
337 name, err := domain.ParseRepoName(r.PathValue("repo"))
338 if err != nil {
339 return domain.NumberNone, 0, err
340 }
341 kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
342 return kind, n, err
343}
344
345// issueToMR is where the number of an issue URL goes when it names a merge
346// request.
347func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
348 kind, n, err := s.numberKind(ctx, r)
349 if err != nil || kind != domain.NumberMergeRequest {
350 return "", err
351 }
352 return mrHref(r.PathValue("repo"), n), nil
353}
354
355// mrToIssue is where the number of a merge request URL goes when it names an
356// issue.
357func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
358 kind, n, err := s.numberKind(ctx, r)
359 if err != nil || kind != domain.NumberIssue {
360 return "", err
361 }
362 return issueHref(r.PathValue("repo"), n), nil
363}