detached 931429b

feat(web): add the issue pages, tab, redirects and live notices

commit
931429b6bcf527df2b3d0847d7eab24be74086ab
author
brice · · 2026-10-19 05:51 UTC
parent
a5d6827
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
48 files · +2126 -40
M internal/adapters/web/events.go +3 -1
@@ -31,6 +31,8 @@ import (
3131 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3232 // GET /-/events?topic=repo:<name>&view=mrs
3333 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
34+// GET /-/events?topic=repo:<name>&view=issues
35+// GET /-/events?topic=repo:<name>&view=issue&n=<number>
3436 // GET /-/events?topic=repo:<name>&view=actions
3537 // GET /-/events?topic=repo:<name>&view=run&n=<run id>
3638 // GET /-/events?topic=repo:<name>&view=job&job=<job id>
@@ -51,7 +53,7 @@ import (
5153 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
5254 // <dl id="refs-kv">…</dl>, the facts of the rail
5355 // commits-head data: <div id="commits-head">N new commits — reload</div>
54-// mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
56+// mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
5557 // something changed, with the link that reloads the page
5658 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
5759 // <section id="recent-actions">…</section> (the rail of the summary) or
@@ -31,6 +31,8 @@ import (
31 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>31 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
32 // GET /-/events?topic=repo:<name>&view=mrs32 // GET /-/events?topic=repo:<name>&view=mrs
33 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>33 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
34+// GET /-/events?topic=repo:<name>&view=issues
35+// GET /-/events?topic=repo:<name>&view=issue&n=<number>
34 // GET /-/events?topic=repo:<name>&view=actions36 // GET /-/events?topic=repo:<name>&view=actions
35 // GET /-/events?topic=repo:<name>&view=run&n=<run id>37 // GET /-/events?topic=repo:<name>&view=run&n=<run id>
36 // GET /-/events?topic=repo:<name>&view=job&job=<job id>38 // GET /-/events?topic=repo:<name>&view=job&job=<job id>
@@ -51,7 +53,7 @@ import (
51 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with53 // refs-list data: <tbody id="ref-rows">…</tbody>, and again with
52 // <dl id="refs-kv">…</dl>, the facts of the rail54 // <dl id="refs-kv">…</dl>, the facts of the rail
53 // commits-head data: <div id="commits-head">N new commits — reload</div>55 // commits-head data: <div id="commits-head">N new commits — reload</div>
54-// mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:56+// mr-notice data: <div id="mr-notice">…</div>, on the merge request and issue pages:
55 // something changed, with the link that reloads the page57 // something changed, with the link that reloads the page
56 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),58 // ci-run data: <tr id="run-<id>">…</tr> (the list of runs),
57 // <section id="recent-actions">…</section> (the rail of the summary) or59 // <section id="recent-actions">…</section> (the rail of the summary) or
M internal/adapters/web/fragments.go +26 -1
@@ -59,7 +59,8 @@ func liveEvent(we ports.WatchEvent) bool {
5959 switch we.Event.(type) {
6060 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
6161 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
62- domain.RunUpdated, domain.JobUpdated:
62+ domain.RunUpdated, domain.JobUpdated,
63+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:
6364 return true
6465 }
6566 return false
@@ -166,6 +167,14 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
166167 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
167168 case domain.CommentAdded:
168169 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
170+ case domain.IssueOpened:
171+ return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")
172+ case domain.IssueClosed:
173+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")
174+ case domain.IssueReopened:
175+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
176+ case domain.IssueCommented:
177+ return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
169178 case domain.PushEvent:
170179 switch l.view {
171180 case "mr":
@@ -400,6 +409,22 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([
400409 return nil, nil
401410 }
402411
412+// issueNotice tells an issue page that something happened to the issue it
413+// shows, or the list that its content changed. Like mrNotice it sends a fixed
414+// sentence only, in the same slot.
415+func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
416+ switch l.view {
417+ case "issues":
418+ return l.notice(ctx, "issues changed — reload", issuesBase(l.repo))
419+ case "issue":
420+ if strconv.FormatInt(number, 10) != l.n {
421+ return nil, nil
422+ }
423+ return l.notice(ctx, text, issueHref(l.repo, number))
424+ }
425+ return nil, nil
426+}
427+
403428 // mrPushed announces a push to the source branch of the merge request shown.
404429 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
405430 if l.ref == "" {
@@ -59,7 +59,8 @@ func liveEvent(we ports.WatchEvent) bool {
59 switch we.Event.(type) {59 switch we.Event.(type) {
60 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,60 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
61 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,61 domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
62- domain.RunUpdated, domain.JobUpdated:62+ domain.RunUpdated, domain.JobUpdated,
63+ domain.IssueOpened, domain.IssueClosed, domain.IssueReopened, domain.IssueCommented:
63 return true64 return true
64 }65 }
65 return false66 return false
@@ -166,6 +167,14 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
166 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")167 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
167 case domain.CommentAdded:168 case domain.CommentAdded:
168 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")169 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
170+ case domain.IssueOpened:
171+ return l.issueNotice(ctx, ev.Issue.Number, "an issue was opened — reload")
172+ case domain.IssueClosed:
173+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was closed — reload")
174+ case domain.IssueReopened:
175+ return l.issueNotice(ctx, ev.Issue.Number, "this issue was reopened — reload")
176+ case domain.IssueCommented:
177+ return l.issueNotice(ctx, ev.Number, "a comment was added — reload")
169 case domain.PushEvent:178 case domain.PushEvent:
170 switch l.view {179 switch l.view {
171 case "mr":180 case "mr":
@@ -400,6 +409,22 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([
400 return nil, nil409 return nil, nil
401 }410 }
402 411
412+// issueNotice tells an issue page that something happened to the issue it
413+// shows, or the list that its content changed. Like mrNotice it sends a fixed
414+// sentence only, in the same slot.
415+func (l *liveStream) issueNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
416+ switch l.view {
417+ case "issues":
418+ return l.notice(ctx, "issues changed — reload", issuesBase(l.repo))
419+ case "issue":
420+ if strconv.FormatInt(number, 10) != l.n {
421+ return nil, nil
422+ }
423+ return l.notice(ctx, text, issueHref(l.repo, number))
424+ }
425+ return nil, nil
426+}
427+
403 // mrPushed announces a push to the source branch of the merge request shown.428 // mrPushed announces a push to the source branch of the merge request shown.
404 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {429 func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
405 if l.ref == "" {430 if l.ref == "" {
A internal/adapters/web/issues.go +339 -0
@@ -0,0 +1,339 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
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.
36+func issuesBase(repo string) string { return repoHref(repo, "issues") }
37+
38+func 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.
43+type 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.
55+type 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.
66+type IssueActions struct {
67+ Action string // the URL that closes or reopens
68+ Label string
69+}
70+
71+// IssuePage is an issue with its comments.
72+type 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.
89+type 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.
95+func (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+func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
107+ row := IssueRow{
108+ Number: "#" + strconv.FormatInt(i.Number, 10), Href: issueHref(repo, i.Number),
109+ Title: visibleText(i.Title.String()), State: string(i.State), Author: s.who(i.Author),
110+ }
111+ ts := i.UpdatedAt
112+ if ts.IsZero() {
113+ ts = i.CreatedAt
114+ }
115+ row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
116+ return row
117+}
118+
119+// issueList lists the issues of one state, a page at a time.
120+func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
121+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
122+ if err != nil {
123+ return nil, 0, err
124+ }
125+ state := domain.IssueStateOpen
126+ switch r.URL.Query().Get("state") {
127+ case "", "open":
128+ case "closed":
129+ state = domain.IssueStateClosed
130+ default:
131+ return nil, 0, errNotFound
132+ }
133+ token := r.URL.Query().Get("page")
134+ base := issuesBase(d.name())
135+ closed := state == domain.IssueStateClosed
136+ link := func(page string) string {
137+ q := url.Values{}
138+ if closed {
139+ q.Set("state", "closed")
140+ }
141+ if page != "" {
142+ q.Set("page", page)
143+ }
144+ if len(q) == 0 {
145+ return base
146+ }
147+ return base + "?" + q.Encode()
148+ }
149+
150+ res, err := s.cfg.Issues.List(ctx, principal(ctx), d.repo.Name, ports.ListIssuesParams{
151+ State: state, PageSize: pageSize, PageToken: token,
152+ })
153+ if err != nil {
154+ return nil, 0, err
155+ }
156+ p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"}
157+ for _, i := range res.Issues {
158+ p.Rows = append(p.Rows, s.issueRow(d.name(), i))
159+ }
160+ if len(p.Rows) > 0 {
161+ p.Rows[0].Cursor = true
162+ }
163+ if res.NextPageToken != "" {
164+ p.Older = link(res.NextPageToken)
165+ }
166+ if token != "" {
167+ p.Newest = link("")
168+ }
169+ if SignedIn(ctx) {
170+ p.NewHref = issuesBase(d.name()) + "/new"
171+ }
172+
173+ l := s.repoLayout(ctx, d, "issues")
174+ l.Title = "issues · " + d.name() + " · " + s.instance
175+ l.Crumbs[0].Current = false
176+ l.Crumbs = append(l.Crumbs, Crumb{Label: "issues", Current: true})
177+ if token == "" {
178+ p.Live = true
179+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issues")
180+ }
181+ l.HasRail = true
182+ return IssueList(l, p), http.StatusOK, nil
183+}
184+
185+// issueNew is the form that opens an issue; it is the owner's.
186+func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component, int, error) {
187+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
188+ if err != nil {
189+ return nil, 0, err
190+ }
191+ if !SignedIn(ctx) {
192+ return nil, 0, errNotFound
193+ }
194+ l := s.repoLayout(ctx, d, "issues")
195+ l.Title = "new issue · " + d.name() + " · " + s.instance
196+ l.Crumbs[0].Current = false
197+ l.Crumbs = append(l.Crumbs,
198+ Crumb{Label: "issues", Href: issuesBase(d.name())},
199+ Crumb{Label: "new", Current: true},
200+ )
201+ l.HasRail = true
202+ return IssueNew(l, IssueNewPage{Action: issuesBase(d.name()), Cancel: issuesBase(d.name())}), http.StatusOK, nil
203+}
204+
205+// issueShow shows an issue with its comments.
206+//
207+//hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here
208+func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
209+ n, err := mrNumber(r)
210+ if err != nil {
211+ return nil, 0, err
212+ }
213+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
214+ if err != nil {
215+ return nil, 0, err
216+ }
217+ res, err := s.cfg.Issues.Get(ctx, principal(ctx), d.repo.Name, n)
218+ if err != nil {
219+ return nil, 0, err
220+ }
221+ i := res.Issue
222+ now := s.cfg.Now()
223+ signed := SignedIn(ctx)
224+ self := issueHref(d.name(), n)
225+
226+ p := IssuePage{
227+ Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
228+ State: string(i.State), Open: i.IsOpen(),
229+ }
230+ if body := i.Body.String(); strings.TrimSpace(body) != "" {
231+ h, _, err := s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
232+ if err != nil {
233+ return nil, 0, err
234+ }
235+ p.Desc = h
236+ }
237+ p.Opened.Name = s.who(i.Author)
238+ p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt)
239+ if !i.CreatedAt.IsZero() {
240+ p.Opened.Abs = absolute(i.CreatedAt)
241+ }
242+ p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
243+ for _, c := range res.Comments {
244+ v := CommentView{
245+ ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
246+ }
247+ v.When, v.WhenAt = whenOf(now, c.CreatedAt)
248+ if !c.CreatedAt.IsZero() {
249+ v.Abs = absolute(c.CreatedAt)
250+ }
251+ p.Comments = append(p.Comments, v)
252+ }
253+ if signed {
254+ p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue"}
255+ switch i.State {
256+ case domain.IssueStateOpen:
257+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
258+ case domain.IssueStateClosed:
259+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
260+ }
261+ }
262+
263+ l := s.repoLayout(ctx, d, "issues")
264+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
265+ l.Crumbs[0].Current = false
266+ l.Crumbs = append(l.Crumbs,
267+ Crumb{Label: "issues", Href: issuesBase(d.name())},
268+ Crumb{Label: p.Number, Href: self, Current: true},
269+ )
270+ p.Live = true
271+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
272+ l.HasRail = true
273+ return IssueShow(l, p), http.StatusOK, nil
274+}
275+
276+// pageOrRedirect is page(), except that a page that is not found may be the
277+// page of the other kind of the number: target then names where to send the
278+// visitor ("" when there is none).
279+func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
280+ plain := s.page(h)
281+ if s.cfg.IssueNumbers == nil {
282+ return plain
283+ }
284+ return func(w http.ResponseWriter, r *http.Request) {
285+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
286+ defer cancel()
287+ c, status, err := h(ctx, r)
288+ if errors.Is(err, domain.ErrNotFound) {
289+ to, terr := target(ctx, r)
290+ switch {
291+ case terr != nil && !errors.Is(terr, domain.ErrNotFound):
292+ s.fail(ctx, w, r, terr)
293+ return
294+ case terr == nil && to != "":
295+ seeOther(w, r, to)
296+ return
297+ }
298+ }
299+ if err != nil {
300+ s.fail(ctx, w, r, err)
301+ return
302+ }
303+ s.write(ctx, w, r, status, c)
304+ }
305+}
306+
307+// numberKind asks what the number of the path names.
308+func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
309+ n, err := mrNumber(r)
310+ if err != nil {
311+ return domain.NumberNone, 0, err
312+ }
313+ name, err := domain.ParseRepoName(r.PathValue("repo"))
314+ if err != nil {
315+ return domain.NumberNone, 0, err
316+ }
317+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
318+ return kind, n, err
319+}
320+
321+// issueToMR is where the number of an issue URL goes when it names a merge
322+// request.
323+func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
324+ kind, n, err := s.numberKind(ctx, r)
325+ if err != nil || kind != domain.NumberMergeRequest {
326+ return "", err
327+ }
328+ return mrHref(r.PathValue("repo"), n), nil
329+}
330+
331+// mrToIssue is where the number of a merge request URL goes when it names an
332+// issue.
333+func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
334+ kind, n, err := s.numberKind(ctx, r)
335+ if err != nil || kind != domain.NumberIssue {
336+ return "", err
337+ }
338+ return issueHref(r.PathValue("repo"), n), nil
339+}
@@ -0,0 +1,339 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
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.
36+func issuesBase(repo string) string { return repoHref(repo, "issues") }
37+
38+func 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.
43+type 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.
55+type 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.
66+type IssueActions struct {
67+ Action string // the URL that closes or reopens
68+ Label string
69+}
70+
71+// IssuePage is an issue with its comments.
72+type 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.
89+type 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.
95+func (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+func (s *Server) issueRow(repo string, i domain.Issue) IssueRow {
107+ row := IssueRow{
108+ Number: "#" + strconv.FormatInt(i.Number, 10), Href: issueHref(repo, i.Number),
109+ Title: visibleText(i.Title.String()), State: string(i.State), Author: s.who(i.Author),
110+ }
111+ ts := i.UpdatedAt
112+ if ts.IsZero() {
113+ ts = i.CreatedAt
114+ }
115+ row.When, row.WhenAt = whenOf(s.cfg.Now(), ts)
116+ return row
117+}
118+
119+// issueList lists the issues of one state, a page at a time.
120+func (s *Server) issueList(ctx context.Context, r *http.Request) (templ.Component, int, error) {
121+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
122+ if err != nil {
123+ return nil, 0, err
124+ }
125+ state := domain.IssueStateOpen
126+ switch r.URL.Query().Get("state") {
127+ case "", "open":
128+ case "closed":
129+ state = domain.IssueStateClosed
130+ default:
131+ return nil, 0, errNotFound
132+ }
133+ token := r.URL.Query().Get("page")
134+ base := issuesBase(d.name())
135+ closed := state == domain.IssueStateClosed
136+ link := func(page string) string {
137+ q := url.Values{}
138+ if closed {
139+ q.Set("state", "closed")
140+ }
141+ if page != "" {
142+ q.Set("page", page)
143+ }
144+ if len(q) == 0 {
145+ return base
146+ }
147+ return base + "?" + q.Encode()
148+ }
149+
150+ res, err := s.cfg.Issues.List(ctx, principal(ctx), d.repo.Name, ports.ListIssuesParams{
151+ State: state, PageSize: pageSize, PageToken: token,
152+ })
153+ if err != nil {
154+ return nil, 0, err
155+ }
156+ p := IssueListPage{Closed: closed, OpenHref: base, ClosedHref: base + "?state=closed"}
157+ for _, i := range res.Issues {
158+ p.Rows = append(p.Rows, s.issueRow(d.name(), i))
159+ }
160+ if len(p.Rows) > 0 {
161+ p.Rows[0].Cursor = true
162+ }
163+ if res.NextPageToken != "" {
164+ p.Older = link(res.NextPageToken)
165+ }
166+ if token != "" {
167+ p.Newest = link("")
168+ }
169+ if SignedIn(ctx) {
170+ p.NewHref = issuesBase(d.name()) + "/new"
171+ }
172+
173+ l := s.repoLayout(ctx, d, "issues")
174+ l.Title = "issues · " + d.name() + " · " + s.instance
175+ l.Crumbs[0].Current = false
176+ l.Crumbs = append(l.Crumbs, Crumb{Label: "issues", Current: true})
177+ if token == "" {
178+ p.Live = true
179+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issues")
180+ }
181+ l.HasRail = true
182+ return IssueList(l, p), http.StatusOK, nil
183+}
184+
185+// issueNew is the form that opens an issue; it is the owner's.
186+func (s *Server) issueNew(ctx context.Context, r *http.Request) (templ.Component, int, error) {
187+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
188+ if err != nil {
189+ return nil, 0, err
190+ }
191+ if !SignedIn(ctx) {
192+ return nil, 0, errNotFound
193+ }
194+ l := s.repoLayout(ctx, d, "issues")
195+ l.Title = "new issue · " + d.name() + " · " + s.instance
196+ l.Crumbs[0].Current = false
197+ l.Crumbs = append(l.Crumbs,
198+ Crumb{Label: "issues", Href: issuesBase(d.name())},
199+ Crumb{Label: "new", Current: true},
200+ )
201+ l.HasRail = true
202+ return IssueNew(l, IssueNewPage{Action: issuesBase(d.name()), Cancel: issuesBase(d.name())}), http.StatusOK, nil
203+}
204+
205+// issueShow shows an issue with its comments.
206+//
207+//hexcheck:ignore suspicious-business-logic-in-adapter builds the issue page from the use case results; no rule is decided here
208+func (s *Server) issueShow(ctx context.Context, r *http.Request) (templ.Component, int, error) {
209+ n, err := mrNumber(r)
210+ if err != nil {
211+ return nil, 0, err
212+ }
213+ d, err := s.issueRepo(ctx, r.PathValue("repo"))
214+ if err != nil {
215+ return nil, 0, err
216+ }
217+ res, err := s.cfg.Issues.Get(ctx, principal(ctx), d.repo.Name, n)
218+ if err != nil {
219+ return nil, 0, err
220+ }
221+ i := res.Issue
222+ now := s.cfg.Now()
223+ signed := SignedIn(ctx)
224+ self := issueHref(d.name(), n)
225+
226+ p := IssuePage{
227+ Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(i.Title.String()),
228+ State: string(i.State), Open: i.IsOpen(),
229+ }
230+ if body := i.Body.String(); strings.TrimSpace(body) != "" {
231+ h, _, err := s.md.renderWithIDs([]byte(body), d.name(), d.repo.DefaultBranch, "", "issue-desc-")
232+ if err != nil {
233+ return nil, 0, err
234+ }
235+ p.Desc = h
236+ }
237+ p.Opened.Name = s.who(i.Author)
238+ p.Opened.When, p.Opened.WhenAt = whenOf(now, i.CreatedAt)
239+ if !i.CreatedAt.IsZero() {
240+ p.Opened.Abs = absolute(i.CreatedAt)
241+ }
242+ p.Updated, p.UpdatedAt = whenOf(now, i.UpdatedAt)
243+ for _, c := range res.Comments {
244+ v := CommentView{
245+ ID: strconv.FormatInt(c.ID, 10), Author: s.who(c.Author), Parts: plainParts(c.Text.String()),
246+ }
247+ v.When, v.WhenAt = whenOf(now, c.CreatedAt)
248+ if !c.CreatedAt.IsZero() {
249+ v.Abs = absolute(c.CreatedAt)
250+ }
251+ p.Comments = append(p.Comments, v)
252+ }
253+ if signed {
254+ p.Form = &CommentForm{ID: "cf-general", Action: issueHref(d.name(), n, "comments"), Title: "comment on the issue"}
255+ switch i.State {
256+ case domain.IssueStateOpen:
257+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "close"), Label: "Close issue"}
258+ case domain.IssueStateClosed:
259+ p.Transition = &IssueActions{Action: issueHref(d.name(), n, "reopen"), Label: "Reopen issue"}
260+ }
261+ }
262+
263+ l := s.repoLayout(ctx, d, "issues")
264+ l.Title = p.Number + " " + p.Title + " · " + d.name() + " · " + s.instance
265+ l.Crumbs[0].Current = false
266+ l.Crumbs = append(l.Crumbs,
267+ Crumb{Label: "issues", Href: issuesBase(d.name())},
268+ Crumb{Label: p.Number, Href: self, Current: true},
269+ )
270+ p.Live = true
271+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=issue&n="+strconv.FormatInt(n, 10))
272+ l.HasRail = true
273+ return IssueShow(l, p), http.StatusOK, nil
274+}
275+
276+// pageOrRedirect is page(), except that a page that is not found may be the
277+// page of the other kind of the number: target then names where to send the
278+// visitor ("" when there is none).
279+func (s *Server) pageOrRedirect(h handler, target func(ctx context.Context, r *http.Request) (string, error)) http.HandlerFunc {
280+ plain := s.page(h)
281+ if s.cfg.IssueNumbers == nil {
282+ return plain
283+ }
284+ return func(w http.ResponseWriter, r *http.Request) {
285+ ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
286+ defer cancel()
287+ c, status, err := h(ctx, r)
288+ if errors.Is(err, domain.ErrNotFound) {
289+ to, terr := target(ctx, r)
290+ switch {
291+ case terr != nil && !errors.Is(terr, domain.ErrNotFound):
292+ s.fail(ctx, w, r, terr)
293+ return
294+ case terr == nil && to != "":
295+ seeOther(w, r, to)
296+ return
297+ }
298+ }
299+ if err != nil {
300+ s.fail(ctx, w, r, err)
301+ return
302+ }
303+ s.write(ctx, w, r, status, c)
304+ }
305+}
306+
307+// numberKind asks what the number of the path names.
308+func (s *Server) numberKind(ctx context.Context, r *http.Request) (domain.NumberKind, int64, error) {
309+ n, err := mrNumber(r)
310+ if err != nil {
311+ return domain.NumberNone, 0, err
312+ }
313+ name, err := domain.ParseRepoName(r.PathValue("repo"))
314+ if err != nil {
315+ return domain.NumberNone, 0, err
316+ }
317+ kind, err := s.cfg.IssueNumbers.Kind(ctx, principal(ctx), name, n)
318+ return kind, n, err
319+}
320+
321+// issueToMR is where the number of an issue URL goes when it names a merge
322+// request.
323+func (s *Server) issueToMR(ctx context.Context, r *http.Request) (string, error) {
324+ kind, n, err := s.numberKind(ctx, r)
325+ if err != nil || kind != domain.NumberMergeRequest {
326+ return "", err
327+ }
328+ return mrHref(r.PathValue("repo"), n), nil
329+}
330+
331+// mrToIssue is where the number of a merge request URL goes when it names an
332+// issue.
333+func (s *Server) mrToIssue(ctx context.Context, r *http.Request) (string, error) {
334+ kind, n, err := s.numberKind(ctx, r)
335+ if err != nil || kind != domain.NumberIssue {
336+ return "", err
337+ }
338+ return issueHref(r.PathValue("repo"), n), nil
339+}
A internal/adapters/web/issues.templ +187 -0
@@ -0,0 +1,187 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueList is the list of the issues of a repository, open or closed.
6+templ IssueList(l Layout, p IssueListPage) {
7+ @Shell(l, issueListRail(p)) {
8+ if p.Live {
9+ @LiveSlot("mr-notice")
10+ }
11+ <nav class="since" aria-label="State">
12+ <a
13+ href={ templ.SafeURL(p.OpenHref) }
14+ if !p.Closed {
15+ aria-current="page"
16+ }
17+ >open</a>
18+ <a
19+ href={ templ.SafeURL(p.ClosedHref) }
20+ if p.Closed {
21+ aria-current="page"
22+ }
23+ >closed</a>
24+ </nav>
25+ <div class="filter">
26+ <label for="issue-filter" class="sr">filter</label>
27+ <input
28+ id="issue-filter"
29+ type="search"
30+ data-filter=".mrs tbody tr"
31+ placeholder="Filter by title, author or state…"
32+ autocomplete="off"
33+ hx-on:input="ermite.filter(this)"
34+ />
35+ </div>
36+ <h2 class="rule-h">
37+ if p.Closed {
38+ <span>Closed</span>
39+ } else {
40+ <span>Open</span>
41+ }
42+ <span class="count">{ itoa(len(p.Rows)) }</span>
43+ </h2>
44+ @issueRows(p.Rows)
45+ if len(p.Rows) == 0 {
46+ if p.Closed {
47+ <p class="lede dim">no closed issues.</p>
48+ } else {
49+ <p class="lede dim">no open issues.</p>
50+ }
51+ }
52+ if p.Older != "" || p.Newest != "" {
53+ <nav class="pager" aria-label="Pages">
54+ if p.Newest != "" {
55+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
56+ } else {
57+ <span class="btn" aria-disabled="true">← Newest</span>
58+ }
59+ if p.Older != "" {
60+ <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
61+ } else {
62+ <span class="btn" aria-disabled="true">Older →</span>
63+ }
64+ </nav>
65+ }
66+ }
67+}
68+
69+templ issueRows(rows []IssueRow) {
70+ if len(rows) > 0 {
71+ <table class="list mrs" aria-label="Issues">
72+ <tbody data-rows>
73+ for _, i := range rows {
74+ <tr data-cursor?={ i.Cursor } data-state={ i.State }>
75+ <td class="c-n mute">{ i.Number }</td>
76+ <td class="c-state">{ i.State }</td>
77+ <td class="c-subject"><a href={ templ.SafeURL(i.Href) }>{ i.Title }</a></td>
78+ <td class="c-who dim">{ i.Author }</td>
79+ <td class="c-when nowrap dim">
80+ if i.WhenAt != "" {
81+ <time datetime={ i.WhenAt } title={ i.WhenAt }>{ i.When }</time>
82+ }
83+ </td>
84+ </tr>
85+ }
86+ </tbody>
87+ </table>
88+ }
89+}
90+
91+templ issueListRail(p IssueListPage) {
92+ <section aria-labelledby="r-states">
93+ <h2 id="r-states">States</h2>
94+ <dl class="kv">
95+ <dt>open</dt>
96+ <dd>being worked on</dd>
97+ <dt>closed</dt>
98+ <dd>done with, can be reopened</dd>
99+ </dl>
100+ </section>
101+ if p.NewHref != "" {
102+ <section aria-labelledby="r-new">
103+ <h2 id="r-new">Open one</h2>
104+ <a class="btn primary" href={ templ.SafeURL(p.NewHref) }>New issue</a>
105+ </section>
106+ }
107+}
108+
109+// IssueNew is the form that opens an issue.
110+templ IssueNew(l Layout, p IssueNewPage) {
111+ @Shell(l, issueNewRail()) {
112+ <header class="show mr-h">
113+ <h1>New issue</h1>
114+ </header>
115+ <form class="cform create" method="post" action={ templ.SafeURL(p.Action) }>
116+ <label for="issue-title">title</label>
117+ <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" autofocus/>
118+ <label for="issue-body">description</label>
119+ <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional"></textarea>
120+ <div class="f-btns">
121+ <button type="submit" class="btn primary">Create issue</button>
122+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
123+ </div>
124+ </form>
125+ }
126+}
127+
128+templ issueNewRail() {
129+ <section aria-labelledby="r-about">
130+ <h2 id="r-about">Issues</h2>
131+ <p class="dim">issues share their numbers with the merge requests of the repository.</p>
132+ </section>
133+}
134+
135+// IssueShow is an issue: its facts, its body, its comments and, for the
136+// owner, the form to comment and the button that closes or reopens it.
137+templ IssueShow(l Layout, p IssuePage) {
138+ @Shell(l, issueRail(p)) {
139+ <header class="show mr-h">
140+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
141+ <dl class="kv show-kv">
142+ <dt>state</dt>
143+ <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>
144+ <dt>opened</dt>
145+ <dd>
146+ { p.Opened.Name }
147+ if p.Opened.WhenAt != "" {
148+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
149+ }
150+ </dd>
151+ if p.UpdatedAt != "" {
152+ <dt>updated</dt>
153+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
154+ }
155+ </dl>
156+ </header>
157+ if p.Live {
158+ @LiveSlot("mr-notice")
159+ }
160+ if p.Desc != "" {
161+ <article class="mr-desc prose" aria-label="description" hx-ignore>
162+ @templ.Raw(string(p.Desc))
163+ </article>
164+ }
165+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
166+ for _, c := range p.Comments {
167+ @commentView(c, "")
168+ }
169+ if len(p.Comments) == 0 {
170+ <p class="dim">no comments yet.</p>
171+ }
172+ if p.Form != nil {
173+ @commentForm(*p.Form, "")
174+ }
175+ }
176+}
177+
178+templ issueRail(p IssuePage) {
179+ if p.Transition != nil {
180+ <section aria-labelledby="r-act">
181+ <h2 id="r-act">Issue</h2>
182+ <form method="post" action={ templ.SafeURL(p.Transition.Action) }>
183+ <button type="submit" class="btn">{ p.Transition.Label }</button>
184+ </form>
185+ </section>
186+ }
187+}
@@ -0,0 +1,187 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// IssueList is the list of the issues of a repository, open or closed.
6+templ IssueList(l Layout, p IssueListPage) {
7+ @Shell(l, issueListRail(p)) {
8+ if p.Live {
9+ @LiveSlot("mr-notice")
10+ }
11+ <nav class="since" aria-label="State">
12+ <a
13+ href={ templ.SafeURL(p.OpenHref) }
14+ if !p.Closed {
15+ aria-current="page"
16+ }
17+ >open</a>
18+ <a
19+ href={ templ.SafeURL(p.ClosedHref) }
20+ if p.Closed {
21+ aria-current="page"
22+ }
23+ >closed</a>
24+ </nav>
25+ <div class="filter">
26+ <label for="issue-filter" class="sr">filter</label>
27+ <input
28+ id="issue-filter"
29+ type="search"
30+ data-filter=".mrs tbody tr"
31+ placeholder="Filter by title, author or state…"
32+ autocomplete="off"
33+ hx-on:input="ermite.filter(this)"
34+ />
35+ </div>
36+ <h2 class="rule-h">
37+ if p.Closed {
38+ <span>Closed</span>
39+ } else {
40+ <span>Open</span>
41+ }
42+ <span class="count">{ itoa(len(p.Rows)) }</span>
43+ </h2>
44+ @issueRows(p.Rows)
45+ if len(p.Rows) == 0 {
46+ if p.Closed {
47+ <p class="lede dim">no closed issues.</p>
48+ } else {
49+ <p class="lede dim">no open issues.</p>
50+ }
51+ }
52+ if p.Older != "" || p.Newest != "" {
53+ <nav class="pager" aria-label="Pages">
54+ if p.Newest != "" {
55+ <a class="btn" href={ templ.SafeURL(p.Newest) }>← Newest</a>
56+ } else {
57+ <span class="btn" aria-disabled="true">← Newest</span>
58+ }
59+ if p.Older != "" {
60+ <a class="btn" href={ templ.SafeURL(p.Older) } rel="next">Older →</a>
61+ } else {
62+ <span class="btn" aria-disabled="true">Older →</span>
63+ }
64+ </nav>
65+ }
66+ }
67+}
68+
69+templ issueRows(rows []IssueRow) {
70+ if len(rows) > 0 {
71+ <table class="list mrs" aria-label="Issues">
72+ <tbody data-rows>
73+ for _, i := range rows {
74+ <tr data-cursor?={ i.Cursor } data-state={ i.State }>
75+ <td class="c-n mute">{ i.Number }</td>
76+ <td class="c-state">{ i.State }</td>
77+ <td class="c-subject"><a href={ templ.SafeURL(i.Href) }>{ i.Title }</a></td>
78+ <td class="c-who dim">{ i.Author }</td>
79+ <td class="c-when nowrap dim">
80+ if i.WhenAt != "" {
81+ <time datetime={ i.WhenAt } title={ i.WhenAt }>{ i.When }</time>
82+ }
83+ </td>
84+ </tr>
85+ }
86+ </tbody>
87+ </table>
88+ }
89+}
90+
91+templ issueListRail(p IssueListPage) {
92+ <section aria-labelledby="r-states">
93+ <h2 id="r-states">States</h2>
94+ <dl class="kv">
95+ <dt>open</dt>
96+ <dd>being worked on</dd>
97+ <dt>closed</dt>
98+ <dd>done with, can be reopened</dd>
99+ </dl>
100+ </section>
101+ if p.NewHref != "" {
102+ <section aria-labelledby="r-new">
103+ <h2 id="r-new">Open one</h2>
104+ <a class="btn primary" href={ templ.SafeURL(p.NewHref) }>New issue</a>
105+ </section>
106+ }
107+}
108+
109+// IssueNew is the form that opens an issue.
110+templ IssueNew(l Layout, p IssueNewPage) {
111+ @Shell(l, issueNewRail()) {
112+ <header class="show mr-h">
113+ <h1>New issue</h1>
114+ </header>
115+ <form class="cform create" method="post" action={ templ.SafeURL(p.Action) }>
116+ <label for="issue-title">title</label>
117+ <input id="issue-title" name="title" type="text" maxlength="120" required autocomplete="off" autofocus/>
118+ <label for="issue-body">description</label>
119+ <textarea id="issue-body" name="body" rows="8" maxlength="10000" placeholder="Markdown, optional"></textarea>
120+ <div class="f-btns">
121+ <button type="submit" class="btn primary">Create issue</button>
122+ <a class="btn" href={ templ.SafeURL(p.Cancel) }>Cancel</a>
123+ </div>
124+ </form>
125+ }
126+}
127+
128+templ issueNewRail() {
129+ <section aria-labelledby="r-about">
130+ <h2 id="r-about">Issues</h2>
131+ <p class="dim">issues share their numbers with the merge requests of the repository.</p>
132+ </section>
133+}
134+
135+// IssueShow is an issue: its facts, its body, its comments and, for the
136+// owner, the form to comment and the button that closes or reopens it.
137+templ IssueShow(l Layout, p IssuePage) {
138+ @Shell(l, issueRail(p)) {
139+ <header class="show mr-h">
140+ <h1><span class="mute">{ p.Number }</span> { p.Title }</h1>
141+ <dl class="kv show-kv">
142+ <dt>state</dt>
143+ <dd class="mr-state" data-state={ p.State }>{ p.State }</dd>
144+ <dt>opened</dt>
145+ <dd>
146+ { p.Opened.Name }
147+ if p.Opened.WhenAt != "" {
148+ { " · " }<time datetime={ p.Opened.WhenAt } title={ p.Opened.Abs }>{ p.Opened.When }</time>
149+ }
150+ </dd>
151+ if p.UpdatedAt != "" {
152+ <dt>updated</dt>
153+ <dd><time datetime={ p.UpdatedAt }>{ p.Updated }</time></dd>
154+ }
155+ </dl>
156+ </header>
157+ if p.Live {
158+ @LiveSlot("mr-notice")
159+ }
160+ if p.Desc != "" {
161+ <article class="mr-desc prose" aria-label="description" hx-ignore>
162+ @templ.Raw(string(p.Desc))
163+ </article>
164+ }
165+ <h2 class="rule-h" id="comments"><span>Comments</span><span class="count">{ itoa(len(p.Comments)) }</span></h2>
166+ for _, c := range p.Comments {
167+ @commentView(c, "")
168+ }
169+ if len(p.Comments) == 0 {
170+ <p class="dim">no comments yet.</p>
171+ }
172+ if p.Form != nil {
173+ @commentForm(*p.Form, "")
174+ }
175+ }
176+}
177+
178+templ issueRail(p IssuePage) {
179+ if p.Transition != nil {
180+ <section aria-labelledby="r-act">
181+ <h2 id="r-act">Issue</h2>
182+ <form method="post" action={ templ.SafeURL(p.Transition.Action) }>
183+ <button type="submit" class="btn">{ p.Transition.Label }</button>
184+ </form>
185+ </section>
186+ }
187+}
A internal/adapters/web/issues_post.go +85 -0
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strconv"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// The writes of the issue pages follow those of the merge request pages
15+// (mr_post.go): each is a POST that passed the gate (an Origin of this site
16+// and the owner's session), calls the use case as the owner and answers 303
17+// to the page that shows the result, "Cache-Control: no-store".
18+
19+// issueAction wraps a POST handler like mrAction does.
20+func (s *Server) issueAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
21+ return s.mrAction(h)
22+}
23+
24+// issueCreate is POST /<repo>/issues: it opens an issue.
25+func (s *Server) issueCreate(ctx context.Context, r *http.Request) (string, string, error) {
26+ repo := r.PathValue("repo")
27+ back := issuesBase(repo) + "/new"
28+ name, err := domain.ParseRepoName(repo)
29+ if err != nil {
30+ return "", back, err
31+ }
32+ i, err := s.cfg.Issues.Create(ctx, principal(ctx), name, ports.CreateIssueParams{
33+ Title: r.PostFormValue("title"), Body: normalizeNewlines(r.PostFormValue("body")),
34+ })
35+ if err != nil {
36+ return "", back, err
37+ }
38+ return issueHref(repo, i.Number), back, nil
39+}
40+
41+// issueComment is POST /<repo>/issues/<n>/comments.
42+func (s *Server) issueComment(ctx context.Context, r *http.Request) (string, string, error) {
43+ repo := r.PathValue("repo")
44+ n, err := number(r)
45+ if err != nil {
46+ return "", "", err
47+ }
48+ back := issueHref(repo, n)
49+ name, err := domain.ParseRepoName(repo)
50+ if err != nil {
51+ return "", back, err
52+ }
53+ c, err := s.cfg.Issues.AddComment(ctx, principal(ctx), name, n, normalizeNewlines(r.PostFormValue("text")))
54+ if err != nil {
55+ return "", back, err
56+ }
57+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
58+}
59+
60+// issueClose is POST /<repo>/issues/<n>/close.
61+func (s *Server) issueClose(ctx context.Context, r *http.Request) (string, string, error) {
62+ return s.issueTransition(ctx, r, s.cfg.Issues.Close)
63+}
64+
65+// issueReopen is POST /<repo>/issues/<n>/reopen.
66+func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, string, error) {
67+ return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)
68+}
69+
70+func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) {
71+ repo := r.PathValue("repo")
72+ n, err := number(r)
73+ if err != nil {
74+ return "", "", err
75+ }
76+ back := issueHref(repo, n)
77+ name, err := domain.ParseRepoName(repo)
78+ if err != nil {
79+ return "", back, err
80+ }
81+ if _, err := do(ctx, principal(ctx), name, n); err != nil {
82+ return "", back, err
83+ }
84+ return back, back, nil
85+}
@@ -0,0 +1,85 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "strconv"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+// The writes of the issue pages follow those of the merge request pages
15+// (mr_post.go): each is a POST that passed the gate (an Origin of this site
16+// and the owner's session), calls the use case as the owner and answers 303
17+// to the page that shows the result, "Cache-Control: no-store".
18+
19+// issueAction wraps a POST handler like mrAction does.
20+func (s *Server) issueAction(h func(ctx context.Context, r *http.Request) (to string, back string, err error)) http.HandlerFunc {
21+ return s.mrAction(h)
22+}
23+
24+// issueCreate is POST /<repo>/issues: it opens an issue.
25+func (s *Server) issueCreate(ctx context.Context, r *http.Request) (string, string, error) {
26+ repo := r.PathValue("repo")
27+ back := issuesBase(repo) + "/new"
28+ name, err := domain.ParseRepoName(repo)
29+ if err != nil {
30+ return "", back, err
31+ }
32+ i, err := s.cfg.Issues.Create(ctx, principal(ctx), name, ports.CreateIssueParams{
33+ Title: r.PostFormValue("title"), Body: normalizeNewlines(r.PostFormValue("body")),
34+ })
35+ if err != nil {
36+ return "", back, err
37+ }
38+ return issueHref(repo, i.Number), back, nil
39+}
40+
41+// issueComment is POST /<repo>/issues/<n>/comments.
42+func (s *Server) issueComment(ctx context.Context, r *http.Request) (string, string, error) {
43+ repo := r.PathValue("repo")
44+ n, err := number(r)
45+ if err != nil {
46+ return "", "", err
47+ }
48+ back := issueHref(repo, n)
49+ name, err := domain.ParseRepoName(repo)
50+ if err != nil {
51+ return "", back, err
52+ }
53+ c, err := s.cfg.Issues.AddComment(ctx, principal(ctx), name, n, normalizeNewlines(r.PostFormValue("text")))
54+ if err != nil {
55+ return "", back, err
56+ }
57+ return back + "#c" + strconv.FormatInt(c.ID, 10), back, nil
58+}
59+
60+// issueClose is POST /<repo>/issues/<n>/close.
61+func (s *Server) issueClose(ctx context.Context, r *http.Request) (string, string, error) {
62+ return s.issueTransition(ctx, r, s.cfg.Issues.Close)
63+}
64+
65+// issueReopen is POST /<repo>/issues/<n>/reopen.
66+func (s *Server) issueReopen(ctx context.Context, r *http.Request) (string, string, error) {
67+ return s.issueTransition(ctx, r, s.cfg.Issues.Reopen)
68+}
69+
70+func (s *Server) issueTransition(ctx context.Context, r *http.Request, do func(context.Context, domain.Principal, domain.RepoName, int64) (domain.Issue, error)) (string, string, error) {
71+ repo := r.PathValue("repo")
72+ n, err := number(r)
73+ if err != nil {
74+ return "", "", err
75+ }
76+ back := issueHref(repo, n)
77+ name, err := domain.ParseRepoName(repo)
78+ if err != nil {
79+ return "", back, err
80+ }
81+ if _, err := do(ctx, principal(ctx), name, n); err != nil {
82+ return "", back, err
83+ }
84+ return back, back, nil
85+}
A internal/adapters/web/issues_templ.go +885 -0 generated file: collapsed
A internal/adapters/web/issues_test.go +332 -0
@@ -0,0 +1,332 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23+ t.Helper()
24+ v := publicRepo(t, "proj")
25+ v.Settings.Issues = mode
26+ return v
27+}
28+
29+func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30+ t.Helper()
31+ ti, err := domain.ParseIssueTitle(title)
32+ require.NoError(t, err)
33+ b, err := domain.ParseIssueBody(body)
34+ require.NoError(t, err)
35+ return domain.Issue{
36+ Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37+ CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38+ }
39+}
40+
41+type issueRig struct {
42+ srv *Server
43+ issues *mocks.IssueService
44+ nums *mocks.IssueNumberResolver
45+ mrs *mocks.MergeRequestService
46+}
47+
48+// newIssueRig serves proj with the given mode, signed in as the owner when
49+// the cookie "tok" is sent.
50+func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51+ t.Helper()
52+ r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53+ browse := mocks.NewBrowseService(t)
54+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56+ cfg := Config{
57+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59+ }
60+ if withNumbers {
61+ r.nums = mocks.NewIssueNumberResolver(t)
62+ cfg.IssueNumbers = r.nums
63+ }
64+ r.srv = newTestServer(t, cfg)
65+ return r
66+}
67+
68+func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69+ var body *strings.Reader
70+ if form != nil {
71+ body = strings.NewReader(form.Encode())
72+ } else {
73+ body = strings.NewReader("")
74+ }
75+ req := httptest.NewRequest(method, path, body)
76+ if method == http.MethodPost {
77+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78+ req.Header.Set("Origin", testPublicURL)
79+ }
80+ if owner {
81+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82+ }
83+ rec := httptest.NewRecorder()
84+ r.srv.ServeHTTP(rec, req)
85+ return rec
86+}
87+
88+func TestIssueList(t *testing.T) {
89+ t.Parallel()
90+ r := newIssueRig(t, domain.IssuesOwner, false)
91+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95+
96+ rec := r.do("GET", "/proj/issues", false, nil)
97+ require.Equal(t, 200, rec.Code, rec.Body.String())
98+ body := rec.Body.String()
99+ assert.Contains(t, body, `href="/proj/issues/3"`)
100+ assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101+ assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102+ assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
104+ assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106+
107+ closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108+ require.Equal(t, 200, closed.Code)
109+ assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110+
111+ assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112+
113+ owner := r.do("GET", "/proj/issues", true, nil)
114+ require.Equal(t, 200, owner.Code)
115+ assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116+ assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117+}
118+
119+func TestIssueShow(t *testing.T) {
120+ t.Parallel()
121+ r := newIssueRig(t, domain.IssuesPublic, false)
122+ c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123+ c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126+ Comments: []domain.IssueComment{c},
127+ }, nil)
128+
129+ anon := r.do("GET", "/proj/issues/5", false, nil)
130+ require.Equal(t, 200, anon.Code, anon.Body.String())
131+ body := anon.Body.String()
132+ assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133+ assert.NotContains(t, body, "<script>alert")
134+ assert.Contains(t, body, `id="c1"`)
135+ assert.Contains(t, body, `href="https://example.org"`)
136+ assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137+ assert.NotContains(t, body, `class="cform"`)
138+ assert.NotContains(t, body, "/proj/issues/5/close")
139+ assert.Contains(t, body, `view=issue`)
140+
141+ owner := r.do("GET", "/proj/issues/5", true, nil)
142+ require.Equal(t, 200, owner.Code)
143+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146+
147+ assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148+ assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149+}
150+
151+func TestIssueShowClosedOffersReopen(t *testing.T) {
152+ t.Parallel()
153+ r := newIssueRig(t, domain.IssuesOwner, false)
154+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155+ Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156+ }, nil)
157+ owner := r.do("GET", "/proj/issues/5", true, nil)
158+ require.Equal(t, 200, owner.Code)
159+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161+}
162+
163+func TestIssueNotFound(t *testing.T) {
164+ t.Parallel()
165+ // Mode off: the pages and the tab are not there; the use case is not asked.
166+ off := newIssueRig(t, domain.IssuesOff, false)
167+ for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168+ assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169+ }
170+ summary := off.do("GET", "/proj", false, nil)
171+ require.Equal(t, 200, summary.Code)
172+ assert.NotContains(t, summary.Body.String(), `data-key="i"`)
173+
174+ // The mode is unset in the settings: off as well.
175+ unset := newIssueRig(t, "", false)
176+ assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177+
178+ // An unknown repository.
179+ assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180+
181+ // A private repository, for an anonymous visitor.
182+ priv := mocks.NewRepoService(t)
183+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184+ browse := mocks.NewBrowseService(t)
185+ srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186+ rec := httptest.NewRecorder()
187+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188+ assert.Equal(t, 404, rec.Code)
189+
190+ // The use case says not found for a number nobody has.
191+ r := newIssueRig(t, domain.IssuesOwner, false)
192+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193+ assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194+}
195+
196+func TestIssueNewForm(t *testing.T) {
197+ t.Parallel()
198+ r := newIssueRig(t, domain.IssuesOwner, false)
199+ assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200+ rec := r.do("GET", "/proj/issues/new", true, nil)
201+ require.Equal(t, 200, rec.Code)
202+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203+ assert.Contains(t, rec.Body.String(), `name="title"`)
204+}
205+
206+func TestIssuePosts(t *testing.T) {
207+ t.Parallel()
208+ r := newIssueRig(t, domain.IssuesOwner, false)
209+ name := repoName(t, "proj")
210+ r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211+ Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212+ r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213+ Return(domain.IssueComment{ID: 4}, nil)
214+ r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215+ r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216+
217+ rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218+ assert.Equal(t, 303, rec.Code)
219+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221+
222+ rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223+ assert.Equal(t, 303, rec.Code)
224+ assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225+
226+ rec = r.do("POST", "/proj/issues/7/close", true, nil)
227+ assert.Equal(t, 303, rec.Code)
228+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229+
230+ rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231+ assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233+}
234+
235+func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236+ t.Parallel()
237+ r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238+ for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239+ assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240+ req := httptest.NewRequest("POST", p, nil)
241+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242+ rec := httptest.NewRecorder()
243+ r.srv.ServeHTTP(rec, req)
244+ assert.Equal(t, 403, rec.Code, p+" without Origin")
245+ }
246+}
247+
248+func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249+ t.Parallel()
250+ browse := mocks.NewBrowseService(t)
251+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253+ for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254+ rec := httptest.NewRecorder()
255+ srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256+ assert.Equal(t, 404, rec.Code, p)
257+ }
258+ rec := httptest.NewRecorder()
259+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260+ assert.NotContains(t, rec.Body.String(), `data-key="i"`)
261+}
262+
263+func TestIssueNumberRedirects(t *testing.T) {
264+ t.Parallel()
265+ r := newIssueRig(t, domain.IssuesOwner, true)
266+ name := repoName(t, "proj")
267+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272+
273+ rec := r.do("GET", "/proj/issues/1", false, nil)
274+ assert.Equal(t, 303, rec.Code)
275+ assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277+
278+ rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279+ assert.Equal(t, 303, rec.Code)
280+ assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281+
282+ assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284+}
285+
286+func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287+ t.Parallel()
288+ r := newIssueRig(t, domain.IssuesOwner, false)
289+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291+ assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293+}
294+
295+func TestIssueLiveNotices(t *testing.T) {
296+ t.Parallel()
297+ r := newIssueRig(t, domain.IssuesOwner, false)
298+ issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299+ cases := []struct {
300+ ev domain.Event
301+ view string
302+ n string
303+ want string // "" : nothing
304+ }{
305+ {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306+ {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308+ {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309+ {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310+ {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311+ {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313+ {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314+ }
315+ for _, c := range cases {
316+ ev := ports.WatchEvent{Event: c.ev}
317+ assert.True(t, liveEvent(ev), c.ev.EventKind())
318+ got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
319+ require.NoError(t, err)
320+ if c.want == "" {
321+ assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
322+ continue
323+ }
324+ require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
325+ assert.Equal(t, "mr-notice", got[0].event)
326+ assert.Contains(t, got[0].html, c.want)
327+ }
328+ // MR events do not touch the issue pages.
329+ got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
330+ require.NoError(t, err)
331+ assert.Empty(t, got)
332+}
@@ -0,0 +1,332 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "net/url"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+)
21+
22+func issueRepoView(t testing.TB, mode domain.IssuesMode) ports.RepoView {
23+ t.Helper()
24+ v := publicRepo(t, "proj")
25+ v.Settings.Issues = mode
26+ return v
27+}
28+
29+func testIssue(t testing.TB, n int64, state domain.IssueState, title, body string) domain.Issue {
30+ t.Helper()
31+ ti, err := domain.ParseIssueTitle(title)
32+ require.NoError(t, err)
33+ b, err := domain.ParseIssueBody(body)
34+ require.NoError(t, err)
35+ return domain.Issue{
36+ Repo: repoName(t, "proj"), Number: n, Title: ti, Body: b, State: state, Author: "owner",
37+ CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now().Add(-time.Minute),
38+ }
39+}
40+
41+type issueRig struct {
42+ srv *Server
43+ issues *mocks.IssueService
44+ nums *mocks.IssueNumberResolver
45+ mrs *mocks.MergeRequestService
46+}
47+
48+// newIssueRig serves proj with the given mode, signed in as the owner when
49+// the cookie "tok" is sent.
50+func newIssueRig(t *testing.T, mode domain.IssuesMode, withNumbers bool) *issueRig {
51+ t.Helper()
52+ r := &issueRig{issues: mocks.NewIssueService(t), mrs: mocks.NewMergeRequestService(t)}
53+ browse := mocks.NewBrowseService(t)
54+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
55+ r.mrs.EXPECT().CountOpen(mock.Anything, mock.Anything, mock.Anything).Return(0, nil).Maybe()
56+ cfg := Config{
57+ Repos: reposServing(t, issueRepoView(t, mode)), Browse: browse, MergeRequests: r.mrs, Issues: r.issues,
58+ Sessions: newSessions(t, "tok"), Limiter: &countingLimiter{},
59+ }
60+ if withNumbers {
61+ r.nums = mocks.NewIssueNumberResolver(t)
62+ cfg.IssueNumbers = r.nums
63+ }
64+ r.srv = newTestServer(t, cfg)
65+ return r
66+}
67+
68+func (r *issueRig) do(method, path string, owner bool, form url.Values) *httptest.ResponseRecorder {
69+ var body *strings.Reader
70+ if form != nil {
71+ body = strings.NewReader(form.Encode())
72+ } else {
73+ body = strings.NewReader("")
74+ }
75+ req := httptest.NewRequest(method, path, body)
76+ if method == http.MethodPost {
77+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
78+ req.Header.Set("Origin", testPublicURL)
79+ }
80+ if owner {
81+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
82+ }
83+ rec := httptest.NewRecorder()
84+ r.srv.ServeHTTP(rec, req)
85+ return rec
86+}
87+
88+func TestIssueList(t *testing.T) {
89+ t.Parallel()
90+ r := newIssueRig(t, domain.IssuesOwner, false)
91+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateOpen, PageSize: pageSize}).
92+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 3, domain.IssueStateOpen, "crash <b>on</b> start", "")}, NextPageToken: "tok2"}, nil)
93+ r.issues.EXPECT().List(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListIssuesParams{State: domain.IssueStateClosed, PageSize: pageSize}).
94+ Return(ports.IssuePage{Issues: []domain.Issue{testIssue(t, 2, domain.IssueStateClosed, "old", "")}}, nil)
95+
96+ rec := r.do("GET", "/proj/issues", false, nil)
97+ require.Equal(t, 200, rec.Code, rec.Body.String())
98+ body := rec.Body.String()
99+ assert.Contains(t, body, `href="/proj/issues/3"`)
100+ assert.Contains(t, body, "crash &lt;b&gt;on&lt;/b&gt; start")
101+ assert.Contains(t, body, `href="/proj/issues?page=tok2"`, "older page")
102+ assert.Contains(t, body, `href="/proj/issues?state=closed"`, "closed tab")
103+ assert.Contains(t, body, `href="/proj/issues" data-key="i"`, "the Issues tab")
104+ assert.NotContains(t, body, "New issue", "no form for an anonymous visitor")
105+ assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
106+
107+ closed := r.do("GET", "/proj/issues?state=closed", false, nil)
108+ require.Equal(t, 200, closed.Code)
109+ assert.Contains(t, closed.Body.String(), `href="/proj/issues/2"`)
110+
111+ assert.Equal(t, 404, r.do("GET", "/proj/issues?state=weird", false, nil).Code)
112+
113+ owner := r.do("GET", "/proj/issues", true, nil)
114+ require.Equal(t, 200, owner.Code)
115+ assert.Contains(t, owner.Body.String(), `href="/proj/issues/new"`)
116+ assert.Equal(t, "private, no-store", owner.Header().Get("Cache-Control"))
117+}
118+
119+func TestIssueShow(t *testing.T) {
120+ t.Parallel()
121+ r := newIssueRig(t, domain.IssuesPublic, false)
122+ c := domain.IssueComment{ID: 1, Author: "owner", CreatedAt: time.Now()}
123+ c.Text, _ = domain.ParseIssueBody("see https://example.org <script>x</script>")
124+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, repoName(t, "proj"), int64(5)).Return(ports.IssueDetail{
125+ Issue: testIssue(t, 5, domain.IssueStateOpen, "bug", "# Title\n\n**bold**\n\n<script>alert(1)</script>"),
126+ Comments: []domain.IssueComment{c},
127+ }, nil)
128+
129+ anon := r.do("GET", "/proj/issues/5", false, nil)
130+ require.Equal(t, 200, anon.Code, anon.Body.String())
131+ body := anon.Body.String()
132+ assert.Contains(t, body, "<strong>bold</strong>", "Markdown body")
133+ assert.NotContains(t, body, "<script>alert")
134+ assert.Contains(t, body, `id="c1"`)
135+ assert.Contains(t, body, `href="https://example.org"`)
136+ assert.Contains(t, body, "&lt;script&gt;x&lt;/script&gt;")
137+ assert.NotContains(t, body, `class="cform"`)
138+ assert.NotContains(t, body, "/proj/issues/5/close")
139+ assert.Contains(t, body, `view=issue`)
140+
141+ owner := r.do("GET", "/proj/issues/5", true, nil)
142+ require.Equal(t, 200, owner.Code)
143+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/comments"`)
144+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/close"`)
145+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/reopen")
146+
147+ assert.Equal(t, 404, r.do("GET", "/proj/issues/05", false, nil).Code)
148+ assert.Equal(t, 404, r.do("GET", "/proj/issues/x", false, nil).Code)
149+}
150+
151+func TestIssueShowClosedOffersReopen(t *testing.T) {
152+ t.Parallel()
153+ r := newIssueRig(t, domain.IssuesOwner, false)
154+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(5)).Return(ports.IssueDetail{
155+ Issue: testIssue(t, 5, domain.IssueStateClosed, "bug", ""),
156+ }, nil)
157+ owner := r.do("GET", "/proj/issues/5", true, nil)
158+ require.Equal(t, 200, owner.Code)
159+ assert.Contains(t, owner.Body.String(), `action="/proj/issues/5/reopen"`)
160+ assert.NotContains(t, owner.Body.String(), "/proj/issues/5/close")
161+}
162+
163+func TestIssueNotFound(t *testing.T) {
164+ t.Parallel()
165+ // Mode off: the pages and the tab are not there; the use case is not asked.
166+ off := newIssueRig(t, domain.IssuesOff, false)
167+ for _, p := range []string{"/proj/issues", "/proj/issues/1", "/proj/issues/new"} {
168+ assert.Equal(t, 404, off.do("GET", p, true, nil).Code, p)
169+ }
170+ summary := off.do("GET", "/proj", false, nil)
171+ require.Equal(t, 200, summary.Code)
172+ assert.NotContains(t, summary.Body.String(), `data-key="i"`)
173+
174+ // The mode is unset in the settings: off as well.
175+ unset := newIssueRig(t, "", false)
176+ assert.Equal(t, 404, unset.do("GET", "/proj/issues", false, nil).Code)
177+
178+ // An unknown repository.
179+ assert.Equal(t, 404, off.do("GET", "/nope/issues", false, nil).Code)
180+
181+ // A private repository, for an anonymous visitor.
182+ priv := mocks.NewRepoService(t)
183+ priv.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything).Return(domain.Repo{}, domain.ErrNotFound)
184+ browse := mocks.NewBrowseService(t)
185+ srv := newTestServer(t, Config{Repos: priv, Browse: browse, Issues: mocks.NewIssueService(t)})
186+ rec := httptest.NewRecorder()
187+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj/issues/1", nil))
188+ assert.Equal(t, 404, rec.Code)
189+
190+ // The use case says not found for a number nobody has.
191+ r := newIssueRig(t, domain.IssuesOwner, false)
192+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(9)).Return(ports.IssueDetail{}, domain.ErrNotFound)
193+ assert.Equal(t, 404, r.do("GET", "/proj/issues/9", false, nil).Code)
194+}
195+
196+func TestIssueNewForm(t *testing.T) {
197+ t.Parallel()
198+ r := newIssueRig(t, domain.IssuesOwner, false)
199+ assert.Equal(t, 404, r.do("GET", "/proj/issues/new", false, nil).Code, "owner only")
200+ rec := r.do("GET", "/proj/issues/new", true, nil)
201+ require.Equal(t, 200, rec.Code)
202+ assert.Contains(t, rec.Body.String(), `action="/proj/issues"`)
203+ assert.Contains(t, rec.Body.String(), `name="title"`)
204+}
205+
206+func TestIssuePosts(t *testing.T) {
207+ t.Parallel()
208+ r := newIssueRig(t, domain.IssuesOwner, false)
209+ name := repoName(t, "proj")
210+ r.issues.EXPECT().Create(mock.Anything, domain.Owner(), name, ports.CreateIssueParams{Title: "t", Body: "a\nb"}).
211+ Return(testIssue(t, 7, domain.IssueStateOpen, "t", ""), nil)
212+ r.issues.EXPECT().AddComment(mock.Anything, domain.Owner(), name, int64(7), "hi\nthere").
213+ Return(domain.IssueComment{ID: 4}, nil)
214+ r.issues.EXPECT().Close(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, nil)
215+ r.issues.EXPECT().Reopen(mock.Anything, domain.Owner(), name, int64(7)).Return(domain.Issue{}, domain.ErrPreconditionFailed)
216+
217+ rec := r.do("POST", "/proj/issues", true, url.Values{"title": {"t"}, "body": {"a\r\nb"}})
218+ assert.Equal(t, 303, rec.Code)
219+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
220+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
221+
222+ rec = r.do("POST", "/proj/issues/7/comments", true, url.Values{"text": {"hi\r\nthere"}})
223+ assert.Equal(t, 303, rec.Code)
224+ assert.Equal(t, "/proj/issues/7#c4", rec.Header().Get("Location"))
225+
226+ rec = r.do("POST", "/proj/issues/7/close", true, nil)
227+ assert.Equal(t, 303, rec.Code)
228+ assert.Equal(t, "/proj/issues/7", rec.Header().Get("Location"))
229+
230+ rec = r.do("POST", "/proj/issues/7/reopen", true, nil)
231+ assert.Equal(t, 409, rec.Code, "a refused action is a state page")
232+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
233+}
234+
235+func TestIssuePostsRefusedWithoutSession(t *testing.T) {
236+ t.Parallel()
237+ r := newIssueRig(t, domain.IssuesPublic, false) // no write use case call is expected
238+ for _, p := range []string{"/proj/issues", "/proj/issues/7/comments", "/proj/issues/7/close", "/proj/issues/7/reopen"} {
239+ assert.Equal(t, 403, r.do("POST", p, false, url.Values{"title": {"t"}}).Code, p+" anonymous")
240+ req := httptest.NewRequest("POST", p, nil)
241+ req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"})
242+ rec := httptest.NewRecorder()
243+ r.srv.ServeHTTP(rec, req)
244+ assert.Equal(t, 403, rec.Code, p+" without Origin")
245+ }
246+}
247+
248+func TestIssueRoutesAbsentWithoutService(t *testing.T) {
249+ t.Parallel()
250+ browse := mocks.NewBrowseService(t)
251+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
252+ srv := newTestServer(t, Config{Repos: reposServing(t, issueRepoView(t, domain.IssuesOwner)), Browse: browse})
253+ for _, p := range []string{"/proj/issues", "/proj/issues/1"} {
254+ rec := httptest.NewRecorder()
255+ srv.ServeHTTP(rec, httptest.NewRequest("GET", p, nil))
256+ assert.Equal(t, 404, rec.Code, p)
257+ }
258+ rec := httptest.NewRecorder()
259+ srv.ServeHTTP(rec, httptest.NewRequest("GET", "/proj", nil))
260+ assert.NotContains(t, rec.Body.String(), `data-key="i"`)
261+}
262+
263+func TestIssueNumberRedirects(t *testing.T) {
264+ t.Parallel()
265+ r := newIssueRig(t, domain.IssuesOwner, true)
266+ name := repoName(t, "proj")
267+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything).Return(ports.IssueDetail{}, domain.ErrNotFound)
268+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, name, mock.Anything, mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
269+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(1)).Return(domain.NumberMergeRequest, nil)
270+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(2)).Return(domain.NumberIssue, nil)
271+ r.nums.EXPECT().Kind(mock.Anything, mock.Anything, name, int64(3)).Return(domain.NumberNone, nil)
272+
273+ rec := r.do("GET", "/proj/issues/1", false, nil)
274+ assert.Equal(t, 303, rec.Code)
275+ assert.Equal(t, "/proj/merge-requests/1", rec.Header().Get("Location"))
276+ assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
277+
278+ rec = r.do("GET", "/proj/merge-requests/2", false, nil)
279+ assert.Equal(t, 303, rec.Code)
280+ assert.Equal(t, "/proj/issues/2", rec.Header().Get("Location"))
281+
282+ assert.Equal(t, 404, r.do("GET", "/proj/issues/3", false, nil).Code)
283+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/3", false, nil).Code)
284+}
285+
286+func TestIssueNumberNoRedirectWithoutResolver(t *testing.T) {
287+ t.Parallel()
288+ r := newIssueRig(t, domain.IssuesOwner, false)
289+ r.issues.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1)).Return(ports.IssueDetail{}, domain.ErrNotFound)
290+ r.mrs.EXPECT().Get(mock.Anything, mock.Anything, mock.Anything, int64(1), mock.Anything).Return(ports.MergeRequestDetail{}, domain.ErrNotFound)
291+ assert.Equal(t, 404, r.do("GET", "/proj/issues/1", false, nil).Code)
292+ assert.Equal(t, 404, r.do("GET", "/proj/merge-requests/1", false, nil).Code)
293+}
294+
295+func TestIssueLiveNotices(t *testing.T) {
296+ t.Parallel()
297+ r := newIssueRig(t, domain.IssuesOwner, false)
298+ issue := testIssue(t, 5, domain.IssueStateOpen, "bug", "")
299+ cases := []struct {
300+ ev domain.Event
301+ view string
302+ n string
303+ want string // "" : nothing
304+ }{
305+ {domain.IssueOpened{Issue: issue}, "issues", "", "issues changed"},
306+ {domain.IssueClosed{Issue: issue}, "issues", "", "issues changed"},
307+ {domain.IssueOpened{Issue: issue}, "issue", "5", "an issue was opened"},
308+ {domain.IssueClosed{Issue: issue}, "issue", "5", "this issue was closed"},
309+ {domain.IssueReopened{Issue: issue}, "issue", "5", "this issue was reopened"},
310+ {domain.IssueCommented{Repo: issue.Repo, Number: 5}, "issue", "5", "a comment was added"},
311+ {domain.IssueCommented{Repo: issue.Repo, Number: 6}, "issue", "5", ""},
312+ {domain.IssueClosed{Issue: issue}, "mrs", "", ""},
313+ {domain.IssueClosed{Issue: issue}, "summary", "", ""},
314+ }
315+ for _, c := range cases {
316+ ev := ports.WatchEvent{Event: c.ev}
317+ assert.True(t, liveEvent(ev), c.ev.EventKind())
318+ got, err := (&liveStream{s: r.srv, repo: "proj", view: c.view, n: c.n}).render(t.Context(), ev)
319+ require.NoError(t, err)
320+ if c.want == "" {
321+ assert.Empty(t, got, "%s on %s", c.ev.EventKind(), c.view)
322+ continue
323+ }
324+ require.Len(t, got, 1, "%s on %s", c.ev.EventKind(), c.view)
325+ assert.Equal(t, "mr-notice", got[0].event)
326+ assert.Contains(t, got[0].html, c.want)
327+ }
328+ // MR events do not touch the issue pages.
329+ got, err := (&liveStream{s: r.srv, repo: "proj", view: "issues"}).render(t.Context(), ports.WatchEvent{Event: domain.MergeRequestClosed{}})
330+ require.NoError(t, err)
331+ assert.Empty(t, got)
332+}
M internal/adapters/web/keys.templ +1 -0
@@ -27,6 +27,7 @@ templ Keys() {
2727 k-c from:body -> ermite.tab('c');
2828 k-r from:body -> ermite.tab('r');
2929 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
3031 k-next from:body -> ermite.nextNote();
3132 k-split from:body -> ermite.layout('split');
3233 k-unified from:body -> ermite.layout('unified');
@@ -27,6 +27,7 @@ templ Keys() {
27 k-c from:body -> ermite.tab('c');27 k-c from:body -> ermite.tab('c');
28 k-r from:body -> ermite.tab('r');28 k-r from:body -> ermite.tab('r');
29 k-m from:body -> ermite.tab('m');29 k-m from:body -> ermite.tab('m');
30+ k-i from:body -> ermite.tab('i');
30 k-next from:body -> ermite.nextNote();31 k-next from:body -> ermite.nextNote();
31 k-split from:body -> ermite.layout('split');32 k-split from:body -> ermite.layout('split');
32 k-unified from:body -> ermite.layout('unified');33 k-unified from:body -> ermite.layout('unified');
M internal/adapters/web/keys_templ.go +1 -1
@@ -34,7 +34,7 @@ func Keys() templ.Component {
3434 templ_7745c5c3_Var1 = templ.NopComponent
3535 }
3636 ctx = templ.ClearChildren(ctx)
37- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")
37+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-i from:body -> ermite.tab('i');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")
3838 if templ_7745c5c3_Err != nil {
3939 return templ_7745c5c3_Err
4040 }
@@ -34,7 +34,7 @@ func Keys() templ.Component {
34 templ_7745c5c3_Var1 = templ.NopComponent34 templ_7745c5c3_Var1 = templ.NopComponent
35 }35 }
36 ctx = templ.ClearChildren(ctx)36 ctx = templ.ClearChildren(ctx)
37- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")37+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"keys\" hidden hx-on=\"k-j from:body -> ermite.move(1);\n k-k from:body -> ermite.move(-1);\n k-top from:body -> ermite.edge(false);\n k-bottom from:body -> ermite.edge(true);\n k-open from:body -> ermite.open();\n k-right from:body -> ermite.rail(true);\n k-left from:body -> ermite.rail(false);\n k-esc from:body -> ermite.closeDialogs();\n k-help from:body -> ermite.dialog('panel-help');\n k-refs from:body -> ermite.dialog('panel-refs', 'refs-filter');\n k-cmd from:body -> ermite.dialog('panel-cmd', 'cmd-q'); ermiteFinder.load();\n k-filter from:body -> ermite.focusFilter();\n k-gh from:body -> ermite.go('.brand');\n k-s from:body -> ermite.tab('s');\n k-t from:body -> ermite.tab('t');\n k-c from:body -> ermite.tab('c');\n k-r from:body -> ermite.tab('r');\n k-m from:body -> ermite.tab('m');\n k-i from:body -> ermite.tab('i');\n k-next from:body -> ermite.nextNote();\n k-split from:body -> ermite.layout('split');\n k-unified from:body -> ermite.layout('unified');\n k-collapse from:body -> ermite.collapse();\n k-expand from:body -> ermite.expandFile();\n k-expandAll from:body -> ermite.expandAll();\n k-permalink from:body -> ermite.permalink();\n k-merge from:body -> window.ermiteMR?.openMerge();\n k-read from:body -> window.ermiteMR?.markRead();\n k-gf from:body -> window.ermiteMR?.focusFiles();\n k-keys from:body -> ermite.toggleKeys()\"></div>")
38 if templ_7745c5c3_Err != nil {38 if templ_7745c5c3_Err != nil {
39 return templ_7745c5c3_Err39 return templ_7745c5c3_Err
40 }40 }
M internal/adapters/web/mr_post.go +5 -1
@@ -31,7 +31,11 @@ const maxFormBytes = 128 << 10
3131 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
3232 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
3333 if err := r.ParseForm(); err != nil {
34- s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))
34+ back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.Contains(r.URL.Path, "/issues") {
36+ back, what = issuesBase(r.PathValue("repo")), "an issue"
37+ }
38+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
3539 return false
3640 }
3741 return true
@@ -31,7 +31,11 @@ const maxFormBytes = 128 << 10
31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {31 func (s *Server) form(w http.ResponseWriter, r *http.Request) bool {
32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)32 r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
33 if err := r.ParseForm(); err != nil {33 if err := r.ParseForm(); err != nil {
34- s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than a merge request can be.", mrBase(r.PathValue("repo")))34+ back, what := mrBase(r.PathValue("repo")), "a merge request"
35+ if strings.Contains(r.URL.Path, "/issues") {
36+ back, what = issuesBase(r.PathValue("repo")), "an issue"
37+ }
38+ s.reject(r.Context(), w, r, http.StatusRequestEntityTooLarge, "form too large", "the form is larger than "+what+" can be.", back)
35 return false39 return false
36 }40 }
37 return true41 return true
M internal/adapters/web/overlays.templ +1 -1
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
2626 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
2727 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2828 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
29+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
3030 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
3131 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
3232 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
@@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) {
26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>26 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>27 <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>28 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
29- <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>29+ <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd> <kbd>i</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd></div>
30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>30 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>31 <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div>
32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>32 <div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div>
M internal/adapters/web/overlays_templ.go +1 -1 generated file: collapsed
M internal/adapters/web/repo.go +3 -0
@@ -155,6 +155,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155155 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156156 l.Sections = append(l.Sections, mrs)
157157 }
158+ if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
159+ l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
160+ }
158161 l.Sections = s.withActions(l.Sections, d.name(), section)
159162 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160163
@@ -155,6 +155,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
155 mrs.Count = s.openMRCount(ctx, d.repo.Name)155 mrs.Count = s.openMRCount(ctx, d.repo.Name)
156 l.Sections = append(l.Sections, mrs)156 l.Sections = append(l.Sections, mrs)
157 }157 }
158+ if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
159+ l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
160+ }
158 l.Sections = s.withActions(l.Sections, d.name(), section)161 l.Sections = s.withActions(l.Sections, d.name(), section)
159 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}162 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
160 163
M internal/adapters/web/static/js/ermite.js +2 -2
@@ -555,11 +555,11 @@
555555
556556 // 1. Keys.
557557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
558+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
559559 // the merge request page (mr.js): the merge menu, mark read
560560 M: 'merge', R: 'read' };
561561 // "g t" is the key "t": the tabs have one event each.
562- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };
562+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
563563 let pending = 0;
564564 let pendingTimer = 0;
565565 addEventListener('keydown', (e) => {
@@ -555,11 +555,11 @@
555 555
556 // 1. Keys.556 // 1. Keys.
557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',557 const single = { j: 'j', k: 'k', G: 'bottom', '/': 'filter', '?': 'help', b: 'refs', f: 'cmd', ':': 'cmd', y: 'permalink',
558- s: 's', t: 't', c: 'c', r: 'r', m: 'm', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',558+ s: 's', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', n: 'next', h: 'left', l: 'right', d: 'split', u: 'unified', x: 'collapse', e: 'expand', E: 'expandAll', Enter: 'open', Escape: 'esc',
559 // the merge request page (mr.js): the merge menu, mark read559 // the merge request page (mr.js): the merge menu, mark read
560 M: 'merge', R: 'read' };560 M: 'merge', R: 'read' };
561 // "g t" is the key "t": the tabs have one event each.561 // "g t" is the key "t": the tabs have one event each.
562- const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', g: 'top', f: 'gf' };562+ const goto = { h: 'gh', t: 't', c: 'c', r: 'r', m: 'm', i: 'i', g: 'top', f: 'gf' };
563 let pending = 0;563 let pending = 0;
564 let pendingTimer = 0;564 let pendingTimer = 0;
565 addEventListener('keydown', (e) => {565 addEventListener('keydown', (e) => {
M internal/adapters/web/web.go +21 -1
@@ -54,6 +54,15 @@ type Config struct {
5454 // there are no such pages, nor section for them. Leave it unset rather
5555 // than assigning a typed nil pointer.
5656 MergeRequests ports.MergeRequestService
57+ // Issues serves the issue pages (ADR 0031). Without it there are no such
58+ // pages, nor tab for them. Leave it unset rather than assigning a typed
59+ // nil pointer.
60+ Issues ports.IssueService
61+ // IssueNumbers tells whether a number names an issue or a merge request,
62+ // so that the page of the wrong kind redirects to the right one. Without
63+ // it there is no such redirect. Leave it unset rather than assigning a
64+ // typed nil pointer.
65+ IssueNumbers ports.IssueNumberResolver
5766 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
5867 // commits and merge requests. Without it there are no such pages. Leave it
5968 // unset rather than assigning a typed nil pointer.
@@ -182,7 +191,7 @@ func (s *Server) routes() *http.ServeMux {
182191 if s.cfg.MergeRequests != nil {
183192 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184193 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
185- mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))
194+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
186195 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187196 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188197 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -195,6 +204,17 @@ func (s *Server) routes() *http.ServeMux {
195204 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
196205 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
197206 }
207+ if s.cfg.Issues != nil {
208+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
209+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
210+ mux.HandleFunc("GET /{repo}/issues/new", s.page(s.issueNew))
211+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
212+ // The writes: POST only, behind the gate (ServeHTTP).
213+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
214+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
215+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
216+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
217+ }
198218 if s.cfg.Runs != nil {
199219 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
200220 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
@@ -54,6 +54,15 @@ type Config struct {
54 // there are no such pages, nor section for them. Leave it unset rather54 // there are no such pages, nor section for them. Leave it unset rather
55 // than assigning a typed nil pointer.55 // than assigning a typed nil pointer.
56 MergeRequests ports.MergeRequestService56 MergeRequests ports.MergeRequestService
57+ // Issues serves the issue pages (ADR 0031). Without it there are no such
58+ // pages, nor tab for them. Leave it unset rather than assigning a typed
59+ // nil pointer.
60+ Issues ports.IssueService
61+ // IssueNumbers tells whether a number names an issue or a merge request,
62+ // so that the page of the wrong kind redirects to the right one. Without
63+ // it there is no such redirect. Leave it unset rather than assigning a
64+ // typed nil pointer.
65+ IssueNumbers ports.IssueNumberResolver
57 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to66 // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
58 // commits and merge requests. Without it there are no such pages. Leave it67 // commits and merge requests. Without it there are no such pages. Leave it
59 // unset rather than assigning a typed nil pointer.68 // unset rather than assigning a typed nil pointer.
@@ -182,7 +191,7 @@ func (s *Server) routes() *http.ServeMux {
182 if s.cfg.MergeRequests != nil {191 if s.cfg.MergeRequests != nil {
183 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))192 mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList))
184 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))193 mux.HandleFunc("GET /{repo}/merge-requests/{$}", s.page(s.mrList))
185- mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.page(s.mrShow))194+ mux.HandleFunc("GET /{repo}/merge-requests/{n}", s.pageOrRedirect(s.mrShow, s.mrToIssue))
186 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))195 mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince))
187 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))196 mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles))
188 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))197 mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare))
@@ -195,6 +204,17 @@ func (s *Server) routes() *http.ServeMux {
195 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))204 mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
196 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))205 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
197 }206 }
207+ if s.cfg.Issues != nil {
208+ mux.HandleFunc("GET /{repo}/issues", s.page(s.issueList))
209+ mux.HandleFunc("GET /{repo}/issues/{$}", s.page(s.issueList))
210+ mux.HandleFunc("GET /{repo}/issues/new", s.page(s.issueNew))
211+ mux.HandleFunc("GET /{repo}/issues/{n}", s.pageOrRedirect(s.issueShow, s.issueToMR))
212+ // The writes: POST only, behind the gate (ServeHTTP).
213+ mux.HandleFunc("POST /{repo}/issues", s.issueAction(s.issueCreate))
214+ mux.HandleFunc("POST /{repo}/issues/{n}/comments", s.issueAction(s.issueComment))
215+ mux.HandleFunc("POST /{repo}/issues/{n}/close", s.issueAction(s.issueClose))
216+ mux.HandleFunc("POST /{repo}/issues/{n}/reopen", s.issueAction(s.issueReopen))
217+ }
198 if s.cfg.Runs != nil {218 if s.cfg.Runs != nil {
199 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))219 mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
200 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))220 mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
M internal/app/testdata/web/blob-binary.html +3 -1
@@ -227,8 +227,9 @@
227227 <kbd>c</kbd>
228228 <kbd>r</kbd>
229229 <kbd>m</kbd>
230+<kbd>i</kbd>
230231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232233 </div>
233234 <div>
234235 <dt>
@@ -410,6 +411,7 @@
410411 k-c from:body -> ermite.tab('c');
411412 k-r from:body -> ermite.tab('r');
412413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413415 k-next from:body -> ermite.nextNote();
414416 k-split from:body -> ermite.layout('split');
415417 k-unified from:body -> ermite.layout('unified');
@@ -227,8 +227,9 @@
227 <kbd>c</kbd>227 <kbd>c</kbd>
228 <kbd>r</kbd>228 <kbd>r</kbd>
229 <kbd>m</kbd>229 <kbd>m</kbd>
230+<kbd>i</kbd>
230 </dt>231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232 </div>233 </div>
233 <div>234 <div>
234 <dt>235 <dt>
@@ -410,6 +411,7 @@
410 k-c from:body -> ermite.tab('c');411 k-c from:body -> ermite.tab('c');
411 k-r from:body -> ermite.tab('r');412 k-r from:body -> ermite.tab('r');
412 k-m from:body -> ermite.tab('m');413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413 k-next from:body -> ermite.nextNote();415 k-next from:body -> ermite.nextNote();
414 k-split from:body -> ermite.layout('split');416 k-split from:body -> ermite.layout('split');
415 k-unified from:body -> ermite.layout('unified');417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-code.html +3 -1
@@ -297,8 +297,9 @@
297297 <kbd>c</kbd>
298298 <kbd>r</kbd>
299299 <kbd>m</kbd>
300+<kbd>i</kbd>
300301 </dt>
301-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
302+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
302303 </div>
303304 <div>
304305 <dt>
@@ -480,6 +481,7 @@
480481 k-c from:body -> ermite.tab('c');
481482 k-r from:body -> ermite.tab('r');
482483 k-m from:body -> ermite.tab('m');
484+ k-i from:body -> ermite.tab('i');
483485 k-next from:body -> ermite.nextNote();
484486 k-split from:body -> ermite.layout('split');
485487 k-unified from:body -> ermite.layout('unified');
@@ -297,8 +297,9 @@
297 <kbd>c</kbd>297 <kbd>c</kbd>
298 <kbd>r</kbd>298 <kbd>r</kbd>
299 <kbd>m</kbd>299 <kbd>m</kbd>
300+<kbd>i</kbd>
300 </dt>301 </dt>
301-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>302+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
302 </div>303 </div>
303 <div>304 <div>
304 <dt>305 <dt>
@@ -480,6 +481,7 @@
480 k-c from:body -> ermite.tab('c');481 k-c from:body -> ermite.tab('c');
481 k-r from:body -> ermite.tab('r');482 k-r from:body -> ermite.tab('r');
482 k-m from:body -> ermite.tab('m');483 k-m from:body -> ermite.tab('m');
484+ k-i from:body -> ermite.tab('i');
483 k-next from:body -> ermite.nextNote();485 k-next from:body -> ermite.nextNote();
484 k-split from:body -> ermite.layout('split');486 k-split from:body -> ermite.layout('split');
485 k-unified from:body -> ermite.layout('unified');487 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-empty.html +3 -1
@@ -227,8 +227,9 @@
227227 <kbd>c</kbd>
228228 <kbd>r</kbd>
229229 <kbd>m</kbd>
230+<kbd>i</kbd>
230231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232233 </div>
233234 <div>
234235 <dt>
@@ -410,6 +411,7 @@
410411 k-c from:body -> ermite.tab('c');
411412 k-r from:body -> ermite.tab('r');
412413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413415 k-next from:body -> ermite.nextNote();
414416 k-split from:body -> ermite.layout('split');
415417 k-unified from:body -> ermite.layout('unified');
@@ -227,8 +227,9 @@
227 <kbd>c</kbd>227 <kbd>c</kbd>
228 <kbd>r</kbd>228 <kbd>r</kbd>
229 <kbd>m</kbd>229 <kbd>m</kbd>
230+<kbd>i</kbd>
230 </dt>231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232 </div>233 </div>
233 <div>234 <div>
234 <dt>235 <dt>
@@ -410,6 +411,7 @@
410 k-c from:body -> ermite.tab('c');411 k-c from:body -> ermite.tab('c');
411 k-r from:body -> ermite.tab('r');412 k-r from:body -> ermite.tab('r');
412 k-m from:body -> ermite.tab('m');413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413 k-next from:body -> ermite.nextNote();415 k-next from:body -> ermite.nextNote();
414 k-split from:body -> ermite.layout('split');416 k-split from:body -> ermite.layout('split');
415 k-unified from:body -> ermite.layout('unified');417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-image.html +3 -1
@@ -229,8 +229,9 @@
229229 <kbd>c</kbd>
230230 <kbd>r</kbd>
231231 <kbd>m</kbd>
232+<kbd>i</kbd>
232233 </dt>
233-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
234+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
234235 </div>
235236 <div>
236237 <dt>
@@ -412,6 +413,7 @@
412413 k-c from:body -> ermite.tab('c');
413414 k-r from:body -> ermite.tab('r');
414415 k-m from:body -> ermite.tab('m');
416+ k-i from:body -> ermite.tab('i');
415417 k-next from:body -> ermite.nextNote();
416418 k-split from:body -> ermite.layout('split');
417419 k-unified from:body -> ermite.layout('unified');
@@ -229,8 +229,9 @@
229 <kbd>c</kbd>229 <kbd>c</kbd>
230 <kbd>r</kbd>230 <kbd>r</kbd>
231 <kbd>m</kbd>231 <kbd>m</kbd>
232+<kbd>i</kbd>
232 </dt>233 </dt>
233-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>234+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
234 </div>235 </div>
235 <div>236 <div>
236 <dt>237 <dt>
@@ -412,6 +413,7 @@
412 k-c from:body -> ermite.tab('c');413 k-c from:body -> ermite.tab('c');
413 k-r from:body -> ermite.tab('r');414 k-r from:body -> ermite.tab('r');
414 k-m from:body -> ermite.tab('m');415 k-m from:body -> ermite.tab('m');
416+ k-i from:body -> ermite.tab('i');
415 k-next from:body -> ermite.nextNote();417 k-next from:body -> ermite.nextNote();
416 k-split from:body -> ermite.layout('split');418 k-split from:body -> ermite.layout('split');
417 k-unified from:body -> ermite.layout('unified');419 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-markdown.html +3 -1
@@ -294,8 +294,9 @@
294294 <kbd>c</kbd>
295295 <kbd>r</kbd>
296296 <kbd>m</kbd>
297+<kbd>i</kbd>
297298 </dt>
298-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
299+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
299300 </div>
300301 <div>
301302 <dt>
@@ -477,6 +478,7 @@
477478 k-c from:body -> ermite.tab('c');
478479 k-r from:body -> ermite.tab('r');
479480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480482 k-next from:body -> ermite.nextNote();
481483 k-split from:body -> ermite.layout('split');
482484 k-unified from:body -> ermite.layout('unified');
@@ -294,8 +294,9 @@
294 <kbd>c</kbd>294 <kbd>c</kbd>
295 <kbd>r</kbd>295 <kbd>r</kbd>
296 <kbd>m</kbd>296 <kbd>m</kbd>
297+<kbd>i</kbd>
297 </dt>298 </dt>
298-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>299+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
299 </div>300 </div>
300 <div>301 <div>
301 <dt>302 <dt>
@@ -477,6 +478,7 @@
477 k-c from:body -> ermite.tab('c');478 k-c from:body -> ermite.tab('c');
478 k-r from:body -> ermite.tab('r');479 k-r from:body -> ermite.tab('r');
479 k-m from:body -> ermite.tab('m');480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480 k-next from:body -> ermite.nextNote();482 k-next from:body -> ermite.nextNote();
481 k-split from:body -> ermite.layout('split');483 k-split from:body -> ermite.layout('split');
482 k-unified from:body -> ermite.layout('unified');484 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-svg.html +3 -1
@@ -252,8 +252,9 @@
252252 <kbd>c</kbd>
253253 <kbd>r</kbd>
254254 <kbd>m</kbd>
255+<kbd>i</kbd>
255256 </dt>
256-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
257+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
257258 </div>
258259 <div>
259260 <dt>
@@ -435,6 +436,7 @@
435436 k-c from:body -> ermite.tab('c');
436437 k-r from:body -> ermite.tab('r');
437438 k-m from:body -> ermite.tab('m');
439+ k-i from:body -> ermite.tab('i');
438440 k-next from:body -> ermite.nextNote();
439441 k-split from:body -> ermite.layout('split');
440442 k-unified from:body -> ermite.layout('unified');
@@ -252,8 +252,9 @@
252 <kbd>c</kbd>252 <kbd>c</kbd>
253 <kbd>r</kbd>253 <kbd>r</kbd>
254 <kbd>m</kbd>254 <kbd>m</kbd>
255+<kbd>i</kbd>
255 </dt>256 </dt>
256-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>257+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
257 </div>258 </div>
258 <div>259 <div>
259 <dt>260 <dt>
@@ -435,6 +436,7 @@
435 k-c from:body -> ermite.tab('c');436 k-c from:body -> ermite.tab('c');
436 k-r from:body -> ermite.tab('r');437 k-r from:body -> ermite.tab('r');
437 k-m from:body -> ermite.tab('m');438 k-m from:body -> ermite.tab('m');
439+ k-i from:body -> ermite.tab('i');
438 k-next from:body -> ermite.nextNote();440 k-next from:body -> ermite.nextNote();
439 k-split from:body -> ermite.layout('split');441 k-split from:body -> ermite.layout('split');
440 k-unified from:body -> ermite.layout('unified');442 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-text.html +3 -1
@@ -247,8 +247,9 @@
247247 <kbd>c</kbd>
248248 <kbd>r</kbd>
249249 <kbd>m</kbd>
250+<kbd>i</kbd>
250251 </dt>
251-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
252+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
252253 </div>
253254 <div>
254255 <dt>
@@ -430,6 +431,7 @@
430431 k-c from:body -> ermite.tab('c');
431432 k-r from:body -> ermite.tab('r');
432433 k-m from:body -> ermite.tab('m');
434+ k-i from:body -> ermite.tab('i');
433435 k-next from:body -> ermite.nextNote();
434436 k-split from:body -> ermite.layout('split');
435437 k-unified from:body -> ermite.layout('unified');
@@ -247,8 +247,9 @@
247 <kbd>c</kbd>247 <kbd>c</kbd>
248 <kbd>r</kbd>248 <kbd>r</kbd>
249 <kbd>m</kbd>249 <kbd>m</kbd>
250+<kbd>i</kbd>
250 </dt>251 </dt>
251-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>252+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
252 </div>253 </div>
253 <div>254 <div>
254 <dt>255 <dt>
@@ -430,6 +431,7 @@
430 k-c from:body -> ermite.tab('c');431 k-c from:body -> ermite.tab('c');
431 k-r from:body -> ermite.tab('r');432 k-r from:body -> ermite.tab('r');
432 k-m from:body -> ermite.tab('m');433 k-m from:body -> ermite.tab('m');
434+ k-i from:body -> ermite.tab('i');
433 k-next from:body -> ermite.nextNote();435 k-next from:body -> ermite.nextNote();
434 k-split from:body -> ermite.layout('split');436 k-split from:body -> ermite.layout('split');
435 k-unified from:body -> ermite.layout('unified');437 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-too-large.html +3 -1
@@ -227,8 +227,9 @@
227227 <kbd>c</kbd>
228228 <kbd>r</kbd>
229229 <kbd>m</kbd>
230+<kbd>i</kbd>
230231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232233 </div>
233234 <div>
234235 <dt>
@@ -410,6 +411,7 @@
410411 k-c from:body -> ermite.tab('c');
411412 k-r from:body -> ermite.tab('r');
412413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413415 k-next from:body -> ermite.nextNote();
414416 k-split from:body -> ermite.layout('split');
415417 k-unified from:body -> ermite.layout('unified');
@@ -227,8 +227,9 @@
227 <kbd>c</kbd>227 <kbd>c</kbd>
228 <kbd>r</kbd>228 <kbd>r</kbd>
229 <kbd>m</kbd>229 <kbd>m</kbd>
230+<kbd>i</kbd>
230 </dt>231 </dt>
231-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>232+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
232 </div>233 </div>
233 <div>234 <div>
234 <dt>235 <dt>
@@ -410,6 +411,7 @@
410 k-c from:body -> ermite.tab('c');411 k-c from:body -> ermite.tab('c');
411 k-r from:body -> ermite.tab('r');412 k-r from:body -> ermite.tab('r');
412 k-m from:body -> ermite.tab('m');413 k-m from:body -> ermite.tab('m');
414+ k-i from:body -> ermite.tab('i');
413 k-next from:body -> ermite.nextNote();415 k-next from:body -> ermite.nextNote();
414 k-split from:body -> ermite.layout('split');416 k-split from:body -> ermite.layout('split');
415 k-unified from:body -> ermite.layout('unified');417 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/blob-trojan.html +3 -1
@@ -294,8 +294,9 @@
294294 <kbd>c</kbd>
295295 <kbd>r</kbd>
296296 <kbd>m</kbd>
297+<kbd>i</kbd>
297298 </dt>
298-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
299+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
299300 </div>
300301 <div>
301302 <dt>
@@ -477,6 +478,7 @@
477478 k-c from:body -> ermite.tab('c');
478479 k-r from:body -> ermite.tab('r');
479480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480482 k-next from:body -> ermite.nextNote();
481483 k-split from:body -> ermite.layout('split');
482484 k-unified from:body -> ermite.layout('unified');
@@ -294,8 +294,9 @@
294 <kbd>c</kbd>294 <kbd>c</kbd>
295 <kbd>r</kbd>295 <kbd>r</kbd>
296 <kbd>m</kbd>296 <kbd>m</kbd>
297+<kbd>i</kbd>
297 </dt>298 </dt>
298-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>299+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
299 </div>300 </div>
300 <div>301 <div>
301 <dt>302 <dt>
@@ -477,6 +478,7 @@
477 k-c from:body -> ermite.tab('c');478 k-c from:body -> ermite.tab('c');
478 k-r from:body -> ermite.tab('r');479 k-r from:body -> ermite.tab('r');
479 k-m from:body -> ermite.tab('m');480 k-m from:body -> ermite.tab('m');
481+ k-i from:body -> ermite.tab('i');
480 k-next from:body -> ermite.nextNote();482 k-next from:body -> ermite.nextNote();
481 k-split from:body -> ermite.layout('split');483 k-split from:body -> ermite.layout('split');
482 k-unified from:body -> ermite.layout('unified');484 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-big.html +3 -1
@@ -269,8 +269,9 @@
269269 <kbd>c</kbd>
270270 <kbd>r</kbd>
271271 <kbd>m</kbd>
272+<kbd>i</kbd>
272273 </dt>
273-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
274+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
274275 </div>
275276 <div>
276277 <dt>
@@ -452,6 +453,7 @@
452453 k-c from:body -> ermite.tab('c');
453454 k-r from:body -> ermite.tab('r');
454455 k-m from:body -> ermite.tab('m');
456+ k-i from:body -> ermite.tab('i');
455457 k-next from:body -> ermite.nextNote();
456458 k-split from:body -> ermite.layout('split');
457459 k-unified from:body -> ermite.layout('unified');
@@ -269,8 +269,9 @@
269 <kbd>c</kbd>269 <kbd>c</kbd>
270 <kbd>r</kbd>270 <kbd>r</kbd>
271 <kbd>m</kbd>271 <kbd>m</kbd>
272+<kbd>i</kbd>
272 </dt>273 </dt>
273-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>274+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
274 </div>275 </div>
275 <div>276 <div>
276 <dt>277 <dt>
@@ -452,6 +453,7 @@
452 k-c from:body -> ermite.tab('c');453 k-c from:body -> ermite.tab('c');
453 k-r from:body -> ermite.tab('r');454 k-r from:body -> ermite.tab('r');
454 k-m from:body -> ermite.tab('m');455 k-m from:body -> ermite.tab('m');
456+ k-i from:body -> ermite.tab('i');
455 k-next from:body -> ermite.nextNote();457 k-next from:body -> ermite.nextNote();
456 k-split from:body -> ermite.layout('split');458 k-split from:body -> ermite.layout('split');
457 k-unified from:body -> ermite.layout('unified');459 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-binary.html +3 -1
@@ -261,8 +261,9 @@
261261 <kbd>c</kbd>
262262 <kbd>r</kbd>
263263 <kbd>m</kbd>
264+<kbd>i</kbd>
264265 </dt>
265-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
266+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
266267 </div>
267268 <div>
268269 <dt>
@@ -444,6 +445,7 @@
444445 k-c from:body -> ermite.tab('c');
445446 k-r from:body -> ermite.tab('r');
446447 k-m from:body -> ermite.tab('m');
448+ k-i from:body -> ermite.tab('i');
447449 k-next from:body -> ermite.nextNote();
448450 k-split from:body -> ermite.layout('split');
449451 k-unified from:body -> ermite.layout('unified');
@@ -261,8 +261,9 @@
261 <kbd>c</kbd>261 <kbd>c</kbd>
262 <kbd>r</kbd>262 <kbd>r</kbd>
263 <kbd>m</kbd>263 <kbd>m</kbd>
264+<kbd>i</kbd>
264 </dt>265 </dt>
265-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>266+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
266 </div>267 </div>
267 <div>268 <div>
268 <dt>269 <dt>
@@ -444,6 +445,7 @@
444 k-c from:body -> ermite.tab('c');445 k-c from:body -> ermite.tab('c');
445 k-r from:body -> ermite.tab('r');446 k-r from:body -> ermite.tab('r');
446 k-m from:body -> ermite.tab('m');447 k-m from:body -> ermite.tab('m');
448+ k-i from:body -> ermite.tab('i');
447 k-next from:body -> ermite.nextNote();449 k-next from:body -> ermite.nextNote();
448 k-split from:body -> ermite.layout('split');450 k-split from:body -> ermite.layout('split');
449 k-unified from:body -> ermite.layout('unified');451 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-merge.html +3 -1
@@ -303,8 +303,9 @@
303303 <kbd>c</kbd>
304304 <kbd>r</kbd>
305305 <kbd>m</kbd>
306+<kbd>i</kbd>
306307 </dt>
307-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
308+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
308309 </div>
309310 <div>
310311 <dt>
@@ -486,6 +487,7 @@
486487 k-c from:body -> ermite.tab('c');
487488 k-r from:body -> ermite.tab('r');
488489 k-m from:body -> ermite.tab('m');
490+ k-i from:body -> ermite.tab('i');
489491 k-next from:body -> ermite.nextNote();
490492 k-split from:body -> ermite.layout('split');
491493 k-unified from:body -> ermite.layout('unified');
@@ -303,8 +303,9 @@
303 <kbd>c</kbd>303 <kbd>c</kbd>
304 <kbd>r</kbd>304 <kbd>r</kbd>
305 <kbd>m</kbd>305 <kbd>m</kbd>
306+<kbd>i</kbd>
306 </dt>307 </dt>
307-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>308+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
308 </div>309 </div>
309 <div>310 <div>
310 <dt>311 <dt>
@@ -486,6 +487,7 @@
486 k-c from:body -> ermite.tab('c');487 k-c from:body -> ermite.tab('c');
487 k-r from:body -> ermite.tab('r');488 k-r from:body -> ermite.tab('r');
488 k-m from:body -> ermite.tab('m');489 k-m from:body -> ermite.tab('m');
490+ k-i from:body -> ermite.tab('i');
489 k-next from:body -> ermite.nextNote();491 k-next from:body -> ermite.nextNote();
490 k-split from:body -> ermite.layout('split');492 k-split from:body -> ermite.layout('split');
491 k-unified from:body -> ermite.layout('unified');493 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-modify-split.html +3 -1
@@ -426,8 +426,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
426426 <kbd>c</kbd>
427427 <kbd>r</kbd>
428428 <kbd>m</kbd>
429+<kbd>i</kbd>
429430 </dt>
430-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
431+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
431432 </div>
432433 <div>
433434 <dt>
@@ -609,6 +610,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
609610 k-c from:body -> ermite.tab('c');
610611 k-r from:body -> ermite.tab('r');
611612 k-m from:body -> ermite.tab('m');
613+ k-i from:body -> ermite.tab('i');
612614 k-next from:body -> ermite.nextNote();
613615 k-split from:body -> ermite.layout('split');
614616 k-unified from:body -> ermite.layout('unified');
@@ -426,8 +426,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
426 <kbd>c</kbd>426 <kbd>c</kbd>
427 <kbd>r</kbd>427 <kbd>r</kbd>
428 <kbd>m</kbd>428 <kbd>m</kbd>
429+<kbd>i</kbd>
429 </dt>430 </dt>
430-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>431+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
431 </div>432 </div>
432 <div>433 <div>
433 <dt>434 <dt>
@@ -609,6 +610,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
609 k-c from:body -> ermite.tab('c');610 k-c from:body -> ermite.tab('c');
610 k-r from:body -> ermite.tab('r');611 k-r from:body -> ermite.tab('r');
611 k-m from:body -> ermite.tab('m');612 k-m from:body -> ermite.tab('m');
613+ k-i from:body -> ermite.tab('i');
612 k-next from:body -> ermite.nextNote();614 k-next from:body -> ermite.nextNote();
613 k-split from:body -> ermite.layout('split');615 k-split from:body -> ermite.layout('split');
614 k-unified from:body -> ermite.layout('unified');616 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-modify.html +3 -1
@@ -426,8 +426,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
426426 <kbd>c</kbd>
427427 <kbd>r</kbd>
428428 <kbd>m</kbd>
429+<kbd>i</kbd>
429430 </dt>
430-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
431+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
431432 </div>
432433 <div>
433434 <dt>
@@ -609,6 +610,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
609610 k-c from:body -> ermite.tab('c');
610611 k-r from:body -> ermite.tab('r');
611612 k-m from:body -> ermite.tab('m');
613+ k-i from:body -> ermite.tab('i');
612614 k-next from:body -> ermite.nextNote();
613615 k-split from:body -> ermite.layout('split');
614616 k-unified from:body -> ermite.layout('unified');
@@ -426,8 +426,9 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
426 <kbd>c</kbd>426 <kbd>c</kbd>
427 <kbd>r</kbd>427 <kbd>r</kbd>
428 <kbd>m</kbd>428 <kbd>m</kbd>
429+<kbd>i</kbd>
429 </dt>430 </dt>
430-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>431+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
431 </div>432 </div>
432 <div>433 <div>
433 <dt>434 <dt>
@@ -609,6 +610,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
609 k-c from:body -> ermite.tab('c');610 k-c from:body -> ermite.tab('c');
610 k-r from:body -> ermite.tab('r');611 k-r from:body -> ermite.tab('r');
611 k-m from:body -> ermite.tab('m');612 k-m from:body -> ermite.tab('m');
613+ k-i from:body -> ermite.tab('i');
612 k-next from:body -> ermite.nextNote();614 k-next from:body -> ermite.nextNote();
613 k-split from:body -> ermite.layout('split');615 k-split from:body -> ermite.layout('split');
614 k-unified from:body -> ermite.layout('unified');616 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-rename.html +3 -1
@@ -330,8 +330,9 @@
330330 <kbd>c</kbd>
331331 <kbd>r</kbd>
332332 <kbd>m</kbd>
333+<kbd>i</kbd>
333334 </dt>
334-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
335+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
335336 </div>
336337 <div>
337338 <dt>
@@ -513,6 +514,7 @@
513514 k-c from:body -> ermite.tab('c');
514515 k-r from:body -> ermite.tab('r');
515516 k-m from:body -> ermite.tab('m');
517+ k-i from:body -> ermite.tab('i');
516518 k-next from:body -> ermite.nextNote();
517519 k-split from:body -> ermite.layout('split');
518520 k-unified from:body -> ermite.layout('unified');
@@ -330,8 +330,9 @@
330 <kbd>c</kbd>330 <kbd>c</kbd>
331 <kbd>r</kbd>331 <kbd>r</kbd>
332 <kbd>m</kbd>332 <kbd>m</kbd>
333+<kbd>i</kbd>
333 </dt>334 </dt>
334-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>335+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
335 </div>336 </div>
336 <div>337 <div>
337 <dt>338 <dt>
@@ -513,6 +514,7 @@
513 k-c from:body -> ermite.tab('c');514 k-c from:body -> ermite.tab('c');
514 k-r from:body -> ermite.tab('r');515 k-r from:body -> ermite.tab('r');
515 k-m from:body -> ermite.tab('m');516 k-m from:body -> ermite.tab('m');
517+ k-i from:body -> ermite.tab('i');
516 k-next from:body -> ermite.nextNote();518 k-next from:body -> ermite.nextNote();
517 k-split from:body -> ermite.layout('split');519 k-split from:body -> ermite.layout('split');
518 k-unified from:body -> ermite.layout('unified');520 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-root.html +3 -1
@@ -649,8 +649,9 @@
649649 <kbd>c</kbd>
650650 <kbd>r</kbd>
651651 <kbd>m</kbd>
652+<kbd>i</kbd>
652653 </dt>
653-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
654+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
654655 </div>
655656 <div>
656657 <dt>
@@ -832,6 +833,7 @@
832833 k-c from:body -> ermite.tab('c');
833834 k-r from:body -> ermite.tab('r');
834835 k-m from:body -> ermite.tab('m');
836+ k-i from:body -> ermite.tab('i');
835837 k-next from:body -> ermite.nextNote();
836838 k-split from:body -> ermite.layout('split');
837839 k-unified from:body -> ermite.layout('unified');
@@ -649,8 +649,9 @@
649 <kbd>c</kbd>649 <kbd>c</kbd>
650 <kbd>r</kbd>650 <kbd>r</kbd>
651 <kbd>m</kbd>651 <kbd>m</kbd>
652+<kbd>i</kbd>
652 </dt>653 </dt>
653-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>654+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
654 </div>655 </div>
655 <div>656 <div>
656 <dt>657 <dt>
@@ -832,6 +833,7 @@
832 k-c from:body -> ermite.tab('c');833 k-c from:body -> ermite.tab('c');
833 k-r from:body -> ermite.tab('r');834 k-r from:body -> ermite.tab('r');
834 k-m from:body -> ermite.tab('m');835 k-m from:body -> ermite.tab('m');
836+ k-i from:body -> ermite.tab('i');
835 k-next from:body -> ermite.nextNote();837 k-next from:body -> ermite.nextNote();
836 k-split from:body -> ermite.layout('split');838 k-split from:body -> ermite.layout('split');
837 k-unified from:body -> ermite.layout('unified');839 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commit-trojan.html +3 -1
@@ -478,8 +478,9 @@
478478 <kbd>c</kbd>
479479 <kbd>r</kbd>
480480 <kbd>m</kbd>
481+<kbd>i</kbd>
481482 </dt>
482-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
483+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
483484 </div>
484485 <div>
485486 <dt>
@@ -661,6 +662,7 @@
661662 k-c from:body -> ermite.tab('c');
662663 k-r from:body -> ermite.tab('r');
663664 k-m from:body -> ermite.tab('m');
665+ k-i from:body -> ermite.tab('i');
664666 k-next from:body -> ermite.nextNote();
665667 k-split from:body -> ermite.layout('split');
666668 k-unified from:body -> ermite.layout('unified');
@@ -478,8 +478,9 @@
478 <kbd>c</kbd>478 <kbd>c</kbd>
479 <kbd>r</kbd>479 <kbd>r</kbd>
480 <kbd>m</kbd>480 <kbd>m</kbd>
481+<kbd>i</kbd>
481 </dt>482 </dt>
482-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>483+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
483 </div>484 </div>
484 <div>485 <div>
485 <dt>486 <dt>
@@ -661,6 +662,7 @@
661 k-c from:body -> ermite.tab('c');662 k-c from:body -> ermite.tab('c');
662 k-r from:body -> ermite.tab('r');663 k-r from:body -> ermite.tab('r');
663 k-m from:body -> ermite.tab('m');664 k-m from:body -> ermite.tab('m');
665+ k-i from:body -> ermite.tab('i');
664 k-next from:body -> ermite.nextNote();666 k-next from:body -> ermite.nextNote();
665 k-split from:body -> ermite.layout('split');667 k-split from:body -> ermite.layout('split');
666 k-unified from:body -> ermite.layout('unified');668 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-page2.html +3 -1
@@ -254,8 +254,9 @@
254254 <kbd>c</kbd>
255255 <kbd>r</kbd>
256256 <kbd>m</kbd>
257+<kbd>i</kbd>
257258 </dt>
258-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
259+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
259260 </div>
260261 <div>
261262 <dt>
@@ -423,6 +424,7 @@
423424 k-c from:body -> ermite.tab('c');
424425 k-r from:body -> ermite.tab('r');
425426 k-m from:body -> ermite.tab('m');
427+ k-i from:body -> ermite.tab('i');
426428 k-next from:body -> ermite.nextNote();
427429 k-split from:body -> ermite.layout('split');
428430 k-unified from:body -> ermite.layout('unified');
@@ -254,8 +254,9 @@
254 <kbd>c</kbd>254 <kbd>c</kbd>
255 <kbd>r</kbd>255 <kbd>r</kbd>
256 <kbd>m</kbd>256 <kbd>m</kbd>
257+<kbd>i</kbd>
257 </dt>258 </dt>
258-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>259+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
259 </div>260 </div>
260 <div>261 <div>
261 <dt>262 <dt>
@@ -423,6 +424,7 @@
423 k-c from:body -> ermite.tab('c');424 k-c from:body -> ermite.tab('c');
424 k-r from:body -> ermite.tab('r');425 k-r from:body -> ermite.tab('r');
425 k-m from:body -> ermite.tab('m');426 k-m from:body -> ermite.tab('m');
427+ k-i from:body -> ermite.tab('i');
426 k-next from:body -> ermite.nextNote();428 k-next from:body -> ermite.nextNote();
427 k-split from:body -> ermite.layout('split');429 k-split from:body -> ermite.layout('split');
428 k-unified from:body -> ermite.layout('unified');430 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-path.html +3 -1
@@ -269,8 +269,9 @@
269269 <kbd>c</kbd>
270270 <kbd>r</kbd>
271271 <kbd>m</kbd>
272+<kbd>i</kbd>
272273 </dt>
273-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
274+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
274275 </div>
275276 <div>
276277 <dt>
@@ -438,6 +439,7 @@
438439 k-c from:body -> ermite.tab('c');
439440 k-r from:body -> ermite.tab('r');
440441 k-m from:body -> ermite.tab('m');
442+ k-i from:body -> ermite.tab('i');
441443 k-next from:body -> ermite.nextNote();
442444 k-split from:body -> ermite.layout('split');
443445 k-unified from:body -> ermite.layout('unified');
@@ -269,8 +269,9 @@
269 <kbd>c</kbd>269 <kbd>c</kbd>
270 <kbd>r</kbd>270 <kbd>r</kbd>
271 <kbd>m</kbd>271 <kbd>m</kbd>
272+<kbd>i</kbd>
272 </dt>273 </dt>
273-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>274+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
274 </div>275 </div>
275 <div>276 <div>
276 <dt>277 <dt>
@@ -438,6 +439,7 @@
438 k-c from:body -> ermite.tab('c');439 k-c from:body -> ermite.tab('c');
439 k-r from:body -> ermite.tab('r');440 k-r from:body -> ermite.tab('r');
440 k-m from:body -> ermite.tab('m');441 k-m from:body -> ermite.tab('m');
442+ k-i from:body -> ermite.tab('i');
441 k-next from:body -> ermite.nextNote();443 k-next from:body -> ermite.nextNote();
442 k-split from:body -> ermite.layout('split');444 k-split from:body -> ermite.layout('split');
443 k-unified from:body -> ermite.layout('unified');445 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-root.html +3 -1
@@ -1058,8 +1058,9 @@
10581058 <kbd>c</kbd>
10591059 <kbd>r</kbd>
10601060 <kbd>m</kbd>
1061+<kbd>i</kbd>
10611062 </dt>
1062-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
1063+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
10631064 </div>
10641065 <div>
10651066 <dt>
@@ -1227,6 +1228,7 @@
12271228 k-c from:body -> ermite.tab('c');
12281229 k-r from:body -> ermite.tab('r');
12291230 k-m from:body -> ermite.tab('m');
1231+ k-i from:body -> ermite.tab('i');
12301232 k-next from:body -> ermite.nextNote();
12311233 k-split from:body -> ermite.layout('split');
12321234 k-unified from:body -> ermite.layout('unified');
@@ -1058,8 +1058,9 @@
1058 <kbd>c</kbd>1058 <kbd>c</kbd>
1059 <kbd>r</kbd>1059 <kbd>r</kbd>
1060 <kbd>m</kbd>1060 <kbd>m</kbd>
1061+<kbd>i</kbd>
1061 </dt>1062 </dt>
1062-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>1063+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
1063 </div>1064 </div>
1064 <div>1065 <div>
1065 <dt>1066 <dt>
@@ -1227,6 +1228,7 @@
1227 k-c from:body -> ermite.tab('c');1228 k-c from:body -> ermite.tab('c');
1228 k-r from:body -> ermite.tab('r');1229 k-r from:body -> ermite.tab('r');
1229 k-m from:body -> ermite.tab('m');1230 k-m from:body -> ermite.tab('m');
1231+ k-i from:body -> ermite.tab('i');
1230 k-next from:body -> ermite.nextNote();1232 k-next from:body -> ermite.nextNote();
1231 k-split from:body -> ermite.layout('split');1233 k-split from:body -> ermite.layout('split');
1232 k-unified from:body -> ermite.layout('unified');1234 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/commits-small.html +3 -1
@@ -403,8 +403,9 @@
403403 <kbd>c</kbd>
404404 <kbd>r</kbd>
405405 <kbd>m</kbd>
406+<kbd>i</kbd>
406407 </dt>
407-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
408+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
408409 </div>
409410 <div>
410411 <dt>
@@ -592,6 +593,7 @@
592593 k-c from:body -> ermite.tab('c');
593594 k-r from:body -> ermite.tab('r');
594595 k-m from:body -> ermite.tab('m');
596+ k-i from:body -> ermite.tab('i');
595597 k-next from:body -> ermite.nextNote();
596598 k-split from:body -> ermite.layout('split');
597599 k-unified from:body -> ermite.layout('unified');
@@ -403,8 +403,9 @@
403 <kbd>c</kbd>403 <kbd>c</kbd>
404 <kbd>r</kbd>404 <kbd>r</kbd>
405 <kbd>m</kbd>405 <kbd>m</kbd>
406+<kbd>i</kbd>
406 </dt>407 </dt>
407-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>408+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
408 </div>409 </div>
409 <div>410 <div>
410 <dt>411 <dt>
@@ -592,6 +593,7 @@
592 k-c from:body -> ermite.tab('c');593 k-c from:body -> ermite.tab('c');
593 k-r from:body -> ermite.tab('r');594 k-r from:body -> ermite.tab('r');
594 k-m from:body -> ermite.tab('m');595 k-m from:body -> ermite.tab('m');
596+ k-i from:body -> ermite.tab('i');
595 k-next from:body -> ermite.nextNote();597 k-next from:body -> ermite.nextNote();
596 k-split from:body -> ermite.layout('split');598 k-split from:body -> ermite.layout('split');
597 k-unified from:body -> ermite.layout('unified');599 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/empty.html +3 -1
@@ -176,8 +176,9 @@
176176 <kbd>c</kbd>
177177 <kbd>r</kbd>
178178 <kbd>m</kbd>
179+<kbd>i</kbd>
179180 </dt>
180-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
181+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
181182 </div>
182183 <div>
183184 <dt>
@@ -276,6 +277,7 @@
276277 k-c from:body -> ermite.tab('c');
277278 k-r from:body -> ermite.tab('r');
278279 k-m from:body -> ermite.tab('m');
280+ k-i from:body -> ermite.tab('i');
279281 k-next from:body -> ermite.nextNote();
280282 k-split from:body -> ermite.layout('split');
281283 k-unified from:body -> ermite.layout('unified');
@@ -176,8 +176,9 @@
176 <kbd>c</kbd>176 <kbd>c</kbd>
177 <kbd>r</kbd>177 <kbd>r</kbd>
178 <kbd>m</kbd>178 <kbd>m</kbd>
179+<kbd>i</kbd>
179 </dt>180 </dt>
180-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>181+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
181 </div>182 </div>
182 <div>183 <div>
183 <dt>184 <dt>
@@ -276,6 +277,7 @@
276 k-c from:body -> ermite.tab('c');277 k-c from:body -> ermite.tab('c');
277 k-r from:body -> ermite.tab('r');278 k-r from:body -> ermite.tab('r');
278 k-m from:body -> ermite.tab('m');279 k-m from:body -> ermite.tab('m');
280+ k-i from:body -> ermite.tab('i');
279 k-next from:body -> ermite.nextNote();281 k-next from:body -> ermite.nextNote();
280 k-split from:body -> ermite.layout('split');282 k-split from:body -> ermite.layout('split');
281 k-unified from:body -> ermite.layout('unified');283 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/home.html +3 -1
@@ -277,8 +277,9 @@
277277 <kbd>c</kbd>
278278 <kbd>r</kbd>
279279 <kbd>m</kbd>
280+<kbd>i</kbd>
280281 </dt>
281-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
282+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
282283 </div>
283284 <div>
284285 <dt>
@@ -383,6 +384,7 @@
383384 k-c from:body -> ermite.tab('c');
384385 k-r from:body -> ermite.tab('r');
385386 k-m from:body -> ermite.tab('m');
387+ k-i from:body -> ermite.tab('i');
386388 k-next from:body -> ermite.nextNote();
387389 k-split from:body -> ermite.layout('split');
388390 k-unified from:body -> ermite.layout('unified');
@@ -277,8 +277,9 @@
277 <kbd>c</kbd>277 <kbd>c</kbd>
278 <kbd>r</kbd>278 <kbd>r</kbd>
279 <kbd>m</kbd>279 <kbd>m</kbd>
280+<kbd>i</kbd>
280 </dt>281 </dt>
281-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>282+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
282 </div>283 </div>
283 <div>284 <div>
284 <dt>285 <dt>
@@ -383,6 +384,7 @@
383 k-c from:body -> ermite.tab('c');384 k-c from:body -> ermite.tab('c');
384 k-r from:body -> ermite.tab('r');385 k-r from:body -> ermite.tab('r');
385 k-m from:body -> ermite.tab('m');386 k-m from:body -> ermite.tab('m');
387+ k-i from:body -> ermite.tab('i');
386 k-next from:body -> ermite.nextNote();388 k-next from:body -> ermite.nextNote();
387 k-split from:body -> ermite.layout('split');389 k-split from:body -> ermite.layout('split');
388 k-unified from:body -> ermite.layout('unified');390 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found-page.html +3 -1
@@ -165,8 +165,9 @@
165165 <kbd>c</kbd>
166166 <kbd>r</kbd>
167167 <kbd>m</kbd>
168+<kbd>i</kbd>
168169 </dt>
169-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
170+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
170171 </div>
171172 <div>
172173 <dt>
@@ -259,6 +260,7 @@
259260 k-c from:body -> ermite.tab('c');
260261 k-r from:body -> ermite.tab('r');
261262 k-m from:body -> ermite.tab('m');
263+ k-i from:body -> ermite.tab('i');
262264 k-next from:body -> ermite.nextNote();
263265 k-split from:body -> ermite.layout('split');
264266 k-unified from:body -> ermite.layout('unified');
@@ -165,8 +165,9 @@
165 <kbd>c</kbd>165 <kbd>c</kbd>
166 <kbd>r</kbd>166 <kbd>r</kbd>
167 <kbd>m</kbd>167 <kbd>m</kbd>
168+<kbd>i</kbd>
168 </dt>169 </dt>
169-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>170+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
170 </div>171 </div>
171 <div>172 <div>
172 <dt>173 <dt>
@@ -259,6 +260,7 @@
259 k-c from:body -> ermite.tab('c');260 k-c from:body -> ermite.tab('c');
260 k-r from:body -> ermite.tab('r');261 k-r from:body -> ermite.tab('r');
261 k-m from:body -> ermite.tab('m');262 k-m from:body -> ermite.tab('m');
263+ k-i from:body -> ermite.tab('i');
262 k-next from:body -> ermite.nextNote();264 k-next from:body -> ermite.nextNote();
263 k-split from:body -> ermite.layout('split');265 k-split from:body -> ermite.layout('split');
264 k-unified from:body -> ermite.layout('unified');266 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/not-found.html +3 -1
@@ -165,8 +165,9 @@
165165 <kbd>c</kbd>
166166 <kbd>r</kbd>
167167 <kbd>m</kbd>
168+<kbd>i</kbd>
168169 </dt>
169-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
170+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
170171 </div>
171172 <div>
172173 <dt>
@@ -259,6 +260,7 @@
259260 k-c from:body -> ermite.tab('c');
260261 k-r from:body -> ermite.tab('r');
261262 k-m from:body -> ermite.tab('m');
263+ k-i from:body -> ermite.tab('i');
262264 k-next from:body -> ermite.nextNote();
263265 k-split from:body -> ermite.layout('split');
264266 k-unified from:body -> ermite.layout('unified');
@@ -165,8 +165,9 @@
165 <kbd>c</kbd>165 <kbd>c</kbd>
166 <kbd>r</kbd>166 <kbd>r</kbd>
167 <kbd>m</kbd>167 <kbd>m</kbd>
168+<kbd>i</kbd>
168 </dt>169 </dt>
169-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>170+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
170 </div>171 </div>
171 <div>172 <div>
172 <dt>173 <dt>
@@ -259,6 +260,7 @@
259 k-c from:body -> ermite.tab('c');260 k-c from:body -> ermite.tab('c');
260 k-r from:body -> ermite.tab('r');261 k-r from:body -> ermite.tab('r');
261 k-m from:body -> ermite.tab('m');262 k-m from:body -> ermite.tab('m');
263+ k-i from:body -> ermite.tab('i');
262 k-next from:body -> ermite.nextNote();264 k-next from:body -> ermite.nextNote();
263 k-split from:body -> ermite.layout('split');265 k-split from:body -> ermite.layout('split');
264 k-unified from:body -> ermite.layout('unified');266 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/refs.html +3 -1
@@ -285,8 +285,9 @@
285285 <kbd>c</kbd>
286286 <kbd>r</kbd>
287287 <kbd>m</kbd>
288+<kbd>i</kbd>
288289 </dt>
289-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
290+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
290291 </div>
291292 <div>
292293 <dt>
@@ -484,6 +485,7 @@
484485 k-c from:body -> ermite.tab('c');
485486 k-r from:body -> ermite.tab('r');
486487 k-m from:body -> ermite.tab('m');
488+ k-i from:body -> ermite.tab('i');
487489 k-next from:body -> ermite.nextNote();
488490 k-split from:body -> ermite.layout('split');
489491 k-unified from:body -> ermite.layout('unified');
@@ -285,8 +285,9 @@
285 <kbd>c</kbd>285 <kbd>c</kbd>
286 <kbd>r</kbd>286 <kbd>r</kbd>
287 <kbd>m</kbd>287 <kbd>m</kbd>
288+<kbd>i</kbd>
288 </dt>289 </dt>
289-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>290+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
290 </div>291 </div>
291 <div>292 <div>
292 <dt>293 <dt>
@@ -484,6 +485,7 @@
484 k-c from:body -> ermite.tab('c');485 k-c from:body -> ermite.tab('c');
485 k-r from:body -> ermite.tab('r');486 k-r from:body -> ermite.tab('r');
486 k-m from:body -> ermite.tab('m');487 k-m from:body -> ermite.tab('m');
488+ k-i from:body -> ermite.tab('i');
487 k-next from:body -> ermite.nextNote();489 k-next from:body -> ermite.nextNote();
488 k-split from:body -> ermite.layout('split');490 k-split from:body -> ermite.layout('split');
489 k-unified from:body -> ermite.layout('unified');491 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/summary.html +3 -1
@@ -311,8 +311,9 @@
311311 <kbd>c</kbd>
312312 <kbd>r</kbd>
313313 <kbd>m</kbd>
314+<kbd>i</kbd>
314315 </dt>
315-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
316+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
316317 </div>
317318 <div>
318319 <dt>
@@ -510,6 +511,7 @@
510511 k-c from:body -> ermite.tab('c');
511512 k-r from:body -> ermite.tab('r');
512513 k-m from:body -> ermite.tab('m');
514+ k-i from:body -> ermite.tab('i');
513515 k-next from:body -> ermite.nextNote();
514516 k-split from:body -> ermite.layout('split');
515517 k-unified from:body -> ermite.layout('unified');
@@ -311,8 +311,9 @@
311 <kbd>c</kbd>311 <kbd>c</kbd>
312 <kbd>r</kbd>312 <kbd>r</kbd>
313 <kbd>m</kbd>313 <kbd>m</kbd>
314+<kbd>i</kbd>
314 </dt>315 </dt>
315-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>316+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
316 </div>317 </div>
317 <div>318 <div>
318 <dt>319 <dt>
@@ -510,6 +511,7 @@
510 k-c from:body -> ermite.tab('c');511 k-c from:body -> ermite.tab('c');
511 k-r from:body -> ermite.tab('r');512 k-r from:body -> ermite.tab('r');
512 k-m from:body -> ermite.tab('m');513 k-m from:body -> ermite.tab('m');
514+ k-i from:body -> ermite.tab('i');
513 k-next from:body -> ermite.nextNote();515 k-next from:body -> ermite.nextNote();
514 k-split from:body -> ermite.layout('split');516 k-split from:body -> ermite.layout('split');
515 k-unified from:body -> ermite.layout('unified');517 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-nested.html +3 -1
@@ -242,8 +242,9 @@
242242 <kbd>c</kbd>
243243 <kbd>r</kbd>
244244 <kbd>m</kbd>
245+<kbd>i</kbd>
245246 </dt>
246-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
247+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
247248 </div>
248249 <div>
249250 <dt>
@@ -425,6 +426,7 @@
425426 k-c from:body -> ermite.tab('c');
426427 k-r from:body -> ermite.tab('r');
427428 k-m from:body -> ermite.tab('m');
429+ k-i from:body -> ermite.tab('i');
428430 k-next from:body -> ermite.nextNote();
429431 k-split from:body -> ermite.layout('split');
430432 k-unified from:body -> ermite.layout('unified');
@@ -242,8 +242,9 @@
242 <kbd>c</kbd>242 <kbd>c</kbd>
243 <kbd>r</kbd>243 <kbd>r</kbd>
244 <kbd>m</kbd>244 <kbd>m</kbd>
245+<kbd>i</kbd>
245 </dt>246 </dt>
246-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>247+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
247 </div>248 </div>
248 <div>249 <div>
249 <dt>250 <dt>
@@ -425,6 +426,7 @@
425 k-c from:body -> ermite.tab('c');426 k-c from:body -> ermite.tab('c');
426 k-r from:body -> ermite.tab('r');427 k-r from:body -> ermite.tab('r');
427 k-m from:body -> ermite.tab('m');428 k-m from:body -> ermite.tab('m');
429+ k-i from:body -> ermite.tab('i');
428 k-next from:body -> ermite.nextNote();430 k-next from:body -> ermite.nextNote();
429 k-split from:body -> ermite.layout('split');431 k-split from:body -> ermite.layout('split');
430 k-unified from:body -> ermite.layout('unified');432 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-root.html +3 -1
@@ -463,8 +463,9 @@
463463 <kbd>c</kbd>
464464 <kbd>r</kbd>
465465 <kbd>m</kbd>
466+<kbd>i</kbd>
466467 </dt>
467-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
468+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
468469 </div>
469470 <div>
470471 <dt>
@@ -646,6 +647,7 @@
646647 k-c from:body -> ermite.tab('c');
647648 k-r from:body -> ermite.tab('r');
648649 k-m from:body -> ermite.tab('m');
650+ k-i from:body -> ermite.tab('i');
649651 k-next from:body -> ermite.nextNote();
650652 k-split from:body -> ermite.layout('split');
651653 k-unified from:body -> ermite.layout('unified');
@@ -463,8 +463,9 @@
463 <kbd>c</kbd>463 <kbd>c</kbd>
464 <kbd>r</kbd>464 <kbd>r</kbd>
465 <kbd>m</kbd>465 <kbd>m</kbd>
466+<kbd>i</kbd>
466 </dt>467 </dt>
467-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>468+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
468 </div>469 </div>
469 <div>470 <div>
470 <dt>471 <dt>
@@ -646,6 +647,7 @@
646 k-c from:body -> ermite.tab('c');647 k-c from:body -> ermite.tab('c');
647 k-r from:body -> ermite.tab('r');648 k-r from:body -> ermite.tab('r');
648 k-m from:body -> ermite.tab('m');649 k-m from:body -> ermite.tab('m');
650+ k-i from:body -> ermite.tab('i');
649 k-next from:body -> ermite.nextNote();651 k-next from:body -> ermite.nextNote();
650 k-split from:body -> ermite.layout('split');652 k-split from:body -> ermite.layout('split');
651 k-unified from:body -> ermite.layout('unified');653 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-slash-ref.html +3 -1
@@ -239,8 +239,9 @@
239239 <kbd>c</kbd>
240240 <kbd>r</kbd>
241241 <kbd>m</kbd>
242+<kbd>i</kbd>
242243 </dt>
243-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
244+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
244245 </div>
245246 <div>
246247 <dt>
@@ -422,6 +423,7 @@
422423 k-c from:body -> ermite.tab('c');
423424 k-r from:body -> ermite.tab('r');
424425 k-m from:body -> ermite.tab('m');
426+ k-i from:body -> ermite.tab('i');
425427 k-next from:body -> ermite.nextNote();
426428 k-split from:body -> ermite.layout('split');
427429 k-unified from:body -> ermite.layout('unified');
@@ -239,8 +239,9 @@
239 <kbd>c</kbd>239 <kbd>c</kbd>
240 <kbd>r</kbd>240 <kbd>r</kbd>
241 <kbd>m</kbd>241 <kbd>m</kbd>
242+<kbd>i</kbd>
242 </dt>243 </dt>
243-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>244+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
244 </div>245 </div>
245 <div>246 <div>
246 <dt>247 <dt>
@@ -422,6 +423,7 @@
422 k-c from:body -> ermite.tab('c');423 k-c from:body -> ermite.tab('c');
423 k-r from:body -> ermite.tab('r');424 k-r from:body -> ermite.tab('r');
424 k-m from:body -> ermite.tab('m');425 k-m from:body -> ermite.tab('m');
426+ k-i from:body -> ermite.tab('i');
425 k-next from:body -> ermite.nextNote();427 k-next from:body -> ermite.nextNote();
426 k-split from:body -> ermite.layout('split');428 k-split from:body -> ermite.layout('split');
427 k-unified from:body -> ermite.layout('unified');429 k-unified from:body -> ermite.layout('unified');
M internal/app/testdata/web/tree-subdir.html +3 -1
@@ -255,8 +255,9 @@
255255 <kbd>c</kbd>
256256 <kbd>r</kbd>
257257 <kbd>m</kbd>
258+<kbd>i</kbd>
258259 </dt>
259-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>
260+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
260261 </div>
261262 <div>
262263 <dt>
@@ -438,6 +439,7 @@
438439 k-c from:body -> ermite.tab('c');
439440 k-r from:body -> ermite.tab('r');
440441 k-m from:body -> ermite.tab('m');
442+ k-i from:body -> ermite.tab('i');
441443 k-next from:body -> ermite.nextNote();
442444 k-split from:body -> ermite.layout('split');
443445 k-unified from:body -> ermite.layout('unified');
@@ -255,8 +255,9 @@
255 <kbd>c</kbd>255 <kbd>c</kbd>
256 <kbd>r</kbd>256 <kbd>r</kbd>
257 <kbd>m</kbd>257 <kbd>m</kbd>
258+<kbd>i</kbd>
258 </dt>259 </dt>
259-<dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd>260+<dd>tabs: summary, files, commits, branches &amp; tags, merge requests, issues</dd>
260 </div>261 </div>
261 <div>262 <div>
262 <dt>263 <dt>
@@ -438,6 +439,7 @@
438 k-c from:body -> ermite.tab('c');439 k-c from:body -> ermite.tab('c');
439 k-r from:body -> ermite.tab('r');440 k-r from:body -> ermite.tab('r');
440 k-m from:body -> ermite.tab('m');441 k-m from:body -> ermite.tab('m');
442+ k-i from:body -> ermite.tab('i');
441 k-next from:body -> ermite.nextNote();443 k-next from:body -> ermite.nextNote();
442 k-split from:body -> ermite.layout('split');444 k-split from:body -> ermite.layout('split');
443 k-unified from:body -> ermite.layout('unified');445 k-unified from:body -> ermite.layout('unified');
M internal/app/web.go +2 -0
@@ -45,6 +45,8 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
4545 Repos: f.Repos,
4646 Browse: f.Browse,
4747 MergeRequests: f.MergeRequests,
48+ Issues: f.Issues,
49+ IssueNumbers: f.Issues,
4850 Runs: f.CI,
4951 Artifacts: artifacts,
5052 Events: f.EventLog,
@@ -45,6 +45,8 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
45 Repos: f.Repos,45 Repos: f.Repos,
46 Browse: f.Browse,46 Browse: f.Browse,
47 MergeRequests: f.MergeRequests,47 MergeRequests: f.MergeRequests,
48+ Issues: f.Issues,
49+ IssueNumbers: f.Issues,
48 Runs: f.CI,50 Runs: f.CI,
49 Artifacts: artifacts,51 Artifacts: artifacts,
50 Events: f.EventLog,52 Events: f.EventLog,
A internal/app/web_issue_test.go +139 -0
@@ -0,0 +1,139 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/url"
7+ "regexp"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+var reIssueLocation = regexp.MustCompile(`^/proj/issues/(\d+)$`)
19+
20+// The issue pages end to end: the owner opens, comments on, closes and
21+// reopens an issue through the forms; an anonymous visitor reads them on a
22+// public repository and cannot write; with issues off every page is a 404.
23+func TestWebIssuePagesEndToEnd(t *testing.T) {
24+ r := newMRRepo(t, domain.VisibilityPublic)
25+ b := browser{r.harness}
26+ token := r.signIn(t)
27+
28+ // Off by default: no page, no tab, for the owner as well.
29+ assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
30+ assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)
31+ assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`)
32+
33+ mode := domain.IssuesOwner
34+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})
35+ require.NoError(t, err)
36+
37+ home := b.get("/proj", nil)
38+ require.Equal(t, 200, home.status)
39+ assert.Contains(t, home.body, `href="/proj/issues"`, "the Issues tab")
40+
41+ empty := b.get("/proj/issues", nil)
42+ require.Equal(t, 200, empty.status, empty.body)
43+ assert.Contains(t, empty.body, "no open issues")
44+ assert.NotContains(t, empty.body, "New issue")
45+
46+ // The form is the owner's.
47+ assert.Equal(t, 404, b.get("/proj/issues/new", nil).status)
48+ form := b.get("/proj/issues/new", as(token))
49+ require.Equal(t, 200, form.status)
50+ assert.Contains(t, form.body, `action="/proj/issues"`)
51+
52+ // A POST without the session is refused.
53+ anon := b.do("POST", "/proj/issues", map[string]string{"Origin": publicOrigin}, url.Values{"title": {"x"}}.Encode())
54+ assert.Equal(t, 403, anon.status)
55+
56+ // Create.
57+ created := b.form("/proj/issues", token, url.Values{"title": {"search is slow"}, "body": {"## Steps\n\nrun **find**"}})
58+ require.Equal(t, 303, created.status, created.body)
59+ assert.Equal(t, "no-store", created.hdr.Get("Cache-Control"))
60+ m := reIssueLocation.FindStringSubmatch(created.hdr.Get("Location"))
61+ require.Len(t, m, 2, created.hdr.Get("Location"))
62+ path := "/proj/issues/" + m[1]
63+
64+ owner := b.get(path, as(token))
65+ require.Equal(t, 200, owner.status, owner.body)
66+ assert.Equal(t, "private, no-store", owner.hdr.Get("Cache-Control"))
67+ assert.Contains(t, owner.body, "search is slow")
68+ assert.Contains(t, owner.body, "<strong>find</strong>")
69+ assert.Contains(t, owner.body, `action="`+path+`/comments"`)
70+ assert.Contains(t, owner.body, `action="`+path+`/close"`)
71+
72+ // An invalid title is a state page, nothing is created.
73+ bad := b.form("/proj/issues", token, url.Values{"title": {""}})
74+ assert.Equal(t, 400, bad.status)
75+
76+ // Comment.
77+ com := b.form(path+"/comments", token, url.Values{"text": {"reproduced\r\non main"}})
78+ require.Equal(t, 303, com.status, com.body)
79+ assert.Equal(t, path+"#c1", com.hdr.Get("Location"))
80+ assert.Equal(t, 400, b.form(path+"/comments", token, url.Values{"text": {" "}}).status, "a blank comment")
81+
82+ // Anonymous reads a public repository: no forms, public cache headers.
83+ pub := b.get(path, nil)
84+ require.Equal(t, 200, pub.status)
85+ assert.Equal(t, "no-cache", pub.hdr.Get("Cache-Control"))
86+ assert.Contains(t, pub.body, "reproduced")
87+ assert.NotContains(t, pub.body, `class="cform"`)
88+ assert.NotContains(t, pub.body, path+"/close")
89+ list := b.get("/proj/issues", nil)
90+ assert.Contains(t, list.body, `href="`+path+`"`)
91+ assert.Contains(t, list.body, "search is slow")
92+ assert.Equal(t, 403, b.do("POST", path+"/close", map[string]string{"Origin": publicOrigin}, "").status)
93+
94+ // Close, then reopen.
95+ closed := b.form(path+"/close", token, nil)
96+ require.Equal(t, 303, closed.status, closed.body)
97+ assert.Equal(t, path, closed.hdr.Get("Location"))
98+ assert.Contains(t, b.get("/proj/issues?state=closed", nil).body, `href="`+path+`"`)
99+ assert.NotContains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
100+ again := b.get(path, as(token))
101+ assert.Contains(t, again.body, `action="`+path+`/reopen"`)
102+ assert.Equal(t, 409, b.form(path+"/close", token, nil).status, "closing a closed issue")
103+
104+ reopened := b.form(path+"/reopen", token, nil)
105+ require.Equal(t, 303, reopened.status, reopened.body)
106+ assert.Contains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
107+
108+ // The number is shared with the merge requests: #n goes to the right page.
109+ mr, err := r.forge.MergeRequests.Create(r.ctx, domain.Root(), r.name("proj"), ports.CreateMergeRequestParams{
110+ Title: "add search", Source: "feat/search", Target: "main",
111+ })
112+ require.NoError(t, err)
113+ to := b.get("/proj/issues/"+itoa64(mr.Number), nil)
114+ assert.Equal(t, 303, to.status)
115+ assert.Equal(t, "/proj/merge-requests/"+itoa64(mr.Number), to.hdr.Get("Location"))
116+ back := b.get("/proj/merge-requests/"+m[1], nil)
117+ assert.Equal(t, 303, back.status)
118+ assert.Equal(t, path, back.hdr.Get("Location"))
119+ assert.Equal(t, 404, b.get("/proj/issues/999", nil).status)
120+ assert.Equal(t, 404, b.get("/proj/merge-requests/999", nil).status)
121+
122+ // Private repository: invisible to an anonymous visitor.
123+ priv := domain.VisibilityPrivate
124+ _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
125+ require.NoError(t, err)
126+ assert.Equal(t, 404, b.get("/proj/issues", nil).status)
127+ assert.Equal(t, 404, b.get(path, nil).status)
128+ assert.Equal(t, 200, b.get(path, as(token)).status)
129+
130+ // Off again: 404 for everyone.
131+ off := domain.IssuesOff
132+ _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &off})
133+ require.NoError(t, err)
134+ assert.Equal(t, 404, b.get(path, as(token)).status)
135+ assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
136+ assert.Equal(t, 404, b.form(path+"/comments", token, url.Values{"text": {"x"}}).status)
137+}
138+
139+func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
@@ -0,0 +1,139 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/url"
7+ "regexp"
8+ "strconv"
9+ "testing"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+var reIssueLocation = regexp.MustCompile(`^/proj/issues/(\d+)$`)
19+
20+// The issue pages end to end: the owner opens, comments on, closes and
21+// reopens an issue through the forms; an anonymous visitor reads them on a
22+// public repository and cannot write; with issues off every page is a 404.
23+func TestWebIssuePagesEndToEnd(t *testing.T) {
24+ r := newMRRepo(t, domain.VisibilityPublic)
25+ b := browser{r.harness}
26+ token := r.signIn(t)
27+
28+ // Off by default: no page, no tab, for the owner as well.
29+ assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
30+ assert.Equal(t, 404, b.get("/proj/issues/new", as(token)).status)
31+ assert.NotContains(t, b.get("/proj", as(token)).body, `data-key="i"`)
32+
33+ mode := domain.IssuesOwner
34+ _, err := r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &mode})
35+ require.NoError(t, err)
36+
37+ home := b.get("/proj", nil)
38+ require.Equal(t, 200, home.status)
39+ assert.Contains(t, home.body, `href="/proj/issues"`, "the Issues tab")
40+
41+ empty := b.get("/proj/issues", nil)
42+ require.Equal(t, 200, empty.status, empty.body)
43+ assert.Contains(t, empty.body, "no open issues")
44+ assert.NotContains(t, empty.body, "New issue")
45+
46+ // The form is the owner's.
47+ assert.Equal(t, 404, b.get("/proj/issues/new", nil).status)
48+ form := b.get("/proj/issues/new", as(token))
49+ require.Equal(t, 200, form.status)
50+ assert.Contains(t, form.body, `action="/proj/issues"`)
51+
52+ // A POST without the session is refused.
53+ anon := b.do("POST", "/proj/issues", map[string]string{"Origin": publicOrigin}, url.Values{"title": {"x"}}.Encode())
54+ assert.Equal(t, 403, anon.status)
55+
56+ // Create.
57+ created := b.form("/proj/issues", token, url.Values{"title": {"search is slow"}, "body": {"## Steps\n\nrun **find**"}})
58+ require.Equal(t, 303, created.status, created.body)
59+ assert.Equal(t, "no-store", created.hdr.Get("Cache-Control"))
60+ m := reIssueLocation.FindStringSubmatch(created.hdr.Get("Location"))
61+ require.Len(t, m, 2, created.hdr.Get("Location"))
62+ path := "/proj/issues/" + m[1]
63+
64+ owner := b.get(path, as(token))
65+ require.Equal(t, 200, owner.status, owner.body)
66+ assert.Equal(t, "private, no-store", owner.hdr.Get("Cache-Control"))
67+ assert.Contains(t, owner.body, "search is slow")
68+ assert.Contains(t, owner.body, "<strong>find</strong>")
69+ assert.Contains(t, owner.body, `action="`+path+`/comments"`)
70+ assert.Contains(t, owner.body, `action="`+path+`/close"`)
71+
72+ // An invalid title is a state page, nothing is created.
73+ bad := b.form("/proj/issues", token, url.Values{"title": {""}})
74+ assert.Equal(t, 400, bad.status)
75+
76+ // Comment.
77+ com := b.form(path+"/comments", token, url.Values{"text": {"reproduced\r\non main"}})
78+ require.Equal(t, 303, com.status, com.body)
79+ assert.Equal(t, path+"#c1", com.hdr.Get("Location"))
80+ assert.Equal(t, 400, b.form(path+"/comments", token, url.Values{"text": {" "}}).status, "a blank comment")
81+
82+ // Anonymous reads a public repository: no forms, public cache headers.
83+ pub := b.get(path, nil)
84+ require.Equal(t, 200, pub.status)
85+ assert.Equal(t, "no-cache", pub.hdr.Get("Cache-Control"))
86+ assert.Contains(t, pub.body, "reproduced")
87+ assert.NotContains(t, pub.body, `class="cform"`)
88+ assert.NotContains(t, pub.body, path+"/close")
89+ list := b.get("/proj/issues", nil)
90+ assert.Contains(t, list.body, `href="`+path+`"`)
91+ assert.Contains(t, list.body, "search is slow")
92+ assert.Equal(t, 403, b.do("POST", path+"/close", map[string]string{"Origin": publicOrigin}, "").status)
93+
94+ // Close, then reopen.
95+ closed := b.form(path+"/close", token, nil)
96+ require.Equal(t, 303, closed.status, closed.body)
97+ assert.Equal(t, path, closed.hdr.Get("Location"))
98+ assert.Contains(t, b.get("/proj/issues?state=closed", nil).body, `href="`+path+`"`)
99+ assert.NotContains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
100+ again := b.get(path, as(token))
101+ assert.Contains(t, again.body, `action="`+path+`/reopen"`)
102+ assert.Equal(t, 409, b.form(path+"/close", token, nil).status, "closing a closed issue")
103+
104+ reopened := b.form(path+"/reopen", token, nil)
105+ require.Equal(t, 303, reopened.status, reopened.body)
106+ assert.Contains(t, b.get("/proj/issues", nil).body, `href="`+path+`"`)
107+
108+ // The number is shared with the merge requests: #n goes to the right page.
109+ mr, err := r.forge.MergeRequests.Create(r.ctx, domain.Root(), r.name("proj"), ports.CreateMergeRequestParams{
110+ Title: "add search", Source: "feat/search", Target: "main",
111+ })
112+ require.NoError(t, err)
113+ to := b.get("/proj/issues/"+itoa64(mr.Number), nil)
114+ assert.Equal(t, 303, to.status)
115+ assert.Equal(t, "/proj/merge-requests/"+itoa64(mr.Number), to.hdr.Get("Location"))
116+ back := b.get("/proj/merge-requests/"+m[1], nil)
117+ assert.Equal(t, 303, back.status)
118+ assert.Equal(t, path, back.hdr.Get("Location"))
119+ assert.Equal(t, 404, b.get("/proj/issues/999", nil).status)
120+ assert.Equal(t, 404, b.get("/proj/merge-requests/999", nil).status)
121+
122+ // Private repository: invisible to an anonymous visitor.
123+ priv := domain.VisibilityPrivate
124+ _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Visibility: &priv})
125+ require.NoError(t, err)
126+ assert.Equal(t, 404, b.get("/proj/issues", nil).status)
127+ assert.Equal(t, 404, b.get(path, nil).status)
128+ assert.Equal(t, 200, b.get(path, as(token)).status)
129+
130+ // Off again: 404 for everyone.
131+ off := domain.IssuesOff
132+ _, err = r.forge.Repos.Update(r.ctx, domain.Root(), r.name("proj"), ports.UpdateRepoParams{Issues: &off})
133+ require.NoError(t, err)
134+ assert.Equal(t, 404, b.get(path, as(token)).status)
135+ assert.Equal(t, 404, b.get("/proj/issues", as(token)).status)
136+ assert.Equal(t, 404, b.form(path+"/comments", token, url.Values{"text": {"x"}}).status)
137+}
138+
139+func itoa64(n int64) string { return strconv.FormatInt(n, 10) }

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 43

↑↓ moveenter openesc close