merge(web): issue pages into feat/issues
48 files · +2129 -40
M internal/adapters/web/events.go +3 -1
| @@ -31,6 +31,8 @@ import ( | |||
| 31 | 31 | // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha> | |
| 32 | 32 | // GET /-/events?topic=repo:<name>&view=mrs | |
| 33 | 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 | 36 | // GET /-/events?topic=repo:<name>&view=actions | |
| 35 | 37 | // GET /-/events?topic=repo:<name>&view=run&n=<run id> | |
| 36 | 38 | // GET /-/events?topic=repo:<name>&view=job&job=<job id> | |
| @@ -51,7 +53,7 @@ import ( | |||
| 51 | 53 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | |
| 52 | 54 | // <dl id="refs-kv">…</dl>, the facts of the rail | |
| 53 | 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 | 57 | // something changed, with the link that reloads the page | |
| 56 | 58 | // ci-run data: <tr id="run-<id>">…</tr> (the list of runs), | |
| 57 | 59 | // <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=mrs | 32 | // 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=actions | 36 | // 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 with | 53 | // refs-list data: <tbody id="ref-rows">…</tbody>, and again with | ||
| 52 | // <dl id="refs-kv">…</dl>, the facts of the rail | 54 | // <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 page | 57 | // 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) or | 59 | // <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 { | |||
| 59 | 59 | switch we.Event.(type) { | |
| 60 | 60 | case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent, | |
| 61 | 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 | 64 | return true | |
| 64 | 65 | } | |
| 65 | 66 | return false | |
| @@ -166,6 +167,14 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm | |||
| 166 | 167 | return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload") | |
| 167 | 168 | case domain.CommentAdded: | |
| 168 | 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 | 178 | case domain.PushEvent: | |
| 170 | 179 | switch l.view { | |
| 171 | 180 | case "mr": | |
| @@ -400,6 +409,22 @@ func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([ | |||
| 400 | 409 | 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 | 428 | // mrPushed announces a push to the source branch of the merge request shown. | |
| 404 | 429 | func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) { | |
| 405 | 430 | 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 true | 64 | return true | ||
| 64 | } | 65 | } | ||
| 65 | return false | 66 | 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, nil | 409 | 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 +86 -0
| @@ -0,0 +1,86 @@ | |||
| 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 | + | //hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which | |
| 71 | + | 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) { | |
| 72 | + | repo := r.PathValue("repo") | |
| 73 | + | n, err := number(r) | |
| 74 | + | if err != nil { | |
| 75 | + | return "", "", err | |
| 76 | + | } | |
| 77 | + | back := issueHref(repo, n) | |
| 78 | + | name, err := domain.ParseRepoName(repo) | |
| 79 | + | if err != nil { | |
| 80 | + | return "", back, err | |
| 81 | + | } | |
| 82 | + | if _, err := do(ctx, principal(ctx), name, n); err != nil { | |
| 83 | + | return "", back, err | |
| 84 | + | } | |
| 85 | + | return back, back, nil | |
| 86 | + | } | |
| @@ -0,0 +1,86 @@ | |||||
| 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 | + | //hexcheck:ignore suspicious-business-logic-in-adapter closes or reopens through the use case that owns the transition; the form only picks which | |||
| 71 | + | 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) { | |||
| 72 | + | repo := r.PathValue("repo") | |||
| 73 | + | n, err := number(r) | |||
| 74 | + | if err != nil { | |||
| 75 | + | return "", "", err | |||
| 76 | + | } | |||
| 77 | + | back := issueHref(repo, n) | |||
| 78 | + | name, err := domain.ParseRepoName(repo) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return "", back, err | |||
| 81 | + | } | |||
| 82 | + | if _, err := do(ctx, principal(ctx), name, n); err != nil { | |||
| 83 | + | return "", back, err | |||
| 84 | + | } | |||
| 85 | + | return back, back, nil | |||
| 86 | + | } | |||
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 <b>on</b> 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, "<script>x</script>") | |
| 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 <b>on</b> 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, "<script>x</script>") | |||
| 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() { | |||
| 27 | 27 | k-c from:body -> ermite.tab('c'); | |
| 28 | 28 | k-r from:body -> ermite.tab('r'); | |
| 29 | 29 | k-m from:body -> ermite.tab('m'); | |
| 30 | + | k-i from:body -> ermite.tab('i'); | |
| 30 | 31 | k-next from:body -> ermite.nextNote(); | |
| 31 | 32 | k-split from:body -> ermite.layout('split'); | |
| 32 | 33 | 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 generated file: collapsed
M internal/adapters/web/mr_post.go +5 -1
| @@ -31,7 +31,11 @@ const maxFormBytes = 128 << 10 | |||
| 31 | 31 | func (s *Server) form(w http.ResponseWriter, r *http.Request) bool { | |
| 32 | 32 | r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) | |
| 33 | 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 | 39 | return false | |
| 36 | 40 | } | |
| 37 | 41 | 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 false | 39 | return false | ||
| 36 | } | 40 | } | ||
| 37 | return true | 41 | return true | ||
M internal/adapters/web/overlays.templ +1 -1
| @@ -26,7 +26,7 @@ templ OverlayHelp(mr bool) { | |||
| 26 | 26 | <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div> | |
| 27 | 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 | 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 & 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 & tags, merge requests, issues</dd></div> | |
| 30 | 30 | <div><dt><kbd>g h</kbd></dt><dd>home</dd></div> | |
| 31 | 31 | <div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div> | |
| 32 | 32 | <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 & 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 & 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
| @@ -51,7 +51,7 @@ func OverlayHelp(mr bool) templ.Component { | |||
| 51 | 51 | if templ_7745c5c3_Err != nil { | |
| 52 | 52 | return templ_7745c5c3_Err | |
| 53 | 53 | } | |
| 54 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches & tags, merge requests</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>") | |
| 54 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><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 & tags, merge requests, issues</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>") | |
| 55 | 55 | if templ_7745c5c3_Err != nil { | |
| 56 | 56 | return templ_7745c5c3_Err | |
| 57 | 57 | } | |
| @@ -51,7 +51,7 @@ func OverlayHelp(mr bool) templ.Component { | |||||
| 51 | if templ_7745c5c3_Err != nil { | 51 | if templ_7745c5c3_Err != nil { | ||
| 52 | return templ_7745c5c3_Err | 52 | return templ_7745c5c3_Err | ||
| 53 | } | 53 | } | ||
| 54 | - | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches & tags, merge requests</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>") | 54 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " branch or tag</dd></div><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 & tags, merge requests, issues</dd></div><div><dt><kbd>g h</kbd></dt><dd>home</dd></div><div><dt><kbd>y</kbd></dt><dd>copy a permalink pinned to the commit</dd></div><div><dt><kbd>d</kbd> <kbd>u</kbd> <kbd>x</kbd></dt><dd>diff: split / unified / collapse or expand all</dd></div><div><dt><kbd>e</kbd> <kbd>E</kbd></dt><dd>diff: the whole file in view / every file, with the lines the hunks hide</dd></div><div><dt><kbd>n</kbd></dt><dd>merge request: next note in the diff</dd></div>") |
| 55 | if templ_7745c5c3_Err != nil { | 55 | if templ_7745c5c3_Err != nil { | ||
| 56 | return templ_7745c5c3_Err | 56 | return templ_7745c5c3_Err | ||
| 57 | } | 57 | } | ||
M internal/adapters/web/repo.go +5 -0
| @@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string { | |||
| 131 | 131 | // layout is the Layout of the pages of a repository: the sections, the ref | |
| 132 | 132 | // switcher on the head ref and the ":" targets. The caller sets the title, | |
| 133 | 133 | // the crumbs, the status and the rail. | |
| 134 | + | // | |
| 135 | + | //hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here | |
| 134 | 136 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | |
| 135 | 137 | l := s.base() | |
| 136 | 138 | l.Repo = d.name() | |
| @@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 155 | 157 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | |
| 156 | 158 | l.Sections = append(l.Sections, mrs) | |
| 157 | 159 | } | |
| 160 | + | if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff { | |
| 161 | + | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | |
| 162 | + | } | |
| 158 | 163 | l.Sections = s.withActions(l.Sections, d.name(), section) | |
| 159 | 164 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | |
| 160 | 165 | ||
| @@ -131,6 +131,8 @@ func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string { | |||||
| 131 | // layout is the Layout of the pages of a repository: the sections, the ref | 131 | // layout is the Layout of the pages of a repository: the sections, the ref | ||
| 132 | // switcher on the head ref and the ":" targets. The caller sets the title, | 132 | // switcher on the head ref and the ":" targets. The caller sets the title, | ||
| 133 | // the crumbs, the status and the rail. | 133 | // the crumbs, the status and the rail. | ||
| 134 | + | // | |||
| 135 | + | //hexcheck:ignore suspicious-business-logic-in-adapter lists the tabs of the repository; the issues tab follows the setting, no rule is decided here | |||
| 134 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | 136 | func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) Layout { | ||
| 135 | l := s.base() | 137 | l := s.base() | ||
| 136 | l.Repo = d.name() | 138 | l.Repo = d.name() | ||
| @@ -155,6 +157,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||||
| 155 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | 157 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | ||
| 156 | l.Sections = append(l.Sections, mrs) | 158 | l.Sections = append(l.Sections, mrs) | ||
| 157 | } | 159 | } | ||
| 160 | + | if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff { | |||
| 161 | + | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | |||
| 162 | + | } | |||
| 158 | l.Sections = s.withActions(l.Sections, d.name(), section) | 163 | l.Sections = s.withActions(l.Sections, d.name(), section) | ||
| 159 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | 164 | l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}} | ||
| 160 | 165 | ||||
M internal/adapters/web/static/js/ermite.js +2 -2
| @@ -555,11 +555,11 @@ | |||
| 555 | 555 | ||
| 556 | 556 | // 1. Keys. | |
| 557 | 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 | 559 | // the merge request page (mr.js): the merge menu, mark read | |
| 560 | 560 | M: 'merge', R: 'read' }; | |
| 561 | 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 | 563 | let pending = 0; | |
| 564 | 564 | let pendingTimer = 0; | |
| 565 | 565 | 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 read | 559 | // 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 { | |||
| 54 | 54 | // there are no such pages, nor section for them. Leave it unset rather | |
| 55 | 55 | // than assigning a typed nil pointer. | |
| 56 | 56 | 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 | 66 | // Runs serves the CI pages (/<repo>/actions) and the checks shown next to | |
| 58 | 67 | // commits and merge requests. Without it there are no such pages. Leave it | |
| 59 | 68 | // unset rather than assigning a typed nil pointer. | |
| @@ -182,7 +191,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 182 | 191 | if s.cfg.MergeRequests != nil { | |
| 183 | 192 | mux.HandleFunc("GET /{repo}/merge-requests", s.page(s.mrList)) | |
| 184 | 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 | 195 | mux.HandleFunc("GET /{repo}/merge-requests/{n}/since", s.page(s.mrSince)) | |
| 187 | 196 | mux.HandleFunc("GET /{repo}/merge-requests/{n}/files", s.page(s.mrFiles)) | |
| 188 | 197 | mux.HandleFunc("GET /{repo}/compare/{rest...}", s.page(s.compare)) | |
| @@ -195,6 +204,17 @@ func (s *Server) routes() *http.ServeMux { | |||
| 195 | 204 | mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady)) | |
| 196 | 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 | 218 | if s.cfg.Runs != nil { | |
| 199 | 219 | mux.HandleFunc("GET /{repo}/actions", s.page(s.actions)) | |
| 200 | 220 | 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 rather | 54 | // 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.MergeRequestService | 56 | 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 to | 66 | // 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 it | 67 | // 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 @@ | |||
| 227 | 227 | <kbd>c</kbd> | |
| 228 | 228 | <kbd>r</kbd> | |
| 229 | 229 | <kbd>m</kbd> | |
| 230 | + | <kbd>i</kbd> | |
| 230 | 231 | </dt> | |
| 231 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 232 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 232 | 233 | </div> | |
| 233 | 234 | <div> | |
| 234 | 235 | <dt> | |
| @@ -410,6 +411,7 @@ | |||
| 410 | 411 | k-c from:body -> ermite.tab('c'); | |
| 411 | 412 | k-r from:body -> ermite.tab('r'); | |
| 412 | 413 | k-m from:body -> ermite.tab('m'); | |
| 414 | + | k-i from:body -> ermite.tab('i'); | |
| 413 | 415 | k-next from:body -> ermite.nextNote(); | |
| 414 | 416 | k-split from:body -> ermite.layout('split'); | |
| 415 | 417 | 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 & tags, merge requests</dd> | 232 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 297 | 297 | <kbd>c</kbd> | |
| 298 | 298 | <kbd>r</kbd> | |
| 299 | 299 | <kbd>m</kbd> | |
| 300 | + | <kbd>i</kbd> | |
| 300 | 301 | </dt> | |
| 301 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 302 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 302 | 303 | </div> | |
| 303 | 304 | <div> | |
| 304 | 305 | <dt> | |
| @@ -480,6 +481,7 @@ | |||
| 480 | 481 | k-c from:body -> ermite.tab('c'); | |
| 481 | 482 | k-r from:body -> ermite.tab('r'); | |
| 482 | 483 | k-m from:body -> ermite.tab('m'); | |
| 484 | + | k-i from:body -> ermite.tab('i'); | |
| 483 | 485 | k-next from:body -> ermite.nextNote(); | |
| 484 | 486 | k-split from:body -> ermite.layout('split'); | |
| 485 | 487 | 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 & tags, merge requests</dd> | 302 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 227 | 227 | <kbd>c</kbd> | |
| 228 | 228 | <kbd>r</kbd> | |
| 229 | 229 | <kbd>m</kbd> | |
| 230 | + | <kbd>i</kbd> | |
| 230 | 231 | </dt> | |
| 231 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 232 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 232 | 233 | </div> | |
| 233 | 234 | <div> | |
| 234 | 235 | <dt> | |
| @@ -410,6 +411,7 @@ | |||
| 410 | 411 | k-c from:body -> ermite.tab('c'); | |
| 411 | 412 | k-r from:body -> ermite.tab('r'); | |
| 412 | 413 | k-m from:body -> ermite.tab('m'); | |
| 414 | + | k-i from:body -> ermite.tab('i'); | |
| 413 | 415 | k-next from:body -> ermite.nextNote(); | |
| 414 | 416 | k-split from:body -> ermite.layout('split'); | |
| 415 | 417 | 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 & tags, merge requests</dd> | 232 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 229 | 229 | <kbd>c</kbd> | |
| 230 | 230 | <kbd>r</kbd> | |
| 231 | 231 | <kbd>m</kbd> | |
| 232 | + | <kbd>i</kbd> | |
| 232 | 233 | </dt> | |
| 233 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 234 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 234 | 235 | </div> | |
| 235 | 236 | <div> | |
| 236 | 237 | <dt> | |
| @@ -412,6 +413,7 @@ | |||
| 412 | 413 | k-c from:body -> ermite.tab('c'); | |
| 413 | 414 | k-r from:body -> ermite.tab('r'); | |
| 414 | 415 | k-m from:body -> ermite.tab('m'); | |
| 416 | + | k-i from:body -> ermite.tab('i'); | |
| 415 | 417 | k-next from:body -> ermite.nextNote(); | |
| 416 | 418 | k-split from:body -> ermite.layout('split'); | |
| 417 | 419 | 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 & tags, merge requests</dd> | 234 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 294 | 294 | <kbd>c</kbd> | |
| 295 | 295 | <kbd>r</kbd> | |
| 296 | 296 | <kbd>m</kbd> | |
| 297 | + | <kbd>i</kbd> | |
| 297 | 298 | </dt> | |
| 298 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 299 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 299 | 300 | </div> | |
| 300 | 301 | <div> | |
| 301 | 302 | <dt> | |
| @@ -477,6 +478,7 @@ | |||
| 477 | 478 | k-c from:body -> ermite.tab('c'); | |
| 478 | 479 | k-r from:body -> ermite.tab('r'); | |
| 479 | 480 | k-m from:body -> ermite.tab('m'); | |
| 481 | + | k-i from:body -> ermite.tab('i'); | |
| 480 | 482 | k-next from:body -> ermite.nextNote(); | |
| 481 | 483 | k-split from:body -> ermite.layout('split'); | |
| 482 | 484 | 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 & tags, merge requests</dd> | 299 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 252 | 252 | <kbd>c</kbd> | |
| 253 | 253 | <kbd>r</kbd> | |
| 254 | 254 | <kbd>m</kbd> | |
| 255 | + | <kbd>i</kbd> | |
| 255 | 256 | </dt> | |
| 256 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 257 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 257 | 258 | </div> | |
| 258 | 259 | <div> | |
| 259 | 260 | <dt> | |
| @@ -435,6 +436,7 @@ | |||
| 435 | 436 | k-c from:body -> ermite.tab('c'); | |
| 436 | 437 | k-r from:body -> ermite.tab('r'); | |
| 437 | 438 | k-m from:body -> ermite.tab('m'); | |
| 439 | + | k-i from:body -> ermite.tab('i'); | |
| 438 | 440 | k-next from:body -> ermite.nextNote(); | |
| 439 | 441 | k-split from:body -> ermite.layout('split'); | |
| 440 | 442 | 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 & tags, merge requests</dd> | 257 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 247 | 247 | <kbd>c</kbd> | |
| 248 | 248 | <kbd>r</kbd> | |
| 249 | 249 | <kbd>m</kbd> | |
| 250 | + | <kbd>i</kbd> | |
| 250 | 251 | </dt> | |
| 251 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 252 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 252 | 253 | </div> | |
| 253 | 254 | <div> | |
| 254 | 255 | <dt> | |
| @@ -430,6 +431,7 @@ | |||
| 430 | 431 | k-c from:body -> ermite.tab('c'); | |
| 431 | 432 | k-r from:body -> ermite.tab('r'); | |
| 432 | 433 | k-m from:body -> ermite.tab('m'); | |
| 434 | + | k-i from:body -> ermite.tab('i'); | |
| 433 | 435 | k-next from:body -> ermite.nextNote(); | |
| 434 | 436 | k-split from:body -> ermite.layout('split'); | |
| 435 | 437 | 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 & tags, merge requests</dd> | 252 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 227 | 227 | <kbd>c</kbd> | |
| 228 | 228 | <kbd>r</kbd> | |
| 229 | 229 | <kbd>m</kbd> | |
| 230 | + | <kbd>i</kbd> | |
| 230 | 231 | </dt> | |
| 231 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 232 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 232 | 233 | </div> | |
| 233 | 234 | <div> | |
| 234 | 235 | <dt> | |
| @@ -410,6 +411,7 @@ | |||
| 410 | 411 | k-c from:body -> ermite.tab('c'); | |
| 411 | 412 | k-r from:body -> ermite.tab('r'); | |
| 412 | 413 | k-m from:body -> ermite.tab('m'); | |
| 414 | + | k-i from:body -> ermite.tab('i'); | |
| 413 | 415 | k-next from:body -> ermite.nextNote(); | |
| 414 | 416 | k-split from:body -> ermite.layout('split'); | |
| 415 | 417 | 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 & tags, merge requests</dd> | 232 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 294 | 294 | <kbd>c</kbd> | |
| 295 | 295 | <kbd>r</kbd> | |
| 296 | 296 | <kbd>m</kbd> | |
| 297 | + | <kbd>i</kbd> | |
| 297 | 298 | </dt> | |
| 298 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 299 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 299 | 300 | </div> | |
| 300 | 301 | <div> | |
| 301 | 302 | <dt> | |
| @@ -477,6 +478,7 @@ | |||
| 477 | 478 | k-c from:body -> ermite.tab('c'); | |
| 478 | 479 | k-r from:body -> ermite.tab('r'); | |
| 479 | 480 | k-m from:body -> ermite.tab('m'); | |
| 481 | + | k-i from:body -> ermite.tab('i'); | |
| 480 | 482 | k-next from:body -> ermite.nextNote(); | |
| 481 | 483 | k-split from:body -> ermite.layout('split'); | |
| 482 | 484 | 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 & tags, merge requests</dd> | 299 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 269 | 269 | <kbd>c</kbd> | |
| 270 | 270 | <kbd>r</kbd> | |
| 271 | 271 | <kbd>m</kbd> | |
| 272 | + | <kbd>i</kbd> | |
| 272 | 273 | </dt> | |
| 273 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 274 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 274 | 275 | </div> | |
| 275 | 276 | <div> | |
| 276 | 277 | <dt> | |
| @@ -452,6 +453,7 @@ | |||
| 452 | 453 | k-c from:body -> ermite.tab('c'); | |
| 453 | 454 | k-r from:body -> ermite.tab('r'); | |
| 454 | 455 | k-m from:body -> ermite.tab('m'); | |
| 456 | + | k-i from:body -> ermite.tab('i'); | |
| 455 | 457 | k-next from:body -> ermite.nextNote(); | |
| 456 | 458 | k-split from:body -> ermite.layout('split'); | |
| 457 | 459 | 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 & tags, merge requests</dd> | 274 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 261 | 261 | <kbd>c</kbd> | |
| 262 | 262 | <kbd>r</kbd> | |
| 263 | 263 | <kbd>m</kbd> | |
| 264 | + | <kbd>i</kbd> | |
| 264 | 265 | </dt> | |
| 265 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 266 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 266 | 267 | </div> | |
| 267 | 268 | <div> | |
| 268 | 269 | <dt> | |
| @@ -444,6 +445,7 @@ | |||
| 444 | 445 | k-c from:body -> ermite.tab('c'); | |
| 445 | 446 | k-r from:body -> ermite.tab('r'); | |
| 446 | 447 | k-m from:body -> ermite.tab('m'); | |
| 448 | + | k-i from:body -> ermite.tab('i'); | |
| 447 | 449 | k-next from:body -> ermite.nextNote(); | |
| 448 | 450 | k-split from:body -> ermite.layout('split'); | |
| 449 | 451 | 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 & tags, merge requests</dd> | 266 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 303 | 303 | <kbd>c</kbd> | |
| 304 | 304 | <kbd>r</kbd> | |
| 305 | 305 | <kbd>m</kbd> | |
| 306 | + | <kbd>i</kbd> | |
| 306 | 307 | </dt> | |
| 307 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 308 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 308 | 309 | </div> | |
| 309 | 310 | <div> | |
| 310 | 311 | <dt> | |
| @@ -486,6 +487,7 @@ | |||
| 486 | 487 | k-c from:body -> ermite.tab('c'); | |
| 487 | 488 | k-r from:body -> ermite.tab('r'); | |
| 488 | 489 | k-m from:body -> ermite.tab('m'); | |
| 490 | + | k-i from:body -> ermite.tab('i'); | |
| 489 | 491 | k-next from:body -> ermite.nextNote(); | |
| 490 | 492 | k-split from:body -> ermite.layout('split'); | |
| 491 | 493 | 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 & tags, merge requests</dd> | 308 | + | <dd>tabs: summary, files, commits, branches & 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 <b>html</b> & "quotes".</pre> | |||
| 426 | 426 | <kbd>c</kbd> | |
| 427 | 427 | <kbd>r</kbd> | |
| 428 | 428 | <kbd>m</kbd> | |
| 429 | + | <kbd>i</kbd> | |
| 429 | 430 | </dt> | |
| 430 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 431 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 431 | 432 | </div> | |
| 432 | 433 | <div> | |
| 433 | 434 | <dt> | |
| @@ -609,6 +610,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 609 | 610 | k-c from:body -> ermite.tab('c'); | |
| 610 | 611 | k-r from:body -> ermite.tab('r'); | |
| 611 | 612 | k-m from:body -> ermite.tab('m'); | |
| 613 | + | k-i from:body -> ermite.tab('i'); | |
| 612 | 614 | k-next from:body -> ermite.nextNote(); | |
| 613 | 615 | k-split from:body -> ermite.layout('split'); | |
| 614 | 616 | k-unified from:body -> ermite.layout('unified'); | |
| @@ -426,8 +426,9 @@ And <b>html</b> & "quotes".</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 & tags, merge requests</dd> | 431 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> |
| 431 | </div> | 432 | </div> | ||
| 432 | <div> | 433 | <div> | ||
| 433 | <dt> | 434 | <dt> | ||
| @@ -609,6 +610,7 @@ And <b>html</b> & "quotes".</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 <b>html</b> & "quotes".</pre> | |||
| 426 | 426 | <kbd>c</kbd> | |
| 427 | 427 | <kbd>r</kbd> | |
| 428 | 428 | <kbd>m</kbd> | |
| 429 | + | <kbd>i</kbd> | |
| 429 | 430 | </dt> | |
| 430 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 431 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 431 | 432 | </div> | |
| 432 | 433 | <div> | |
| 433 | 434 | <dt> | |
| @@ -609,6 +610,7 @@ And <b>html</b> & "quotes".</pre> | |||
| 609 | 610 | k-c from:body -> ermite.tab('c'); | |
| 610 | 611 | k-r from:body -> ermite.tab('r'); | |
| 611 | 612 | k-m from:body -> ermite.tab('m'); | |
| 613 | + | k-i from:body -> ermite.tab('i'); | |
| 612 | 614 | k-next from:body -> ermite.nextNote(); | |
| 613 | 615 | k-split from:body -> ermite.layout('split'); | |
| 614 | 616 | k-unified from:body -> ermite.layout('unified'); | |
| @@ -426,8 +426,9 @@ And <b>html</b> & "quotes".</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 & tags, merge requests</dd> | 431 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> |
| 431 | </div> | 432 | </div> | ||
| 432 | <div> | 433 | <div> | ||
| 433 | <dt> | 434 | <dt> | ||
| @@ -609,6 +610,7 @@ And <b>html</b> & "quotes".</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 @@ | |||
| 330 | 330 | <kbd>c</kbd> | |
| 331 | 331 | <kbd>r</kbd> | |
| 332 | 332 | <kbd>m</kbd> | |
| 333 | + | <kbd>i</kbd> | |
| 333 | 334 | </dt> | |
| 334 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 335 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 335 | 336 | </div> | |
| 336 | 337 | <div> | |
| 337 | 338 | <dt> | |
| @@ -513,6 +514,7 @@ | |||
| 513 | 514 | k-c from:body -> ermite.tab('c'); | |
| 514 | 515 | k-r from:body -> ermite.tab('r'); | |
| 515 | 516 | k-m from:body -> ermite.tab('m'); | |
| 517 | + | k-i from:body -> ermite.tab('i'); | |
| 516 | 518 | k-next from:body -> ermite.nextNote(); | |
| 517 | 519 | k-split from:body -> ermite.layout('split'); | |
| 518 | 520 | 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 & tags, merge requests</dd> | 335 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 649 | 649 | <kbd>c</kbd> | |
| 650 | 650 | <kbd>r</kbd> | |
| 651 | 651 | <kbd>m</kbd> | |
| 652 | + | <kbd>i</kbd> | |
| 652 | 653 | </dt> | |
| 653 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 654 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 654 | 655 | </div> | |
| 655 | 656 | <div> | |
| 656 | 657 | <dt> | |
| @@ -832,6 +833,7 @@ | |||
| 832 | 833 | k-c from:body -> ermite.tab('c'); | |
| 833 | 834 | k-r from:body -> ermite.tab('r'); | |
| 834 | 835 | k-m from:body -> ermite.tab('m'); | |
| 836 | + | k-i from:body -> ermite.tab('i'); | |
| 835 | 837 | k-next from:body -> ermite.nextNote(); | |
| 836 | 838 | k-split from:body -> ermite.layout('split'); | |
| 837 | 839 | 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 & tags, merge requests</dd> | 654 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 478 | 478 | <kbd>c</kbd> | |
| 479 | 479 | <kbd>r</kbd> | |
| 480 | 480 | <kbd>m</kbd> | |
| 481 | + | <kbd>i</kbd> | |
| 481 | 482 | </dt> | |
| 482 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 483 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 483 | 484 | </div> | |
| 484 | 485 | <div> | |
| 485 | 486 | <dt> | |
| @@ -661,6 +662,7 @@ | |||
| 661 | 662 | k-c from:body -> ermite.tab('c'); | |
| 662 | 663 | k-r from:body -> ermite.tab('r'); | |
| 663 | 664 | k-m from:body -> ermite.tab('m'); | |
| 665 | + | k-i from:body -> ermite.tab('i'); | |
| 664 | 666 | k-next from:body -> ermite.nextNote(); | |
| 665 | 667 | k-split from:body -> ermite.layout('split'); | |
| 666 | 668 | 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 & tags, merge requests</dd> | 483 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 254 | 254 | <kbd>c</kbd> | |
| 255 | 255 | <kbd>r</kbd> | |
| 256 | 256 | <kbd>m</kbd> | |
| 257 | + | <kbd>i</kbd> | |
| 257 | 258 | </dt> | |
| 258 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 259 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 259 | 260 | </div> | |
| 260 | 261 | <div> | |
| 261 | 262 | <dt> | |
| @@ -423,6 +424,7 @@ | |||
| 423 | 424 | k-c from:body -> ermite.tab('c'); | |
| 424 | 425 | k-r from:body -> ermite.tab('r'); | |
| 425 | 426 | k-m from:body -> ermite.tab('m'); | |
| 427 | + | k-i from:body -> ermite.tab('i'); | |
| 426 | 428 | k-next from:body -> ermite.nextNote(); | |
| 427 | 429 | k-split from:body -> ermite.layout('split'); | |
| 428 | 430 | 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 & tags, merge requests</dd> | 259 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 269 | 269 | <kbd>c</kbd> | |
| 270 | 270 | <kbd>r</kbd> | |
| 271 | 271 | <kbd>m</kbd> | |
| 272 | + | <kbd>i</kbd> | |
| 272 | 273 | </dt> | |
| 273 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 274 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 274 | 275 | </div> | |
| 275 | 276 | <div> | |
| 276 | 277 | <dt> | |
| @@ -438,6 +439,7 @@ | |||
| 438 | 439 | k-c from:body -> ermite.tab('c'); | |
| 439 | 440 | k-r from:body -> ermite.tab('r'); | |
| 440 | 441 | k-m from:body -> ermite.tab('m'); | |
| 442 | + | k-i from:body -> ermite.tab('i'); | |
| 441 | 443 | k-next from:body -> ermite.nextNote(); | |
| 442 | 444 | k-split from:body -> ermite.layout('split'); | |
| 443 | 445 | 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 & tags, merge requests</dd> | 274 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 1058 | 1058 | <kbd>c</kbd> | |
| 1059 | 1059 | <kbd>r</kbd> | |
| 1060 | 1060 | <kbd>m</kbd> | |
| 1061 | + | <kbd>i</kbd> | |
| 1061 | 1062 | </dt> | |
| 1062 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 1063 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 1063 | 1064 | </div> | |
| 1064 | 1065 | <div> | |
| 1065 | 1066 | <dt> | |
| @@ -1227,6 +1228,7 @@ | |||
| 1227 | 1228 | k-c from:body -> ermite.tab('c'); | |
| 1228 | 1229 | k-r from:body -> ermite.tab('r'); | |
| 1229 | 1230 | k-m from:body -> ermite.tab('m'); | |
| 1231 | + | k-i from:body -> ermite.tab('i'); | |
| 1230 | 1232 | k-next from:body -> ermite.nextNote(); | |
| 1231 | 1233 | k-split from:body -> ermite.layout('split'); | |
| 1232 | 1234 | 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 & tags, merge requests</dd> | 1063 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 403 | 403 | <kbd>c</kbd> | |
| 404 | 404 | <kbd>r</kbd> | |
| 405 | 405 | <kbd>m</kbd> | |
| 406 | + | <kbd>i</kbd> | |
| 406 | 407 | </dt> | |
| 407 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 408 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 408 | 409 | </div> | |
| 409 | 410 | <div> | |
| 410 | 411 | <dt> | |
| @@ -592,6 +593,7 @@ | |||
| 592 | 593 | k-c from:body -> ermite.tab('c'); | |
| 593 | 594 | k-r from:body -> ermite.tab('r'); | |
| 594 | 595 | k-m from:body -> ermite.tab('m'); | |
| 596 | + | k-i from:body -> ermite.tab('i'); | |
| 595 | 597 | k-next from:body -> ermite.nextNote(); | |
| 596 | 598 | k-split from:body -> ermite.layout('split'); | |
| 597 | 599 | 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 & tags, merge requests</dd> | 408 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 176 | 176 | <kbd>c</kbd> | |
| 177 | 177 | <kbd>r</kbd> | |
| 178 | 178 | <kbd>m</kbd> | |
| 179 | + | <kbd>i</kbd> | |
| 179 | 180 | </dt> | |
| 180 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 181 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 181 | 182 | </div> | |
| 182 | 183 | <div> | |
| 183 | 184 | <dt> | |
| @@ -276,6 +277,7 @@ | |||
| 276 | 277 | k-c from:body -> ermite.tab('c'); | |
| 277 | 278 | k-r from:body -> ermite.tab('r'); | |
| 278 | 279 | k-m from:body -> ermite.tab('m'); | |
| 280 | + | k-i from:body -> ermite.tab('i'); | |
| 279 | 281 | k-next from:body -> ermite.nextNote(); | |
| 280 | 282 | k-split from:body -> ermite.layout('split'); | |
| 281 | 283 | 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 & tags, merge requests</dd> | 181 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 277 | 277 | <kbd>c</kbd> | |
| 278 | 278 | <kbd>r</kbd> | |
| 279 | 279 | <kbd>m</kbd> | |
| 280 | + | <kbd>i</kbd> | |
| 280 | 281 | </dt> | |
| 281 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 282 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 282 | 283 | </div> | |
| 283 | 284 | <div> | |
| 284 | 285 | <dt> | |
| @@ -383,6 +384,7 @@ | |||
| 383 | 384 | k-c from:body -> ermite.tab('c'); | |
| 384 | 385 | k-r from:body -> ermite.tab('r'); | |
| 385 | 386 | k-m from:body -> ermite.tab('m'); | |
| 387 | + | k-i from:body -> ermite.tab('i'); | |
| 386 | 388 | k-next from:body -> ermite.nextNote(); | |
| 387 | 389 | k-split from:body -> ermite.layout('split'); | |
| 388 | 390 | 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 & tags, merge requests</dd> | 282 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 165 | 165 | <kbd>c</kbd> | |
| 166 | 166 | <kbd>r</kbd> | |
| 167 | 167 | <kbd>m</kbd> | |
| 168 | + | <kbd>i</kbd> | |
| 168 | 169 | </dt> | |
| 169 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 170 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 170 | 171 | </div> | |
| 171 | 172 | <div> | |
| 172 | 173 | <dt> | |
| @@ -259,6 +260,7 @@ | |||
| 259 | 260 | k-c from:body -> ermite.tab('c'); | |
| 260 | 261 | k-r from:body -> ermite.tab('r'); | |
| 261 | 262 | k-m from:body -> ermite.tab('m'); | |
| 263 | + | k-i from:body -> ermite.tab('i'); | |
| 262 | 264 | k-next from:body -> ermite.nextNote(); | |
| 263 | 265 | k-split from:body -> ermite.layout('split'); | |
| 264 | 266 | 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 & tags, merge requests</dd> | 170 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 165 | 165 | <kbd>c</kbd> | |
| 166 | 166 | <kbd>r</kbd> | |
| 167 | 167 | <kbd>m</kbd> | |
| 168 | + | <kbd>i</kbd> | |
| 168 | 169 | </dt> | |
| 169 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 170 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 170 | 171 | </div> | |
| 171 | 172 | <div> | |
| 172 | 173 | <dt> | |
| @@ -259,6 +260,7 @@ | |||
| 259 | 260 | k-c from:body -> ermite.tab('c'); | |
| 260 | 261 | k-r from:body -> ermite.tab('r'); | |
| 261 | 262 | k-m from:body -> ermite.tab('m'); | |
| 263 | + | k-i from:body -> ermite.tab('i'); | |
| 262 | 264 | k-next from:body -> ermite.nextNote(); | |
| 263 | 265 | k-split from:body -> ermite.layout('split'); | |
| 264 | 266 | 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 & tags, merge requests</dd> | 170 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 285 | 285 | <kbd>c</kbd> | |
| 286 | 286 | <kbd>r</kbd> | |
| 287 | 287 | <kbd>m</kbd> | |
| 288 | + | <kbd>i</kbd> | |
| 288 | 289 | </dt> | |
| 289 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 290 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 290 | 291 | </div> | |
| 291 | 292 | <div> | |
| 292 | 293 | <dt> | |
| @@ -484,6 +485,7 @@ | |||
| 484 | 485 | k-c from:body -> ermite.tab('c'); | |
| 485 | 486 | k-r from:body -> ermite.tab('r'); | |
| 486 | 487 | k-m from:body -> ermite.tab('m'); | |
| 488 | + | k-i from:body -> ermite.tab('i'); | |
| 487 | 489 | k-next from:body -> ermite.nextNote(); | |
| 488 | 490 | k-split from:body -> ermite.layout('split'); | |
| 489 | 491 | 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 & tags, merge requests</dd> | 290 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 311 | 311 | <kbd>c</kbd> | |
| 312 | 312 | <kbd>r</kbd> | |
| 313 | 313 | <kbd>m</kbd> | |
| 314 | + | <kbd>i</kbd> | |
| 314 | 315 | </dt> | |
| 315 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 316 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 316 | 317 | </div> | |
| 317 | 318 | <div> | |
| 318 | 319 | <dt> | |
| @@ -510,6 +511,7 @@ | |||
| 510 | 511 | k-c from:body -> ermite.tab('c'); | |
| 511 | 512 | k-r from:body -> ermite.tab('r'); | |
| 512 | 513 | k-m from:body -> ermite.tab('m'); | |
| 514 | + | k-i from:body -> ermite.tab('i'); | |
| 513 | 515 | k-next from:body -> ermite.nextNote(); | |
| 514 | 516 | k-split from:body -> ermite.layout('split'); | |
| 515 | 517 | 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 & tags, merge requests</dd> | 316 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 242 | 242 | <kbd>c</kbd> | |
| 243 | 243 | <kbd>r</kbd> | |
| 244 | 244 | <kbd>m</kbd> | |
| 245 | + | <kbd>i</kbd> | |
| 245 | 246 | </dt> | |
| 246 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 247 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 247 | 248 | </div> | |
| 248 | 249 | <div> | |
| 249 | 250 | <dt> | |
| @@ -425,6 +426,7 @@ | |||
| 425 | 426 | k-c from:body -> ermite.tab('c'); | |
| 426 | 427 | k-r from:body -> ermite.tab('r'); | |
| 427 | 428 | k-m from:body -> ermite.tab('m'); | |
| 429 | + | k-i from:body -> ermite.tab('i'); | |
| 428 | 430 | k-next from:body -> ermite.nextNote(); | |
| 429 | 431 | k-split from:body -> ermite.layout('split'); | |
| 430 | 432 | 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 & tags, merge requests</dd> | 247 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 463 | 463 | <kbd>c</kbd> | |
| 464 | 464 | <kbd>r</kbd> | |
| 465 | 465 | <kbd>m</kbd> | |
| 466 | + | <kbd>i</kbd> | |
| 466 | 467 | </dt> | |
| 467 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 468 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 468 | 469 | </div> | |
| 469 | 470 | <div> | |
| 470 | 471 | <dt> | |
| @@ -646,6 +647,7 @@ | |||
| 646 | 647 | k-c from:body -> ermite.tab('c'); | |
| 647 | 648 | k-r from:body -> ermite.tab('r'); | |
| 648 | 649 | k-m from:body -> ermite.tab('m'); | |
| 650 | + | k-i from:body -> ermite.tab('i'); | |
| 649 | 651 | k-next from:body -> ermite.nextNote(); | |
| 650 | 652 | k-split from:body -> ermite.layout('split'); | |
| 651 | 653 | 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 & tags, merge requests</dd> | 468 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 239 | 239 | <kbd>c</kbd> | |
| 240 | 240 | <kbd>r</kbd> | |
| 241 | 241 | <kbd>m</kbd> | |
| 242 | + | <kbd>i</kbd> | |
| 242 | 243 | </dt> | |
| 243 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 244 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 244 | 245 | </div> | |
| 245 | 246 | <div> | |
| 246 | 247 | <dt> | |
| @@ -422,6 +423,7 @@ | |||
| 422 | 423 | k-c from:body -> ermite.tab('c'); | |
| 423 | 424 | k-r from:body -> ermite.tab('r'); | |
| 424 | 425 | k-m from:body -> ermite.tab('m'); | |
| 426 | + | k-i from:body -> ermite.tab('i'); | |
| 425 | 427 | k-next from:body -> ermite.nextNote(); | |
| 426 | 428 | k-split from:body -> ermite.layout('split'); | |
| 427 | 429 | 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 & tags, merge requests</dd> | 244 | + | <dd>tabs: summary, files, commits, branches & 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 @@ | |||
| 255 | 255 | <kbd>c</kbd> | |
| 256 | 256 | <kbd>r</kbd> | |
| 257 | 257 | <kbd>m</kbd> | |
| 258 | + | <kbd>i</kbd> | |
| 258 | 259 | </dt> | |
| 259 | - | <dd>tabs: summary, files, commits, branches & tags, merge requests</dd> | |
| 260 | + | <dd>tabs: summary, files, commits, branches & tags, merge requests, issues</dd> | |
| 260 | 261 | </div> | |
| 261 | 262 | <div> | |
| 262 | 263 | <dt> | |
| @@ -438,6 +439,7 @@ | |||
| 438 | 439 | k-c from:body -> ermite.tab('c'); | |
| 439 | 440 | k-r from:body -> ermite.tab('r'); | |
| 440 | 441 | k-m from:body -> ermite.tab('m'); | |
| 442 | + | k-i from:body -> ermite.tab('i'); | |
| 441 | 443 | k-next from:body -> ermite.nextNote(); | |
| 442 | 444 | k-split from:body -> ermite.layout('split'); | |
| 443 | 445 | 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 & tags, merge requests</dd> | 260 | + | <dd>tabs: summary, files, commits, branches & 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 | |||
| 45 | 45 | Repos: f.Repos, | |
| 46 | 46 | Browse: f.Browse, | |
| 47 | 47 | MergeRequests: f.MergeRequests, | |
| 48 | + | Issues: f.Issues, | |
| 49 | + | IssueNumbers: f.Issues, | |
| 48 | 50 | Runs: f.CI, | |
| 49 | 51 | Artifacts: artifacts, | |
| 50 | 52 | 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) } | |||