detached 0cea275

feat(web): live MR checks, draft merge requests, sticky review rail (#47)

commit
0cea2758ac9d47b10e83c4d30c134423025e7bed
author
brice · · 2026-10-17 17:44 UTC
committer
bnema · · 2026-10-17 17:44 UTC
parent
ab8e178
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
refs
tag: v0.18.0
59 files · +1177 -456
M internal/adapters/kvstore/mergerequests.go +7 -0
@@ -344,6 +344,13 @@ func (m *MergeRequests) ListOpen(ctx context.Context, repo domain.RepoName) ([]d
344344 return out, nil
345345 }
346346
347+// CountOpen counts the open merge requests of the repository from the keys of
348+// the index: no request is decoded.
349+func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {
350+ numbers, _, err := m.scanIndex(ctx, []string{mrOpenRepoPrefix(repo)}, 0)
351+ return len(numbers), err
352+}
353+
347354 // Update replaces the merge request with a version precondition.
348355 //
349356 //hexcheck:ignore suspicious-business-logic-in-adapter port method name only
@@ -344,6 +344,13 @@ func (m *MergeRequests) ListOpen(ctx context.Context, repo domain.RepoName) ([]d
344 return out, nil344 return out, nil
345 }345 }
346 346
347+// CountOpen counts the open merge requests of the repository from the keys of
348+// the index: no request is decoded.
349+func (m *MergeRequests) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {
350+ numbers, _, err := m.scanIndex(ctx, []string{mrOpenRepoPrefix(repo)}, 0)
351+ return len(numbers), err
352+}
353+
347 // Update replaces the merge request with a version precondition.354 // Update replaces the merge request with a version precondition.
348 //355 //
349 //hexcheck:ignore suspicious-business-logic-in-adapter port method name only356 //hexcheck:ignore suspicious-business-logic-in-adapter port method name only
M internal/adapters/web/actions_pages.go +2 -2
@@ -237,7 +237,7 @@ func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
237237 Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),
238238 Workflow: visibleText(run.WorkflowName), Event: visibleText(run.Event), Title: visibleText(run.Title),
239239 Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),
240- Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor),
240+ Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: s.who(run.Actor),
241241 Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
242242 }
243243 row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
@@ -248,7 +248,7 @@ func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
248248 if ok {
249249 // A pull_request run is for refs/pull/<n>/merge, a pull_request_target
250250 // run for the default branch: both show the merge request.
251- row.Ref, row.RefHref = "!"+strconv.FormatInt(n, 10), mrHref(repo, n)
251+ row.Ref, row.RefHref = "#"+strconv.FormatInt(n, 10), mrHref(repo, n)
252252 }
253253 row.When, row.WhenAt = whenOf(now, run.CreatedAt)
254254 return row
@@ -237,7 +237,7 @@ func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
237 Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),237 Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),
238 Workflow: visibleText(run.WorkflowName), Event: visibleText(run.Event), Title: visibleText(run.Title),238 Workflow: visibleText(run.WorkflowName), Event: visibleText(run.Event), Title: visibleText(run.Title),
239 Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),239 Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),
240- Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor),240+ Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: s.who(run.Actor),
241 Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,241 Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
242 }242 }
243 row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))243 row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
@@ -248,7 +248,7 @@ func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
248 if ok {248 if ok {
249 // A pull_request run is for refs/pull/<n>/merge, a pull_request_target249 // A pull_request run is for refs/pull/<n>/merge, a pull_request_target
250 // run for the default branch: both show the merge request.250 // run for the default branch: both show the merge request.
251- row.Ref, row.RefHref = "!"+strconv.FormatInt(n, 10), mrHref(repo, n)251+ row.Ref, row.RefHref = "#"+strconv.FormatInt(n, 10), mrHref(repo, n)
252 }252 }
253 row.When, row.WhenAt = whenOf(now, run.CreatedAt)253 row.When, row.WhenAt = whenOf(now, run.CreatedAt)
254 return row254 return row
M internal/adapters/web/compare.go +1 -1
@@ -111,7 +111,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
111111 }
112112 switch {
113113 case existing != nil:
114- p.Existing = &Link{Label: "!" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)}
114+ p.Existing = &Link{Label: "#" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)}
115115 case SignedIn(ctx) && !same && len(ahead) > 0:
116116 title := ""
117117 if len(ahead) == 1 {
@@ -111,7 +111,7 @@ func (s *Server) compare(ctx context.Context, r *http.Request) (templ.Component,
111 }111 }
112 switch {112 switch {
113 case existing != nil:113 case existing != nil:
114- p.Existing = &Link{Label: "!" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)}114+ p.Existing = &Link{Label: "#" + itoa(int(existing.Number)) + " " + visibleText(existing.Title.String()), Href: mrHref(name, existing.Number)}
115 case SignedIn(ctx) && !same && len(ahead) > 0:115 case SignedIn(ctx) && !same && len(ahead) > 0:
116 title := ""116 title := ""
117 if len(ahead) == 1 {117 if len(ahead) == 1 {
M internal/adapters/web/format.go +3 -3
@@ -10,8 +10,8 @@ import (
1010
1111 func itoa(n int) string { return strconv.Itoa(n) }
1212
13-// ago renders a short relative date: now, 5m ago, 3h ago, 12d ago, 5mo ago,
14-// 2y ago.
13+// ago renders a short relative date: now, 5 min ago, 3h ago, 12d ago, 5mo
14+// ago, 2y ago. Minutes are "min", so that they never read as months.
1515 func ago(now, t time.Time) string {
1616 if t.IsZero() {
1717 return "never"
@@ -21,7 +21,7 @@ func ago(now, t time.Time) string {
2121 case d < time.Minute:
2222 return "now"
2323 case d < time.Hour:
24- return fmt.Sprintf("%dm ago", int(d.Minutes()))
24+ return fmt.Sprintf("%d min ago", int(d.Minutes()))
2525 case d < 48*time.Hour:
2626 return fmt.Sprintf("%dh ago", int(d.Hours()))
2727 case d < 60*24*time.Hour:
@@ -10,8 +10,8 @@ import (
10 10
11 func itoa(n int) string { return strconv.Itoa(n) }11 func itoa(n int) string { return strconv.Itoa(n) }
12 12
13-// ago renders a short relative date: now, 5m ago, 3h ago, 12d ago, 5mo ago,13+// ago renders a short relative date: now, 5 min ago, 3h ago, 12d ago, 5mo
14-// 2y ago.14+// ago, 2y ago. Minutes are "min", so that they never read as months.
15 func ago(now, t time.Time) string {15 func ago(now, t time.Time) string {
16 if t.IsZero() {16 if t.IsZero() {
17 return "never"17 return "never"
@@ -21,7 +21,7 @@ func ago(now, t time.Time) string {
21 case d < time.Minute:21 case d < time.Minute:
22 return "now"22 return "now"
23 case d < time.Hour:23 case d < time.Hour:
24- return fmt.Sprintf("%dm ago", int(d.Minutes()))24+ return fmt.Sprintf("%d min ago", int(d.Minutes()))
25 case d < 48*time.Hour:25 case d < 48*time.Hour:
26 return fmt.Sprintf("%dh ago", int(d.Hours()))26 return fmt.Sprintf("%dh ago", int(d.Hours()))
27 case d < 60*24*time.Hour:27 case d < 60*24*time.Hour:
M internal/adapters/web/fragments.go +75 -1
@@ -29,8 +29,15 @@ type liveStream struct {
2929 // sha is the commit of the run of the job shown, read once when the stream
3030 // is set up: the annotations link to files at it, and it never changes.
3131 sha string
32+ // headRuns says, on a merge request page, whether a run is one of the
33+ // head shown (its checks are on that head); a job event does not carry
34+ // its commit, so its run is read once. One stream reads it, no lock.
35+ headRuns map[int64]bool
3236 }
3337
38+// maxHeadRuns bounds headRuns: past it the map starts again.
39+const maxHeadRuns = 256
40+
3441 // renderFragment renders a component to the data of an event.
3542 func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) {
3643 var b bytes.Buffer
@@ -51,7 +58,7 @@ func liveEvent(we ports.WatchEvent) bool {
5158 }
5259 switch we.Event.(type) {
5360 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
54- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded,
61+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
5562 domain.RunUpdated, domain.JobUpdated:
5663 return true
5764 }
@@ -148,6 +155,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
148155 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
149156 case domain.MergeRequestClosed:
150157 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
158+ case domain.MergeRequestReady:
159+ return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
151160 case domain.CommentAdded:
152161 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
153162 case domain.PushEvent:
@@ -431,10 +440,66 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
431440 return nil, nil
432441 }
433442 return l.runBody(ctx)
443+ case "mr":
444+ ofHead := ev.Run.CheckSha() == l.head
445+ l.rememberRun(ev.Run.ID, ofHead)
446+ if !ofHead {
447+ return nil, nil
448+ }
449+ return l.mrChecks(ctx)
434450 }
435451 return nil, nil
436452 }
437453
454+// rememberRun records whether a run is one of the head of the page.
455+func (l *liveStream) rememberRun(id int64, ofHead bool) {
456+ if l.headRuns == nil || len(l.headRuns) >= maxHeadRuns {
457+ l.headRuns = make(map[int64]bool)
458+ }
459+ l.headRuns[id] = ofHead
460+}
461+
462+// jobOfHead tells whether the run of a job is one of the head of the page,
463+// reading the run the first time.
464+func (l *liveStream) jobOfHead(ctx context.Context, job domain.CIJob) (bool, error) {
465+ if ofHead, known := l.headRuns[job.RunID]; known {
466+ return ofHead, nil
467+ }
468+ run, err := l.s.cfg.Runs.GetRun(ctx, principal(ctx), job.Repo, job.RunID, 0)
469+ switch {
470+ case errors.Is(err, domain.ErrNotFound):
471+ return false, nil
472+ case err != nil:
473+ return false, err
474+ }
475+ ofHead := run.Run.CheckSha() == l.head
476+ l.rememberRun(job.RunID, ofHead)
477+ return ofHead, nil
478+}
479+
480+// mrChecks is the checks block of the merge request page, read again for the
481+// head the page shows: a run or a job of that head changed. A job event does
482+// not name its commit; reading the checks of the head is one store read, and
483+// a job of another commit leaves them as they were.
484+func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) {
485+ if !isFullID(l.head) {
486+ return nil, nil
487+ }
488+ name, err := domain.ParseRepoName(l.repo)
489+ if err != nil {
490+ return nil, nil
491+ }
492+ repo, err := l.s.repoRecord(ctx, name)
493+ switch {
494+ case errors.Is(err, domain.ErrNotFound):
495+ return gap, nil
496+ case err != nil:
497+ return nil, err
498+ }
499+ f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, l.s.checksFor(ctx, name, l.head))))
500+ return []fragment{f}, err
501+}
502+
438503 // recentActions is the "Recent actions" block of the summary rail, read again
439504 // as the principal of the stream: the newest runs change with every event, and
440505 // a viewer who cannot read CI any more gets the block hidden and empty.
@@ -470,6 +535,15 @@ func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]frag
470535 return nil, nil
471536 }
472537 return l.jobState(ctx)
538+ case "mr":
539+ if !isFullID(l.head) {
540+ return nil, nil
541+ }
542+ ofHead, err := l.jobOfHead(ctx, ev.Job)
543+ if err != nil || !ofHead {
544+ return nil, err
545+ }
546+ return l.mrChecks(ctx)
473547 }
474548 return nil, nil
475549 }
@@ -29,8 +29,15 @@ type liveStream struct {
29 // sha is the commit of the run of the job shown, read once when the stream29 // sha is the commit of the run of the job shown, read once when the stream
30 // is set up: the annotations link to files at it, and it never changes.30 // is set up: the annotations link to files at it, and it never changes.
31 sha string31 sha string
32+ // headRuns says, on a merge request page, whether a run is one of the
33+ // head shown (its checks are on that head); a job event does not carry
34+ // its commit, so its run is read once. One stream reads it, no lock.
35+ headRuns map[int64]bool
32 }36 }
33 37
38+// maxHeadRuns bounds headRuns: past it the map starts again.
39+const maxHeadRuns = 256
40+
34 // renderFragment renders a component to the data of an event.41 // renderFragment renders a component to the data of an event.
35 func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) {42 func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) {
36 var b bytes.Buffer43 var b bytes.Buffer
@@ -51,7 +58,7 @@ func liveEvent(we ports.WatchEvent) bool {
51 }58 }
52 switch we.Event.(type) {59 switch we.Event.(type) {
53 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,60 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
54- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded,61+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.MergeRequestReady, domain.CommentAdded,
55 domain.RunUpdated, domain.JobUpdated:62 domain.RunUpdated, domain.JobUpdated:
56 return true63 return true
57 }64 }
@@ -148,6 +155,8 @@ func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragm
148 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")155 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
149 case domain.MergeRequestClosed:156 case domain.MergeRequestClosed:
150 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")157 return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
158+ case domain.MergeRequestReady:
159+ return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request is ready for review — reload")
151 case domain.CommentAdded:160 case domain.CommentAdded:
152 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")161 return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
153 case domain.PushEvent:162 case domain.PushEvent:
@@ -431,10 +440,66 @@ func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]frag
431 return nil, nil440 return nil, nil
432 }441 }
433 return l.runBody(ctx)442 return l.runBody(ctx)
443+ case "mr":
444+ ofHead := ev.Run.CheckSha() == l.head
445+ l.rememberRun(ev.Run.ID, ofHead)
446+ if !ofHead {
447+ return nil, nil
448+ }
449+ return l.mrChecks(ctx)
434 }450 }
435 return nil, nil451 return nil, nil
436 }452 }
437 453
454+// rememberRun records whether a run is one of the head of the page.
455+func (l *liveStream) rememberRun(id int64, ofHead bool) {
456+ if l.headRuns == nil || len(l.headRuns) >= maxHeadRuns {
457+ l.headRuns = make(map[int64]bool)
458+ }
459+ l.headRuns[id] = ofHead
460+}
461+
462+// jobOfHead tells whether the run of a job is one of the head of the page,
463+// reading the run the first time.
464+func (l *liveStream) jobOfHead(ctx context.Context, job domain.CIJob) (bool, error) {
465+ if ofHead, known := l.headRuns[job.RunID]; known {
466+ return ofHead, nil
467+ }
468+ run, err := l.s.cfg.Runs.GetRun(ctx, principal(ctx), job.Repo, job.RunID, 0)
469+ switch {
470+ case errors.Is(err, domain.ErrNotFound):
471+ return false, nil
472+ case err != nil:
473+ return false, err
474+ }
475+ ofHead := run.Run.CheckSha() == l.head
476+ l.rememberRun(job.RunID, ofHead)
477+ return ofHead, nil
478+}
479+
480+// mrChecks is the checks block of the merge request page, read again for the
481+// head the page shows: a run or a job of that head changed. A job event does
482+// not name its commit; reading the checks of the head is one store read, and
483+// a job of another commit leaves them as they were.
484+func (l *liveStream) mrChecks(ctx context.Context) ([]fragment, error) {
485+ if !isFullID(l.head) {
486+ return nil, nil
487+ }
488+ name, err := domain.ParseRepoName(l.repo)
489+ if err != nil {
490+ return nil, nil
491+ }
492+ repo, err := l.s.repoRecord(ctx, name)
493+ switch {
494+ case errors.Is(err, domain.ErrNotFound):
495+ return gap, nil
496+ case err != nil:
497+ return nil, err
498+ }
499+ f, err := renderFragment(ctx, "ci-run", MRChecks(checksSection(repo, l.s.checksFor(ctx, name, l.head))))
500+ return []fragment{f}, err
501+}
502+
438 // recentActions is the "Recent actions" block of the summary rail, read again503 // recentActions is the "Recent actions" block of the summary rail, read again
439 // as the principal of the stream: the newest runs change with every event, and504 // as the principal of the stream: the newest runs change with every event, and
440 // a viewer who cannot read CI any more gets the block hidden and empty.505 // a viewer who cannot read CI any more gets the block hidden and empty.
@@ -470,6 +535,15 @@ func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]frag
470 return nil, nil535 return nil, nil
471 }536 }
472 return l.jobState(ctx)537 return l.jobState(ctx)
538+ case "mr":
539+ if !isFullID(l.head) {
540+ return nil, nil
541+ }
542+ ofHead, err := l.jobOfHead(ctx, ev.Job)
543+ if err != nil || !ofHead {
544+ return nil, err
545+ }
546+ return l.mrChecks(ctx)
473 }547 }
474 return nil, nil548 return nil, nil
475 }549 }
M internal/adapters/web/mr.go +23 -8
@@ -138,6 +138,9 @@ type MRCommit struct {
138138 type MergeBox struct {
139139 MergeAction string
140140 CloseAction string
141+ // ReadyAction takes a draft out of draft; empty when it is not one: a
142+ // draft shows it in place of the merge form.
143+ ReadyAction string
141144 ReadAction string
142145 Head string
143146 Modes []ModeOption
@@ -262,6 +265,15 @@ func mrNumber(r *http.Request) (int64, error) {
262265 return n, nil
263266 }
264267
268+// mrStateName is the state a page shows: "draft" for an open request whose
269+// title marks it so.
270+func mrStateName(m domain.MergeRequest) string {
271+ if m.IsOpen() && m.IsDraft() {
272+ return "draft"
273+ }
274+ return stateName(m.State)
275+}
276+
265277 func stateName(s domain.MRState) string {
266278 switch s {
267279 case domain.MRStateOpen:
@@ -287,10 +299,10 @@ func modeName(m domain.MergeMode) string {
287299 // mrRow converts a merge request of a listing.
288300 func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
289301 row := MRRow{
290- Number: "!" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number),
291- Title: visibleText(m.Title.String()), State: stateName(m.State),
302+ Number: "#" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number),
303+ Title: visibleText(m.Title.String()), State: mrStateName(m),
292304 Branches: visibleText(m.Source) + " → " + visibleText(m.Target),
293- Author: visibleText(m.Author),
305+ Author: s.who(m.Author),
294306 }
295307 ts := m.UpdatedAt
296308 if ts.IsZero() {
@@ -422,10 +434,10 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
422434 }
423435
424436 p := MRPage{
425- Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
426- State: stateName(m.State), Open: open, Signed: signed,
437+ Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
438+ State: mrStateName(m), Open: open, Signed: signed,
427439 Source: visibleText(m.Source), Target: visibleText(m.Target),
428- Author: visibleText(m.Author),
440+ Author: s.who(m.Author),
429441 Desc: plainParts(m.Description.String()),
430442 Head: head, HeadShort: short(head),
431443 NFiles: res.Stats.FilesChanged,
@@ -529,6 +541,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
529541 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
530542 if signed && open {
531543 p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
544+ if m.IsDraft() {
545+ p.Box.ReadyAction = mrHref(d.name(), n, "ready")
546+ }
532547 }
533548 if canWrite {
534549 p.GeneralForm = &CommentForm{
@@ -553,7 +568,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
553568 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
554569 }
555570 p.Live = true
556- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source))
571+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head))
557572 l.HasRail = true
558573 return MergeRequest(l, p), http.StatusOK, nil
559574 }
@@ -683,7 +698,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
683698 for _, c := range comments {
684699 cid := strconv.FormatInt(c.ID, 10)
685700 v := CommentView{
686- ID: cid, Author: visibleText(c.Author),
701+ ID: cid, Author: s.who(c.Author),
687702 Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
688703 Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
689704 }
@@ -138,6 +138,9 @@ type MRCommit struct {
138 type MergeBox struct {138 type MergeBox struct {
139 MergeAction string139 MergeAction string
140 CloseAction string140 CloseAction string
141+ // ReadyAction takes a draft out of draft; empty when it is not one: a
142+ // draft shows it in place of the merge form.
143+ ReadyAction string
141 ReadAction string144 ReadAction string
142 Head string145 Head string
143 Modes []ModeOption146 Modes []ModeOption
@@ -262,6 +265,15 @@ func mrNumber(r *http.Request) (int64, error) {
262 return n, nil265 return n, nil
263 }266 }
264 267
268+// mrStateName is the state a page shows: "draft" for an open request whose
269+// title marks it so.
270+func mrStateName(m domain.MergeRequest) string {
271+ if m.IsOpen() && m.IsDraft() {
272+ return "draft"
273+ }
274+ return stateName(m.State)
275+}
276+
265 func stateName(s domain.MRState) string {277 func stateName(s domain.MRState) string {
266 switch s {278 switch s {
267 case domain.MRStateOpen:279 case domain.MRStateOpen:
@@ -287,10 +299,10 @@ func modeName(m domain.MergeMode) string {
287 // mrRow converts a merge request of a listing.299 // mrRow converts a merge request of a listing.
288 func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {300 func (s *Server) mrRow(repo string, m domain.MergeRequest) MRRow {
289 row := MRRow{301 row := MRRow{
290- Number: "!" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number),302+ Number: "#" + strconv.FormatInt(m.Number, 10), Href: mrHref(repo, m.Number),
291- Title: visibleText(m.Title.String()), State: stateName(m.State),303+ Title: visibleText(m.Title.String()), State: mrStateName(m),
292 Branches: visibleText(m.Source) + " → " + visibleText(m.Target),304 Branches: visibleText(m.Source) + " → " + visibleText(m.Target),
293- Author: visibleText(m.Author),305+ Author: s.who(m.Author),
294 }306 }
295 ts := m.UpdatedAt307 ts := m.UpdatedAt
296 if ts.IsZero() {308 if ts.IsZero() {
@@ -422,10 +434,10 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
422 }434 }
423 435
424 p := MRPage{436 p := MRPage{
425- Number: "!" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),437+ Number: "#" + strconv.FormatInt(n, 10), Title: visibleText(m.Title.String()),
426- State: stateName(m.State), Open: open, Signed: signed,438+ State: mrStateName(m), Open: open, Signed: signed,
427 Source: visibleText(m.Source), Target: visibleText(m.Target),439 Source: visibleText(m.Source), Target: visibleText(m.Target),
428- Author: visibleText(m.Author),440+ Author: s.who(m.Author),
429 Desc: plainParts(m.Description.String()),441 Desc: plainParts(m.Description.String()),
430 Head: head, HeadShort: short(head),442 Head: head, HeadShort: short(head),
431 NFiles: res.Stats.FilesChanged,443 NFiles: res.Stats.FilesChanged,
@@ -529,6 +541,9 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
529 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")541 p.DiffView = newDiffView(r, lines, self+"?view=unified", self+"?view=split")
530 if signed && open {542 if signed && open {
531 p.Box = s.mergeBox(d, n, m.Source, head, lastRead)543 p.Box = s.mergeBox(d, n, m.Source, head, lastRead)
544+ if m.IsDraft() {
545+ p.Box.ReadyAction = mrHref(d.name(), n, "ready")
546+ }
532 }547 }
533 if canWrite {548 if canWrite {
534 p.GeneralForm = &CommentForm{549 p.GeneralForm = &CommentForm{
@@ -553,7 +568,7 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
553 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})568 l.Crumbs = append(l.Crumbs, Crumb{Label: "since", Current: true})
554 }569 }
555 p.Live = true570 p.Live = true
556- l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source))571+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=mr&n="+strconv.FormatInt(n, 10)+"&ref="+url.QueryEscape(m.Source)+"&head="+url.QueryEscape(head))
557 l.HasRail = true572 l.HasRail = true
558 return MergeRequest(l, p), http.StatusOK, nil573 return MergeRequest(l, p), http.StatusOK, nil
559 }574 }
@@ -683,7 +698,7 @@ func (s *Server) placeComments(p *MRPage, repo string, n int64, comments []domai
683 for _, c := range comments {698 for _, c := range comments {
684 cid := strconv.FormatInt(c.ID, 10)699 cid := strconv.FormatInt(c.ID, 10)
685 v := CommentView{700 v := CommentView{
686- ID: cid, Author: visibleText(c.Author),701+ ID: cid, Author: s.who(c.Author),
687 Parts: plainParts(c.Text.String()), Resolved: c.Resolved,702 Parts: plainParts(c.Text.String()), Resolved: c.Resolved,
688 Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,703 Outdated: !c.CommitID.IsZero() && c.CommitID.String() != head,
689 }704 }
M internal/adapters/web/mr.templ +11 -4
@@ -368,7 +368,12 @@ templ mergeBox(p MRPage) {
368368 <dt>into</dt>
369369 <dd>{ p.Target }</dd>
370370 </dl>
371- if !p.Box.HeadGone {
371+ if p.Box.ReadyAction != "" {
372+ <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
373+ <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
374+ <button type="submit" class="btn primary" title="remove the draft prefix from the title">Ready for review</button>
375+ </form>
376+ } else if !p.Box.HeadGone {
372377 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
373378 <input type="hidden" name="head_id" value={ p.Box.Head }/>
374379 <label class="sr" for="merge-mode">merge mode</label>
@@ -396,9 +401,11 @@ templ mergeBox(p MRPage) {
396401 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
397402 </form>
398403 </div>
399- if !p.Box.HeadGone {
400- <p class="mute d-or">or from a terminal</p>
401- @CloneBlock(p.Box.CLI)
404+ if !p.Box.HeadGone && p.Box.ReadyAction == "" {
405+ <details class="d-or">
406+ <summary class="mute">or from a terminal</summary>
407+ @CloneBlock(p.Box.CLI)
408+ </details>
402409 }
403410 </section>
404411 }
@@ -368,7 +368,12 @@ templ mergeBox(p MRPage) {
368 <dt>into</dt>368 <dt>into</dt>
369 <dd>{ p.Target }</dd>369 <dd>{ p.Target }</dd>
370 </dl>370 </dl>
371- if !p.Box.HeadGone {371+ if p.Box.ReadyAction != "" {
372+ <p class="d-draft">this is a draft: it cannot be merged until it is ready for review.</p>
373+ <form class="d-btns" method="post" action={ templ.SafeURL(p.Box.ReadyAction) }>
374+ <button type="submit" class="btn primary" title="remove the draft prefix from the title">Ready for review</button>
375+ </form>
376+ } else if !p.Box.HeadGone {
372 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>377 <form class="d-acts" method="post" action={ templ.SafeURL(p.Box.MergeAction) }>
373 <input type="hidden" name="head_id" value={ p.Box.Head }/>378 <input type="hidden" name="head_id" value={ p.Box.Head }/>
374 <label class="sr" for="merge-mode">merge mode</label>379 <label class="sr" for="merge-mode">merge mode</label>
@@ -396,9 +401,11 @@ templ mergeBox(p MRPage) {
396 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>401 <button type="submit" title="close without merging; the branch is kept" class="btn danger">Close</button>
397 </form>402 </form>
398 </div>403 </div>
399- if !p.Box.HeadGone {404+ if !p.Box.HeadGone && p.Box.ReadyAction == "" {
400- <p class="mute d-or">or from a terminal</p>405+ <details class="d-or">
401- @CloneBlock(p.Box.CLI)406+ <summary class="mute">or from a terminal</summary>
407+ @CloneBlock(p.Box.CLI)
408+ </details>
402 }409 }
403 </section>410 </section>
404 }411 }
M internal/adapters/web/mr_post.go +19 -0
@@ -260,6 +260,25 @@ func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string,
260260 return back, back, nil
261261 }
262262
263+// mrReady is POST /<repo>/merge-requests/<n>/ready: the draft prefix leaves
264+// the title.
265+func (s *Server) mrReady(ctx context.Context, r *http.Request) (string, string, error) {
266+ repo := r.PathValue("repo")
267+ n, err := number(r)
268+ if err != nil {
269+ return "", "", err
270+ }
271+ back := mrHref(repo, n)
272+ name, err := domain.ParseRepoName(repo)
273+ if err != nil {
274+ return "", back, err
275+ }
276+ if _, err := s.cfg.MergeRequests.MarkReady(ctx, principal(ctx), name, n); err != nil {
277+ return "", back, err
278+ }
279+ return back, back, nil
280+}
281+
263282 // mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,
264283 // the head the owner was shown (head_id): the server merges only if the
265284 // branch still points to it, and delete_branch when the owner asked to delete
@@ -260,6 +260,25 @@ func (s *Server) mrClose(ctx context.Context, r *http.Request) (string, string,
260 return back, back, nil260 return back, back, nil
261 }261 }
262 262
263+// mrReady is POST /<repo>/merge-requests/<n>/ready: the draft prefix leaves
264+// the title.
265+func (s *Server) mrReady(ctx context.Context, r *http.Request) (string, string, error) {
266+ repo := r.PathValue("repo")
267+ n, err := number(r)
268+ if err != nil {
269+ return "", "", err
270+ }
271+ back := mrHref(repo, n)
272+ name, err := domain.ParseRepoName(repo)
273+ if err != nil {
274+ return "", back, err
275+ }
276+ if _, err := s.cfg.MergeRequests.MarkReady(ctx, principal(ctx), name, n); err != nil {
277+ return "", back, err
278+ }
279+ return back, back, nil
280+}
281+
263 // mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,282 // mrMerge is POST /<repo>/merge-requests/<n>/merge. The form carries the mode,
264 // the head the owner was shown (head_id): the server merges only if the283 // the head the owner was shown (head_id): the server merges only if the
265 // branch still points to it, and delete_branch when the owner asked to delete284 // branch still points to it, and delete_branch when the owner asked to delete
M internal/adapters/web/mr_templ.go +251 -229 generated file: collapsed
M internal/adapters/web/repo.go +27 -7
@@ -35,18 +35,26 @@ type repoData struct {
3535 // the Git state of its summary. A missing or private repository is
3636 // domain.ErrNotFound.
3737 func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) {
38- repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
38+ repo, err := s.repoRecord(ctx, name)
3939 if err != nil {
4040 return ports.RepoView{}, err
4141 }
42- view, err := s.cfg.Repos.View(ctx, repo)
42+ return s.cfg.Repos.View(ctx, repo)
43+}
44+
45+// repoRecord reads the repository record only, without Git: its name,
46+// visibility and settings (the defaults when it has none).
47+//
48+//hexcheck:ignore suspicious-business-logic-in-adapter reads the repository from the use case; filling in the default settings for display decides no rule
49+func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
50+ repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
4351 if err != nil {
44- return ports.RepoView{}, err
52+ return domain.Repo{}, err
4553 }
46- if view.Settings.IsZero() {
47- view.Settings = domain.DefaultRepoSettings()
54+ if repo.Settings.IsZero() {
55+ repo.Settings = domain.DefaultRepoSettings()
4856 }
49- return view, nil
57+ return repo, nil
5058 }
5159
5260 // loadRepo reads a repository and its refs. A missing, private or invalidly
@@ -110,6 +118,16 @@ func (d *repoData) allRefs() []domain.Ref {
110118 // name is the repository name.
111119 func (d *repoData) name() string { return d.repo.Name.String() }
112120
121+// openMRCount is the number of open merge requests shown on the tab, empty
122+// when there are none or they cannot be counted (the tab still works).
123+func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
124+ n, err := s.cfg.MergeRequests.CountOpen(ctx, principal(ctx), name)
125+ if err != nil || n == 0 {
126+ return ""
127+ }
128+ return itoa(n)
129+}
130+
113131 // layout is the Layout of the pages of a repository: the sections, the ref
114132 // switcher on the head ref and the ":" targets. The caller sets the title,
115133 // the crumbs, the status and the rail.
@@ -133,7 +151,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
133151 mk("r", "Branches & tags", repoHref(d.name(), "refs"), "refs"),
134152 }
135153 if s.cfg.MergeRequests != nil {
136- l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests"))
154+ mrs := mk("m", "Merge requests", mrBase(d.name()), "merge-requests")
155+ mrs.Count = s.openMRCount(ctx, d.repo.Name)
156+ l.Sections = append(l.Sections, mrs)
137157 }
138158 l.Sections = s.withActions(l.Sections, d.name(), section)
139159 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
@@ -35,18 +35,26 @@ type repoData struct {
35 // the Git state of its summary. A missing or private repository is35 // the Git state of its summary. A missing or private repository is
36 // domain.ErrNotFound.36 // domain.ErrNotFound.
37 func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) {37 func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.RepoView, error) {
38- repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)38+ repo, err := s.repoRecord(ctx, name)
39 if err != nil {39 if err != nil {
40 return ports.RepoView{}, err40 return ports.RepoView{}, err
41 }41 }
42- view, err := s.cfg.Repos.View(ctx, repo)42+ return s.cfg.Repos.View(ctx, repo)
43+}
44+
45+// repoRecord reads the repository record only, without Git: its name,
46+// visibility and settings (the defaults when it has none).
47+//
48+//hexcheck:ignore suspicious-business-logic-in-adapter reads the repository from the use case; filling in the default settings for display decides no rule
49+func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
50+ repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
43 if err != nil {51 if err != nil {
44- return ports.RepoView{}, err52+ return domain.Repo{}, err
45 }53 }
46- if view.Settings.IsZero() {54+ if repo.Settings.IsZero() {
47- view.Settings = domain.DefaultRepoSettings()55+ repo.Settings = domain.DefaultRepoSettings()
48 }56 }
49- return view, nil57+ return repo, nil
50 }58 }
51 59
52 // loadRepo reads a repository and its refs. A missing, private or invalidly60 // loadRepo reads a repository and its refs. A missing, private or invalidly
@@ -110,6 +118,16 @@ func (d *repoData) allRefs() []domain.Ref {
110 // name is the repository name.118 // name is the repository name.
111 func (d *repoData) name() string { return d.repo.Name.String() }119 func (d *repoData) name() string { return d.repo.Name.String() }
112 120
121+// openMRCount is the number of open merge requests shown on the tab, empty
122+// when there are none or they cannot be counted (the tab still works).
123+func (s *Server) openMRCount(ctx context.Context, name domain.RepoName) string {
124+ n, err := s.cfg.MergeRequests.CountOpen(ctx, principal(ctx), name)
125+ if err != nil || n == 0 {
126+ return ""
127+ }
128+ return itoa(n)
129+}
130+
113 // layout is the Layout of the pages of a repository: the sections, the ref131 // layout is the Layout of the pages of a repository: the sections, the ref
114 // 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,
115 // the crumbs, the status and the rail.133 // the crumbs, the status and the rail.
@@ -133,7 +151,9 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
133 mk("r", "Branches & tags", repoHref(d.name(), "refs"), "refs"),151 mk("r", "Branches & tags", repoHref(d.name(), "refs"), "refs"),
134 }152 }
135 if s.cfg.MergeRequests != nil {153 if s.cfg.MergeRequests != nil {
136- l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests"))154+ mrs := mk("m", "Merge requests", mrBase(d.name()), "merge-requests")
155+ mrs.Count = s.openMRCount(ctx, d.repo.Name)
156+ l.Sections = append(l.Sections, mrs)
137 }157 }
138 l.Sections = s.withActions(l.Sections, d.name(), section)158 l.Sections = s.withActions(l.Sections, d.name(), section)
139 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}159 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
M internal/adapters/web/shell.templ +7 -1
@@ -150,7 +150,13 @@ templ Header(l Layout) {
150150 if s.Current {
151151 aria-current="page"
152152 }
153- >{ s.Label }<kbd aria-hidden="true">{ s.Key }</kbd></a>
153+ >
154+ { s.Label }
155+ if s.Count != "" {
156+ <span class="count">{ s.Count }<span class="sr">{ " open" }</span></span>
157+ }
158+ <kbd aria-hidden="true">{ s.Key }</kbd>
159+ </a>
154160 </li>
155161 }
156162 </ul>
@@ -150,7 +150,13 @@ templ Header(l Layout) {
150 if s.Current {150 if s.Current {
151 aria-current="page"151 aria-current="page"
152 }152 }
153- >{ s.Label }<kbd aria-hidden="true">{ s.Key }</kbd></a>153+ >
154+ { s.Label }
155+ if s.Count != "" {
156+ <span class="count">{ s.Count }<span class="sr">{ " open" }</span></span>
157+ }
158+ <kbd aria-hidden="true">{ s.Key }</kbd>
159+ </a>
154 </li>160 </li>
155 }161 }
156 </ul>162 </ul>
M internal/adapters/web/shell_templ.go +64 -28 generated file: collapsed
M internal/adapters/web/static/css/60-mr.css +21 -0
@@ -5,6 +5,7 @@
55 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
66 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
77 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
8+ .mr-state[data-state="draft"]::before { content: "◌ "; color: var(--ink-dim); }
89 .mr-desc { max-width: var(--measure); white-space: pre-wrap; overflow-wrap: anywhere; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
910 .mr-desc a, .c-text a { text-decoration: underline; }
1011
@@ -29,11 +30,31 @@
2930 & .d-check input { accent-color: var(--ink-hi); margin: 0; }
3031 & .d-btns { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; }
3132 & .d-btns .primary { flex: 1; justify-content: center; }
33+ /* the terminal lines fold away: a reviewer who merges in the page does
34+ not need them in sight */
35+ & .d-draft { font-size: var(--fs-s); color: var(--ink-dim); }
3236 & .d-or { font-size: var(--fs-s); }
37+ & .d-or > summary { cursor: pointer; list-style: none; }
38+ & .d-or > summary::-webkit-details-marker { display: none; }
39+ & .d-or > summary::before { content: "▸ "; }
40+ & .d-or[open] > summary::before { content: "▾ "; }
41+ & .d-or > summary:hover { color: var(--ink-hi); }
42+ & .d-or[open] > .clone { margin-top: var(--s2); }
3343 & .clone { margin-top: 0; }
3444 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
3545 }
3646
47+ /* the rail of a merge request follows the review: the merge block and the
48+ checks stay in reach at the end of a long diff. It scrolls on its own
49+ when it is taller than the window. Only on a two-column layout. */
50+ @media (min-width: 1081px) {
51+ .work.has-rail:has(#mr-checks) .rail {
52+ position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4));
53+ max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2);
54+ overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
55+ }
56+ }
57+
3758 .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); }
3859 .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; }
3960 .pcommits li a { font-family: var(--mono); }
@@ -5,6 +5,7 @@
5 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }5 .mr-state[data-state="open"]::before { content: "● "; color: var(--teal); }
6 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }6 .mr-state[data-state="merged"]::before { content: "✓ "; color: var(--teal); }
7 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }7 .mr-state[data-state="closed"]::before { content: "✗ "; color: var(--red); }
8+ .mr-state[data-state="draft"]::before { content: "◌ "; color: var(--ink-dim); }
8 .mr-desc { max-width: var(--measure); white-space: pre-wrap; overflow-wrap: anywhere; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }9 .mr-desc { max-width: var(--measure); white-space: pre-wrap; overflow-wrap: anywhere; padding: var(--s4) var(--s5); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
9 .mr-desc a, .c-text a { text-decoration: underline; }10 .mr-desc a, .c-text a { text-decoration: underline; }
10 11
@@ -29,11 +30,31 @@
29 & .d-check input { accent-color: var(--ink-hi); margin: 0; }30 & .d-check input { accent-color: var(--ink-hi); margin: 0; }
30 & .d-btns { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; }31 & .d-btns { display: flex; gap: var(--s2); margin-top: var(--s3); flex-wrap: wrap; }
31 & .d-btns .primary { flex: 1; justify-content: center; }32 & .d-btns .primary { flex: 1; justify-content: center; }
33+ /* the terminal lines fold away: a reviewer who merges in the page does
34+ not need them in sight */
35+ & .d-draft { font-size: var(--fs-s); color: var(--ink-dim); }
32 & .d-or { font-size: var(--fs-s); }36 & .d-or { font-size: var(--fs-s); }
37+ & .d-or > summary { cursor: pointer; list-style: none; }
38+ & .d-or > summary::-webkit-details-marker { display: none; }
39+ & .d-or > summary::before { content: "▸ "; }
40+ & .d-or[open] > summary::before { content: "▾ "; }
41+ & .d-or > summary:hover { color: var(--ink-hi); }
42+ & .d-or[open] > .clone { margin-top: var(--s2); }
33 & .clone { margin-top: 0; }43 & .clone { margin-top: 0; }
34 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }44 & .clone code { white-space: pre-wrap; overflow-wrap: anywhere; }
35 }45 }
36 46
47+ /* the rail of a merge request follows the review: the merge block and the
48+ checks stay in reach at the end of a long diff. It scrolls on its own
49+ when it is taller than the window. Only on a two-column layout. */
50+ @media (min-width: 1081px) {
51+ .work.has-rail:has(#mr-checks) .rail {
52+ position: sticky; top: calc(var(--hdr-h, 0px) + var(--s4));
53+ max-height: calc(100dvh - var(--hdr-h, 0px) - var(--s4) * 2);
54+ overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
55+ }
56+ }
57+
37 .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); }58 .pcommits { font-size: var(--fs-s); display: grid; gap: var(--s1); }
38 .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; }59 .pcommits li { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px var(--s2); margin: 0 calc(-1 * var(--s2)); border-radius: 4px; }
39 .pcommits li a { font-family: var(--mono); }60 .pcommits li a { font-family: var(--mono); }
M internal/adapters/web/static/css/65-ci.css +6 -1
@@ -50,7 +50,12 @@
5050 .jobs > li { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
5151 .job-h { display: flex; align-items: center; gap: var(--s2); font-weight: 700; }
5252 .steps { display: grid; gap: 2px; padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
53- .steps li { display: flex; align-items: center; gap: var(--s1); }
53+ /* a long step name (a whole shell command) wraps inside the rail; the icon
54+ stays on its first line and the duration at the right */
55+ .steps li { display: flex; align-items: flex-start; gap: var(--s1); min-width: 0; }
56+ .steps li > :is(a, span:not(.dim)) { flex: 1; min-width: 0; overflow-wrap: anywhere; }
57+ .steps li > .dim { flex: none; margin-left: auto; }
58+ .steps li > :first-child { margin-top: 3px; flex: none; }
5459 .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
5560 .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }
5661 .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
@@ -50,7 +50,12 @@
50 .jobs > li { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }50 .jobs > li { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
51 .job-h { display: flex; align-items: center; gap: var(--s2); font-weight: 700; }51 .job-h { display: flex; align-items: center; gap: var(--s2); font-weight: 700; }
52 .steps { display: grid; gap: 2px; padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }52 .steps { display: grid; gap: 2px; padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
53- .steps li { display: flex; align-items: center; gap: var(--s1); }53+ /* a long step name (a whole shell command) wraps inside the rail; the icon
54+ stays on its first line and the duration at the right */
55+ .steps li { display: flex; align-items: flex-start; gap: var(--s1); min-width: 0; }
56+ .steps li > :is(a, span:not(.dim)) { flex: 1; min-width: 0; overflow-wrap: anywhere; }
57+ .steps li > .dim { flex: none; margin-left: auto; }
58+ .steps li > :first-child { margin-top: 3px; flex: none; }
54 .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }59 .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
55 .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }60 .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }
56 .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }61 .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
M internal/adapters/web/text.go +16 -0
@@ -26,6 +26,22 @@ func invisible(r rune) bool {
2626 // marker is the visible form of an invisible character.
2727 func marker(r rune) string { return fmt.Sprintf("<U+%04X>", r) }
2828
29+// who is the name shown for the author of a merge request or a comment, or the
30+// actor of a run. ermite has one user: the owner's web session, root and every
31+// stored token act for the owner, so they show the owner's name (ERMITE_OWNER),
32+// not the label of the token, which names a device ("laptop"). Only CI is
33+// someone else, shown as stored: a job token ("ci job") and the workflows a job
34+// dispatched ("github-actions[bot]").
35+func (s *Server) who(stored string) string {
36+ if strings.HasPrefix(stored, "ci ") || strings.HasSuffix(stored, "[bot]") {
37+ return visibleText(stored)
38+ }
39+ if s.cfg.Owner != "" {
40+ return s.cfg.Owner
41+ }
42+ return "owner"
43+}
44+
2945 // visibleText is s with every invisible character replaced by its marker,
3046 // for the places that show text as a plain string: paths, names, subjects.
3147 func visibleText(s string) string {
@@ -26,6 +26,22 @@ func invisible(r rune) bool {
26 // marker is the visible form of an invisible character.26 // marker is the visible form of an invisible character.
27 func marker(r rune) string { return fmt.Sprintf("<U+%04X>", r) }27 func marker(r rune) string { return fmt.Sprintf("<U+%04X>", r) }
28 28
29+// who is the name shown for the author of a merge request or a comment, or the
30+// actor of a run. ermite has one user: the owner's web session, root and every
31+// stored token act for the owner, so they show the owner's name (ERMITE_OWNER),
32+// not the label of the token, which names a device ("laptop"). Only CI is
33+// someone else, shown as stored: a job token ("ci job") and the workflows a job
34+// dispatched ("github-actions[bot]").
35+func (s *Server) who(stored string) string {
36+ if strings.HasPrefix(stored, "ci ") || strings.HasSuffix(stored, "[bot]") {
37+ return visibleText(stored)
38+ }
39+ if s.cfg.Owner != "" {
40+ return s.cfg.Owner
41+ }
42+ return "owner"
43+}
44+
29 // visibleText is s with every invisible character replaced by its marker,45 // visibleText is s with every invisible character replaced by its marker,
30 // for the places that show text as a plain string: paths, names, subjects.46 // for the places that show text as a plain string: paths, names, subjects.
31 func visibleText(s string) string {47 func visibleText(s string) string {
M internal/adapters/web/text_test.go +12 -0
@@ -8,6 +8,18 @@ import (
88 "github.com/stretchr/testify/assert"
99 )
1010
11+// People show as the owner's name, whatever token they used; CI as stored.
12+func TestWho(t *testing.T) {
13+ s := &Server{cfg: Config{Owner: "alice"}}
14+ for stored, want := range map[string]string{
15+ "owner": "alice", "root": "alice", "laptop": "alice", "": "alice",
16+ "ci job": "ci job", "github-actions[bot]": "github-actions[bot]",
17+ } {
18+ assert.Equal(t, want, s.who(stored), stored)
19+ }
20+ assert.Equal(t, "owner", (&Server{}).who("laptop"), "no owner configured")
21+}
22+
1123 // Invisible characters in names and subjects show as markers.
1224 func TestVisibleText(t *testing.T) {
1325 t.Parallel()
@@ -8,6 +8,18 @@ import (
8 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
9 )9 )
10 10
11+// People show as the owner's name, whatever token they used; CI as stored.
12+func TestWho(t *testing.T) {
13+ s := &Server{cfg: Config{Owner: "alice"}}
14+ for stored, want := range map[string]string{
15+ "owner": "alice", "root": "alice", "laptop": "alice", "": "alice",
16+ "ci job": "ci job", "github-actions[bot]": "github-actions[bot]",
17+ } {
18+ assert.Equal(t, want, s.who(stored), stored)
19+ }
20+ assert.Equal(t, "owner", (&Server{}).who("laptop"), "no owner configured")
21+}
22+
11 // Invisible characters in names and subjects show as markers.23 // Invisible characters in names and subjects show as markers.
12 func TestVisibleText(t *testing.T) {24 func TestVisibleText(t *testing.T) {
13 t.Parallel()25 t.Parallel()
M internal/adapters/web/view.go +3 -0
@@ -20,6 +20,9 @@ type Section struct {
2020 Label string
2121 Href string
2222 Current bool
23+ // Count is shown after the label when it is not empty: the open merge
24+ // requests.
25+ Count string
2326 }
2427
2528 // RefItem is a branch or tag in the ref switcher.
@@ -20,6 +20,9 @@ type Section struct {
20 Label string20 Label string
21 Href string21 Href string
22 Current bool22 Current bool
23+ // Count is shown after the label when it is not empty: the open merge
24+ // requests.
25+ Count string
23 }26 }
24 27
25 // RefItem is a branch or tag in the ref switcher.28 // RefItem is a branch or tag in the ref switcher.
M internal/adapters/web/web.go +1 -0
@@ -184,6 +184,7 @@ func (s *Server) routes() *http.ServeMux {
184184 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
185185 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
186186 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
187+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
187188 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
188189 }
189190 if s.cfg.Runs != nil {
@@ -184,6 +184,7 @@ func (s *Server) routes() *http.ServeMux {
184 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))184 mux.HandleFunc("POST /{repo}/merge-requests/{n}/comments/{id}/resolve", s.mrAction(s.mrResolve))
185 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))185 mux.HandleFunc("POST /{repo}/merge-requests/{n}/merge", s.mrAction(s.mrMerge))
186 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))186 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
187+ mux.HandleFunc("POST /{repo}/merge-requests/{n}/ready", s.mrAction(s.mrReady))
187 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))188 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
188 }189 }
189 if s.cfg.Runs != nil {190 if s.cfg.Runs != nil {
M internal/app/ci_pull_target_test.go +1 -1
@@ -355,7 +355,7 @@ func TestWebShowsTheMergeRequestOfAPullRequestTargetRun(t *testing.T) {
355355 res := b.get(page, nil)
356356 require.Equal(t, 200, res.status, res.body)
357357 assert.Contains(t, res.body, fmt.Sprintf(`href="/proj/merge-requests/%d"`, n), page)
358- assert.Contains(t, res.body, fmt.Sprintf("!%d", n), page)
358+ assert.Contains(t, res.body, fmt.Sprintf(">#%d<", n), page)
359359 }
360360 }
361361
@@ -355,7 +355,7 @@ func TestWebShowsTheMergeRequestOfAPullRequestTargetRun(t *testing.T) {
355 res := b.get(page, nil)355 res := b.get(page, nil)
356 require.Equal(t, 200, res.status, res.body)356 require.Equal(t, 200, res.status, res.body)
357 assert.Contains(t, res.body, fmt.Sprintf(`href="/proj/merge-requests/%d"`, n), page)357 assert.Contains(t, res.body, fmt.Sprintf(`href="/proj/merge-requests/%d"`, n), page)
358- assert.Contains(t, res.body, fmt.Sprintf("!%d", n), page)358+ assert.Contains(t, res.body, fmt.Sprintf(">#%d<", n), page)
359 }359 }
360 }360 }
361 361
M internal/app/ci_web_test.go +13 -5
@@ -40,7 +40,7 @@ func TestWebActionsPages(t *testing.T) {
4040 assert.Contains(t, list.body, `href="/proj/actions/runs/1"`)
4141 assert.Contains(t, list.body, `aria-label="queued"`)
4242 assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&amp;view=actions", "the page follows the repository stream")
43- assert.Contains(t, list.body, ">Actions<", "the tab")
43+ assert.Contains(t, list.body, `aria-current="page">Actions <kbd`, "the tab")
4444
4545 // The MR page says "waiting for a runner" only when no runner matches.
4646 mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
@@ -134,7 +134,8 @@ func TestWebActionsPages(t *testing.T) {
134134 }
135135
136136 // The live updates of the pages: the run page gets the body of the run when a
137-// job changes, and the job page its state, never a line of the log.
137+// job changes, the job page its state, never a line of the log, and the page
138+// of a merge request the checks of the head it shows (and only of that head).
138139 func TestWebActionsLiveFragments(t *testing.T) {
139140 ci := newCIHarness(t, nil)
140141 c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
@@ -151,12 +152,15 @@ func TestWebActionsLiveFragments(t *testing.T) {
151152 list := ci.sse("topic=repo:proj&view=actions", "")
152153 job := runner.await()
153154 jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
155+ sha := ci.runs()[0].GetSha()
156+ mrPage := ci.sse("topic=repo:proj&view=mr&n=1&ref=main&head="+sha, "")
157+ otherHead := ci.sse("topic=repo:proj&view=mr&n=2&ref=x&head="+strings.Repeat("0", 40), "")
154158 _ = token
155159
156160 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log")
157161 deadline := time.After(10 * time.Second)
158- var sawRun, sawList, sawJob bool
159- for !sawRun || !sawList || !sawJob {
162+ var sawRun, sawList, sawJob, sawChecks bool
163+ for !sawRun || !sawList || !sawJob || !sawChecks {
160164 select {
161165 case ev := <-run.events:
162166 sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`))
@@ -166,8 +170,12 @@ func TestWebActionsLiveFragments(t *testing.T) {
166170 case ev := <-jobStream.events:
167171 sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`))
168172 assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log")
173+ case ev := <-mrPage.events:
174+ sawChecks = sawChecks || (ev.name == "ci-run" && strings.Contains(ev.data, `id="mr-checks"`) && strings.Contains(ev.data, "success"))
175+ case ev := <-otherHead.events:
176+ assert.NotEqual(t, "ci-run", ev.name, "the checks of another head are not sent")
169177 case <-deadline:
170- t.Fatalf("fragments: run %v, list %v, job %v", sawRun, sawList, sawJob)
178+ t.Fatalf("fragments: run %v, list %v, job %v, checks %v", sawRun, sawList, sawJob, sawChecks)
171179 }
172180 }
173181 _ = domain.CISuccess
@@ -40,7 +40,7 @@ func TestWebActionsPages(t *testing.T) {
40 assert.Contains(t, list.body, `href="/proj/actions/runs/1"`)40 assert.Contains(t, list.body, `href="/proj/actions/runs/1"`)
41 assert.Contains(t, list.body, `aria-label="queued"`)41 assert.Contains(t, list.body, `aria-label="queued"`)
42 assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&amp;view=actions", "the page follows the repository stream")42 assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&amp;view=actions", "the page follows the repository stream")
43- assert.Contains(t, list.body, ">Actions<", "the tab")43+ assert.Contains(t, list.body, `aria-current="page">Actions <kbd`, "the tab")
44 44
45 // The MR page says "waiting for a runner" only when no runner matches.45 // The MR page says "waiting for a runner" only when no runner matches.
46 mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))46 mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
@@ -134,7 +134,8 @@ func TestWebActionsPages(t *testing.T) {
134 }134 }
135 135
136 // The live updates of the pages: the run page gets the body of the run when a136 // The live updates of the pages: the run page gets the body of the run when a
137-// job changes, and the job page its state, never a line of the log.137+// job changes, the job page its state, never a line of the log, and the page
138+// of a merge request the checks of the head it shows (and only of that head).
138 func TestWebActionsLiveFragments(t *testing.T) {139 func TestWebActionsLiveFragments(t *testing.T) {
139 ci := newCIHarness(t, nil)140 ci := newCIHarness(t, nil)
140 c := &cliHarness{harness: ci.harness, url: ci.srv.URL}141 c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
@@ -151,12 +152,15 @@ func TestWebActionsLiveFragments(t *testing.T) {
151 list := ci.sse("topic=repo:proj&view=actions", "")152 list := ci.sse("topic=repo:proj&view=actions", "")
152 job := runner.await()153 job := runner.await()
153 jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")154 jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
155+ sha := ci.runs()[0].GetSha()
156+ mrPage := ci.sse("topic=repo:proj&view=mr&n=1&ref=main&head="+sha, "")
157+ otherHead := ci.sse("topic=repo:proj&view=mr&n=2&ref=x&head="+strings.Repeat("0", 40), "")
154 _ = token158 _ = token
155 159
156 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log")160 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log")
157 deadline := time.After(10 * time.Second)161 deadline := time.After(10 * time.Second)
158- var sawRun, sawList, sawJob bool162+ var sawRun, sawList, sawJob, sawChecks bool
159- for !sawRun || !sawList || !sawJob {163+ for !sawRun || !sawList || !sawJob || !sawChecks {
160 select {164 select {
161 case ev := <-run.events:165 case ev := <-run.events:
162 sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`))166 sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`))
@@ -166,8 +170,12 @@ func TestWebActionsLiveFragments(t *testing.T) {
166 case ev := <-jobStream.events:170 case ev := <-jobStream.events:
167 sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`))171 sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`))
168 assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log")172 assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log")
173+ case ev := <-mrPage.events:
174+ sawChecks = sawChecks || (ev.name == "ci-run" && strings.Contains(ev.data, `id="mr-checks"`) && strings.Contains(ev.data, "success"))
175+ case ev := <-otherHead.events:
176+ assert.NotEqual(t, "ci-run", ev.name, "the checks of another head are not sent")
169 case <-deadline:177 case <-deadline:
170- t.Fatalf("fragments: run %v, list %v, job %v", sawRun, sawList, sawJob)178+ t.Fatalf("fragments: run %v, list %v, job %v, checks %v", sawRun, sawList, sawJob, sawChecks)
171 }179 }
172 }180 }
173 _ = domain.CISuccess181 _ = domain.CISuccess
M internal/app/testdata/web/blob-binary.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/blob-code.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/blob-empty.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/blob-image.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/blob-markdown.html +6 -6
@@ -74,27 +74,27 @@
7474 <nav class="tabs" aria-label="Sections">
7575 <ul>
7676 <li>
77-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
77+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7878 </a>
7979 </li>
8080 <li>
81-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
81+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
8282 </a>
8383 </li>
8484 <li>
85-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
85+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8686 </a>
8787 </li>
8888 <li>
89-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
89+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
9090 </a>
9191 </li>
9292 <li>
93-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
93+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9494 </a>
9595 </li>
9696 <li>
97-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
97+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9898 </a>
9999 </li>
100100 </ul>
@@ -74,27 +74,27 @@
74 <nav class="tabs" aria-label="Sections">74 <nav class="tabs" aria-label="Sections">
75 <ul>75 <ul>
76 <li>76 <li>
77-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>77+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
78 </a>78 </a>
79 </li>79 </li>
80 <li>80 <li>
81-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>81+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
82 </a>82 </a>
83 </li>83 </li>
84 <li>84 <li>
85-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>85+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
86 </a>86 </a>
87 </li>87 </li>
88 <li>88 <li>
89-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>89+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
90 </a>90 </a>
91 </li>91 </li>
92 <li>92 <li>
93-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>93+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
94 </a>94 </a>
95 </li>95 </li>
96 <li>96 <li>
97-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>97+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
98 </a>98 </a>
99 </li>99 </li>
100 </ul>100 </ul>
M internal/app/testdata/web/blob-svg.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/blob-text.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/blob-too-large.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/blob-trojan.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/commit-big.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commit-binary.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commit-merge.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commit-modify-split.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commit-modify.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commit-rename.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commit-root.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commit-trojan.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commits-page2.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/long" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/long" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/long" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/long" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commits-path.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/long" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/long" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/long" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/long" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>78+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/commits-root.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/long" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/long" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/long" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/long" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/long/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/long/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/long/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/commits-small.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/diffs" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>75+<a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="c" aria-current="page">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/diffs/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/refs.html +6 -6
@@ -65,27 +65,27 @@
6565 <nav class="tabs" aria-label="Sections">
6666 <ul>
6767 <li>
68-<a href="/demo" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
68+<a href="/demo" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
6969 </a>
7070 </li>
7171 <li>
72-<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
72+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7373 </a>
7474 </li>
7575 <li>
76-<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
76+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
7777 </a>
7878 </li>
7979 <li>
80-<a href="/demo/refs" data-key="r" aria-keyshortcuts="r" aria-current="page">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
80+<a href="/demo/refs" data-key="r" aria-keyshortcuts="r" aria-current="page">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8181 </a>
8282 </li>
8383 <li>
84-<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
84+<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8585 </a>
8686 </li>
8787 <li>
88-<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
88+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
8989 </a>
9090 </li>
9191 </ul>
@@ -65,27 +65,27 @@
65 <nav class="tabs" aria-label="Sections">65 <nav class="tabs" aria-label="Sections">
66 <ul>66 <ul>
67 <li>67 <li>
68-<a href="/demo" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>68+<a href="/demo" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
69 </a>69 </a>
70 </li>70 </li>
71 <li>71 <li>
72-<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>72+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
73 </a>73 </a>
74 </li>74 </li>
75 <li>75 <li>
76-<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>76+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
77 </a>77 </a>
78 </li>78 </li>
79 <li>79 <li>
80-<a href="/demo/refs" data-key="r" aria-keyshortcuts="r" aria-current="page">Branches &amp; tags<kbd aria-hidden="true">r</kbd>80+<a href="/demo/refs" data-key="r" aria-keyshortcuts="r" aria-current="page">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
81 </a>81 </a>
82 </li>82 </li>
83 <li>83 <li>
84-<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>84+<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
85 </a>85 </a>
86 </li>86 </li>
87 <li>87 <li>
88-<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>88+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
89 </a>89 </a>
90 </li>90 </li>
91 </ul>91 </ul>
M internal/app/testdata/web/summary.html +6 -6
@@ -62,27 +62,27 @@
6262 <nav class="tabs" aria-label="Sections">
6363 <ul>
6464 <li>
65-<a href="/demo" data-key="s" aria-keyshortcuts="s" aria-current="page">Summary<kbd aria-hidden="true">s</kbd>
65+<a href="/demo" data-key="s" aria-keyshortcuts="s" aria-current="page">Summary <kbd aria-hidden="true">s</kbd>
6666 </a>
6767 </li>
6868 <li>
69-<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>
69+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
7070 </a>
7171 </li>
7272 <li>
73-<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
73+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
7474 </a>
7575 </li>
7676 <li>
77-<a href="/demo/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
77+<a href="/demo/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
7878 </a>
7979 </li>
8080 <li>
81-<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
81+<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8282 </a>
8383 </li>
8484 <li>
85-<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
85+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
8686 </a>
8787 </li>
8888 </ul>
@@ -62,27 +62,27 @@
62 <nav class="tabs" aria-label="Sections">62 <nav class="tabs" aria-label="Sections">
63 <ul>63 <ul>
64 <li>64 <li>
65-<a href="/demo" data-key="s" aria-keyshortcuts="s" aria-current="page">Summary<kbd aria-hidden="true">s</kbd>65+<a href="/demo" data-key="s" aria-keyshortcuts="s" aria-current="page">Summary <kbd aria-hidden="true">s</kbd>
66 </a>66 </a>
67 </li>67 </li>
68 <li>68 <li>
69-<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files<kbd aria-hidden="true">t</kbd>69+<a href="/demo/tree/main" data-key="t" aria-keyshortcuts="t">Files <kbd aria-hidden="true">t</kbd>
70 </a>70 </a>
71 </li>71 </li>
72 <li>72 <li>
73-<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>73+<a href="/demo/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
74 </a>74 </a>
75 </li>75 </li>
76 <li>76 <li>
77-<a href="/demo/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>77+<a href="/demo/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
78 </a>78 </a>
79 </li>79 </li>
80 <li>80 <li>
81-<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>81+<a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
82 </a>82 </a>
83 </li>83 </li>
84 <li>84 <li>
85-<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>85+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
86 </a>86 </a>
87 </li>87 </li>
88 </ul>88 </ul>
M internal/app/testdata/web/tree-nested.html +6 -6
@@ -74,27 +74,27 @@
7474 <nav class="tabs" aria-label="Sections">
7575 <ul>
7676 <li>
77-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
77+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7878 </a>
7979 </li>
8080 <li>
81-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
81+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
8282 </a>
8383 </li>
8484 <li>
85-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
85+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8686 </a>
8787 </li>
8888 <li>
89-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
89+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
9090 </a>
9191 </li>
9292 <li>
93-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
93+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9494 </a>
9595 </li>
9696 <li>
97-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
97+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9898 </a>
9999 </li>
100100 </ul>
@@ -74,27 +74,27 @@
74 <nav class="tabs" aria-label="Sections">74 <nav class="tabs" aria-label="Sections">
75 <ul>75 <ul>
76 <li>76 <li>
77-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>77+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
78 </a>78 </a>
79 </li>79 </li>
80 <li>80 <li>
81-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>81+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
82 </a>82 </a>
83 </li>83 </li>
84 <li>84 <li>
85-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>85+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
86 </a>86 </a>
87 </li>87 </li>
88 <li>88 <li>
89-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>89+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
90 </a>90 </a>
91 </li>91 </li>
92 <li>92 <li>
93-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>93+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
94 </a>94 </a>
95 </li>95 </li>
96 <li>96 <li>
97-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>97+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
98 </a>98 </a>
99 </li>99 </li>
100 </ul>100 </ul>
M internal/app/testdata/web/tree-root.html +6 -6
@@ -68,27 +68,27 @@
6868 <nav class="tabs" aria-label="Sections">
6969 <ul>
7070 <li>
71-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
71+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7272 </a>
7373 </li>
7474 <li>
75-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
75+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7676 </a>
7777 </li>
7878 <li>
79-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
79+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8080 </a>
8181 </li>
8282 <li>
83-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
83+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8484 </a>
8585 </li>
8686 <li>
87-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
87+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
8888 </a>
8989 </li>
9090 <li>
91-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9292 </a>
9393 </li>
9494 </ul>
@@ -68,27 +68,27 @@
68 <nav class="tabs" aria-label="Sections">68 <nav class="tabs" aria-label="Sections">
69 <ul>69 <ul>
70 <li>70 <li>
71-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>71+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
72 </a>72 </a>
73 </li>73 </li>
74 <li>74 <li>
75-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>75+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
76 </a>76 </a>
77 </li>77 </li>
78 <li>78 <li>
79-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>79+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
80 </a>80 </a>
81 </li>81 </li>
82 <li>82 <li>
83-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>83+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
84 </a>84 </a>
85 </li>85 </li>
86 <li>86 <li>
87-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>87+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
88 </a>88 </a>
89 </li>89 </li>
90 <li>90 <li>
91-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
92 </a>92 </a>
93 </li>93 </li>
94 </ul>94 </ul>
M internal/app/testdata/web/tree-slash-ref.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/testdata/web/tree-subdir.html +6 -6
@@ -71,27 +71,27 @@
7171 <nav class="tabs" aria-label="Sections">
7272 <ul>
7373 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>
74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
7575 </a>
7676 </li>
7777 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>
78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
7979 </a>
8080 </li>
8181 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>
82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
8383 </a>
8484 </li>
8585 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>
86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
8787 </a>
8888 </li>
8989 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
9191 </a>
9292 </li>
9393 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
9595 </a>
9696 </li>
9797 </ul>
@@ -71,27 +71,27 @@
71 <nav class="tabs" aria-label="Sections">71 <nav class="tabs" aria-label="Sections">
72 <ul>72 <ul>
73 <li>73 <li>
74-<a href="/files" data-key="s" aria-keyshortcuts="s">Summary<kbd aria-hidden="true">s</kbd>74+<a href="/files" data-key="s" aria-keyshortcuts="s">Summary <kbd aria-hidden="true">s</kbd>
75 </a>75 </a>
76 </li>76 </li>
77 <li>77 <li>
78-<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files<kbd aria-hidden="true">t</kbd>78+<a href="/files/tree/main" data-key="t" aria-keyshortcuts="t" aria-current="page">Files <kbd aria-hidden="true">t</kbd>
79 </a>79 </a>
80 </li>80 </li>
81 <li>81 <li>
82-<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits<kbd aria-hidden="true">c</kbd>82+<a href="/files/commits/main" data-key="c" aria-keyshortcuts="c">Commits <kbd aria-hidden="true">c</kbd>
83 </a>83 </a>
84 </li>84 </li>
85 <li>85 <li>
86-<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags<kbd aria-hidden="true">r</kbd>86+<a href="/files/refs" data-key="r" aria-keyshortcuts="r">Branches &amp; tags <kbd aria-hidden="true">r</kbd>
87 </a>87 </a>
88 </li>88 </li>
89 <li>89 <li>
90-<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>90+<a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests <kbd aria-hidden="true">m</kbd>
91 </a>91 </a>
92 </li>92 </li>
93 <li>93 <li>
94-<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions <kbd aria-hidden="true">a</kbd>
95 </a>95 </a>
96 </li>96 </li>
97 </ul>97 </ul>
M internal/app/web_mr_test.go +51 -2
@@ -130,7 +130,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
130130 require.Equal(t, 200, mr.status, mr.body)
131131 assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control"))
132132 assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary"))
133- assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")
133+ assert.Contains(t, mr.body, "<h1><span class=\"mute\">#1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")
134134 assert.NotContains(t, mr.body, "<b>search</b>")
135135 assert.Contains(t, mr.body, "feat/search")
136136 assert.Contains(t, mr.body, "package search</td>")
@@ -218,6 +218,7 @@ func TestWebMergeRequestWritesNeedSessionAndOrigin(t *testing.T) {
218218 "/proj/merge-requests/1/merge": "mode=fast-forward&head_id=" + head,
219219 "/proj/merge-requests/1/close": "",
220220 "/proj/merge-requests/1/read": "head_id=" + head,
221+ "/proj/merge-requests/1/ready": "",
221222 }
222223 for path, body := range paths {
223224 noOrigin := b.do("POST", path, as(token), body)
@@ -430,6 +431,54 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
430431 assert.Contains(t, stale.body, "has moved on")
431432 }
432433
434+// A "WIP:" request is a draft: the list and the page say so, the box offers
435+// "Ready for review" instead of the merge form and the terminal lines, the
436+// merge is refused; ready drops the prefix and the merge form comes back. The
437+// tab counts the open requests.
438+func TestWebMergeRequestDraft(t *testing.T) {
439+ r := newMRRepo(t, domain.VisibilityPublic)
440+ b := browser{r.harness}
441+ token := r.signIn(t)
442+ head := r.headOf("feat/search")
443+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "WIP: search").ok(t)
444+
445+ page := b.get("/proj/merge-requests/1", as(token)).body
446+ assert.Contains(t, page, `data-state="draft"`)
447+ assert.Contains(t, page, `action="/proj/merge-requests/1/ready"`)
448+ assert.NotContains(t, page, `action="/proj/merge-requests/1/merge"`)
449+ assert.NotContains(t, page, "ermite mr merge", "no terminal line for a draft")
450+ assert.Contains(t, page, `<span class="count">1<span class="sr"> open</span></span>`, "the tab counts the open requests")
451+ assert.Contains(t, b.get("/proj/merge-requests", nil).body, `data-state="draft"`)
452+
453+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
454+ assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
455+
456+ res = b.form("/proj/merge-requests/1/ready", token, nil)
457+ require.Equal(t, http.StatusSeeOther, res.status, res.body)
458+ page = b.get("/proj/merge-requests/1", as(token)).body
459+ assert.Contains(t, page, "> search</h1>", "the prefix is gone")
460+ assert.Contains(t, page, `data-state="open"`)
461+ assert.Contains(t, page, `action="/proj/merge-requests/1/merge"`)
462+ assert.Contains(t, page, `<details class="d-or">`, "the terminal lines fold away")
463+ assert.Equal(t, http.StatusConflict, b.form("/proj/merge-requests/1/ready", token, nil).status, "not a draft any more")
464+}
465+
466+// A request opened with a token shows the owner's name, not the label of the
467+// token, which names a device.
468+func TestWebMergeRequestAuthorIsTheOwner(t *testing.T) {
469+ r := newMRRepo(t, domain.VisibilityPublic)
470+ out, errOut, code := r.bin(t, adminToken, "token", "create", "laptop", "--preset", "cli")
471+ require.Equal(t, 0, code, errOut)
472+ r.cli(t, strings.TrimSpace(out), "mr", "create", "proj", "--source", "feat/search", "--title", "search").ok(t)
473+
474+ b := browser{r.harness}
475+ for _, path := range []string{"/proj/merge-requests/1", "/proj/merge-requests"} {
476+ body := b.get(path, nil).body
477+ assert.NotContains(t, body, "laptop", path)
478+ }
479+ assert.Contains(t, b.get("/proj/merge-requests", nil).body, `<td class="c-who dim">owner</td>`)
480+}
481+
433482 // The box offers no deletion for the default branch; a fast-forward with the
434483 // box checked deletes the source.
435484 func TestWebMergeDeleteBranchBox(t *testing.T) {
@@ -543,7 +592,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
543592 drained = true
544593 }
545594 }
546- assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch"`)
595+ assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`"`)
547596 }
548597
549598 func (r *mrRepo) get1(path string) string {
@@ -130,7 +130,7 @@ func TestWebMergeRequestPagesRender(t *testing.T) {
130 require.Equal(t, 200, mr.status, mr.body)130 require.Equal(t, 200, mr.status, mr.body)
131 assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control"))131 assert.Equal(t, "no-cache", mr.hdr.Get("Cache-Control"))
132 assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary"))132 assert.Equal(t, []string{"Accept-Encoding"}, mr.hdr.Values("Vary"))
133- assert.Contains(t, mr.body, "<h1><span class=\"mute\">!1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")133+ assert.Contains(t, mr.body, "<h1><span class=\"mute\">#1</span> Add &lt;b&gt;search&lt;/b&gt;</h1>")
134 assert.NotContains(t, mr.body, "<b>search</b>")134 assert.NotContains(t, mr.body, "<b>search</b>")
135 assert.Contains(t, mr.body, "feat/search")135 assert.Contains(t, mr.body, "feat/search")
136 assert.Contains(t, mr.body, "package search</td>")136 assert.Contains(t, mr.body, "package search</td>")
@@ -218,6 +218,7 @@ func TestWebMergeRequestWritesNeedSessionAndOrigin(t *testing.T) {
218 "/proj/merge-requests/1/merge": "mode=fast-forward&head_id=" + head,218 "/proj/merge-requests/1/merge": "mode=fast-forward&head_id=" + head,
219 "/proj/merge-requests/1/close": "",219 "/proj/merge-requests/1/close": "",
220 "/proj/merge-requests/1/read": "head_id=" + head,220 "/proj/merge-requests/1/read": "head_id=" + head,
221+ "/proj/merge-requests/1/ready": "",
221 }222 }
222 for path, body := range paths {223 for path, body := range paths {
223 noOrigin := b.do("POST", path, as(token), body)224 noOrigin := b.do("POST", path, as(token), body)
@@ -430,6 +431,54 @@ func TestWebMergeRequestSquashCloseAndList(t *testing.T) {
430 assert.Contains(t, stale.body, "has moved on")431 assert.Contains(t, stale.body, "has moved on")
431 }432 }
432 433
434+// A "WIP:" request is a draft: the list and the page say so, the box offers
435+// "Ready for review" instead of the merge form and the terminal lines, the
436+// merge is refused; ready drops the prefix and the merge form comes back. The
437+// tab counts the open requests.
438+func TestWebMergeRequestDraft(t *testing.T) {
439+ r := newMRRepo(t, domain.VisibilityPublic)
440+ b := browser{r.harness}
441+ token := r.signIn(t)
442+ head := r.headOf("feat/search")
443+ r.cli(t, adminToken, "mr", "create", "proj", "--source", "feat/search", "--title", "WIP: search").ok(t)
444+
445+ page := b.get("/proj/merge-requests/1", as(token)).body
446+ assert.Contains(t, page, `data-state="draft"`)
447+ assert.Contains(t, page, `action="/proj/merge-requests/1/ready"`)
448+ assert.NotContains(t, page, `action="/proj/merge-requests/1/merge"`)
449+ assert.NotContains(t, page, "ermite mr merge", "no terminal line for a draft")
450+ assert.Contains(t, page, `<span class="count">1<span class="sr"> open</span></span>`, "the tab counts the open requests")
451+ assert.Contains(t, b.get("/proj/merge-requests", nil).body, `data-state="draft"`)
452+
453+ res := b.form("/proj/merge-requests/1/merge", token, url.Values{"mode": {"fast-forward"}, "head_id": {head}})
454+ assert.Equal(t, http.StatusConflict, res.status, "a draft is not merged")
455+
456+ res = b.form("/proj/merge-requests/1/ready", token, nil)
457+ require.Equal(t, http.StatusSeeOther, res.status, res.body)
458+ page = b.get("/proj/merge-requests/1", as(token)).body
459+ assert.Contains(t, page, "> search</h1>", "the prefix is gone")
460+ assert.Contains(t, page, `data-state="open"`)
461+ assert.Contains(t, page, `action="/proj/merge-requests/1/merge"`)
462+ assert.Contains(t, page, `<details class="d-or">`, "the terminal lines fold away")
463+ assert.Equal(t, http.StatusConflict, b.form("/proj/merge-requests/1/ready", token, nil).status, "not a draft any more")
464+}
465+
466+// A request opened with a token shows the owner's name, not the label of the
467+// token, which names a device.
468+func TestWebMergeRequestAuthorIsTheOwner(t *testing.T) {
469+ r := newMRRepo(t, domain.VisibilityPublic)
470+ out, errOut, code := r.bin(t, adminToken, "token", "create", "laptop", "--preset", "cli")
471+ require.Equal(t, 0, code, errOut)
472+ r.cli(t, strings.TrimSpace(out), "mr", "create", "proj", "--source", "feat/search", "--title", "search").ok(t)
473+
474+ b := browser{r.harness}
475+ for _, path := range []string{"/proj/merge-requests/1", "/proj/merge-requests"} {
476+ body := b.get(path, nil).body
477+ assert.NotContains(t, body, "laptop", path)
478+ }
479+ assert.Contains(t, b.get("/proj/merge-requests", nil).body, `<td class="c-who dim">owner</td>`)
480+}
481+
433 // The box offers no deletion for the default branch; a fast-forward with the482 // The box offers no deletion for the default branch; a fast-forward with the
434 // box checked deletes the source.483 // box checked deletes the source.
435 func TestWebMergeDeleteBranchBox(t *testing.T) {484 func TestWebMergeDeleteBranchBox(t *testing.T) {
@@ -543,7 +592,7 @@ func TestWebMergeRequestEvents(t *testing.T) {
543 drained = true592 drained = true
544 }593 }
545 }594 }
546- assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch"`)595+ assert.Contains(t, r.get1("/proj/merge-requests/1"), `hx-sse:connect="/-/events?topic=repo%3Aproj&amp;view=mr&amp;n=1&amp;ref=feat%2Fsearch&amp;head=`+r.headOf("feat/search")+`"`)
547 }596 }
548 597
549 func (r *mrRepo) get1(path string) string {598 func (r *mrRepo) get1(path string) string {
M internal/domain/event.go +12 -0
@@ -148,6 +148,18 @@ type MergeRequestClosed struct {
148148 // EventKind implements Event.
149149 func (MergeRequestClosed) EventKind() string { return "mr.closed" }
150150
151+// MergeRequestReady is published when a draft merge request was marked ready
152+// for review: its title lost the draft prefix.
153+type MergeRequestReady struct {
154+ MergeRequest MergeRequest
155+ At time.Time
156+ Visibility Visibility
157+ CreatedAt time.Time
158+}
159+
160+// EventKind implements Event.
161+func (MergeRequestReady) EventKind() string { return "mr.ready" }
162+
151163 // CommentAdded is published when a comment was added to a merge request.
152164 type CommentAdded struct {
153165 Repo RepoName
@@ -148,6 +148,18 @@ type MergeRequestClosed struct {
148 // EventKind implements Event.148 // EventKind implements Event.
149 func (MergeRequestClosed) EventKind() string { return "mr.closed" }149 func (MergeRequestClosed) EventKind() string { return "mr.closed" }
150 150
151+// MergeRequestReady is published when a draft merge request was marked ready
152+// for review: its title lost the draft prefix.
153+type MergeRequestReady struct {
154+ MergeRequest MergeRequest
155+ At time.Time
156+ Visibility Visibility
157+ CreatedAt time.Time
158+}
159+
160+// EventKind implements Event.
161+func (MergeRequestReady) EventKind() string { return "mr.ready" }
162+
151 // CommentAdded is published when a comment was added to a merge request.163 // CommentAdded is published when a comment was added to a merge request.
152 type CommentAdded struct {164 type CommentAdded struct {
153 Repo RepoName165 Repo RepoName
M internal/domain/mergerequest.go +44 -0
@@ -137,6 +137,50 @@ type MergeRequest struct {
137137 Version uint64
138138 }
139139
140+// draftPrefixes mark a draft at the start of a title, compared without regard
141+// to case, as Forgejo and Gitea do.
142+var draftPrefixes = []string{"wip:", "[wip]"}
143+
144+// IsDraft reports whether the title marks the request as a draft: it starts
145+// with "WIP:" or "[WIP]". A draft cannot be merged.
146+func (mr MergeRequest) IsDraft() bool {
147+ _, ok := draftRest(mr.Title.String())
148+ return ok
149+}
150+
151+// ReadyTitle is the title without its draft prefixes (all of them, so that
152+// "WIP: [WIP] x" is no longer a draft), and false when the title has none or
153+// nothing would be left of it.
154+func ReadyTitle(t MRTitle) (MRTitle, bool) {
155+ rest, ok := draftRest(t.String())
156+ if !ok {
157+ return MRTitle{}, false
158+ }
159+ for {
160+ next, more := draftRest(rest)
161+ if !more {
162+ break
163+ }
164+ rest = next
165+ }
166+ ready, err := ParseMRTitle(rest)
167+ if err != nil {
168+ return MRTitle{}, false
169+ }
170+ return ready, true
171+}
172+
173+// draftRest is the title after its draft prefix and the spaces that follow.
174+func draftRest(title string) (string, bool) {
175+ t := strings.TrimLeft(title, " ")
176+ for _, p := range draftPrefixes {
177+ if len(t) >= len(p) && strings.EqualFold(t[:len(p)], p) {
178+ return strings.TrimLeft(t[len(p):], " "), true
179+ }
180+ }
181+ return "", false
182+}
183+
140184 // IsOpen reports whether the request awaits review.
141185 func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
142186
@@ -137,6 +137,50 @@ type MergeRequest struct {
137 Version uint64137 Version uint64
138 }138 }
139 139
140+// draftPrefixes mark a draft at the start of a title, compared without regard
141+// to case, as Forgejo and Gitea do.
142+var draftPrefixes = []string{"wip:", "[wip]"}
143+
144+// IsDraft reports whether the title marks the request as a draft: it starts
145+// with "WIP:" or "[WIP]". A draft cannot be merged.
146+func (mr MergeRequest) IsDraft() bool {
147+ _, ok := draftRest(mr.Title.String())
148+ return ok
149+}
150+
151+// ReadyTitle is the title without its draft prefixes (all of them, so that
152+// "WIP: [WIP] x" is no longer a draft), and false when the title has none or
153+// nothing would be left of it.
154+func ReadyTitle(t MRTitle) (MRTitle, bool) {
155+ rest, ok := draftRest(t.String())
156+ if !ok {
157+ return MRTitle{}, false
158+ }
159+ for {
160+ next, more := draftRest(rest)
161+ if !more {
162+ break
163+ }
164+ rest = next
165+ }
166+ ready, err := ParseMRTitle(rest)
167+ if err != nil {
168+ return MRTitle{}, false
169+ }
170+ return ready, true
171+}
172+
173+// draftRest is the title after its draft prefix and the spaces that follow.
174+func draftRest(title string) (string, bool) {
175+ t := strings.TrimLeft(title, " ")
176+ for _, p := range draftPrefixes {
177+ if len(t) >= len(p) && strings.EqualFold(t[:len(p)], p) {
178+ return strings.TrimLeft(t[len(p):], " "), true
179+ }
180+ }
181+ return "", false
182+}
183+
140 // IsOpen reports whether the request awaits review.184 // IsOpen reports whether the request awaits review.
141 func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }185 func (m MergeRequest) IsOpen() bool { return m.State == MRStateOpen }
142 186
M internal/domain/mergerequest_test.go +24 -0
@@ -211,3 +211,27 @@ func TestParseBranchName(t *testing.T) {
211211 assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", bad)
212212 }
213213 }
214+
215+// A title that starts with "WIP:" or "[WIP]", in any case, is a draft; ready
216+// drops the prefix and the spaces after it.
217+func TestMergeRequestDraft(t *testing.T) {
218+ for _, tc := range []struct {
219+ title, ready string
220+ draft bool
221+ }{
222+ {"WIP: feat: drag and drop", "feat: drag and drop", true},
223+ {"wip:fix", "fix", true},
224+ {" [WIP] tidy", "tidy", true},
225+ {"WIP: [wip] both", "both", true}, // every prefix goes
226+ {"feat: wip: later", "", false},
227+ {"WIPE the cache", "", false},
228+ {"WIP:", "", true}, // a draft, but nothing would be left
229+ } {
230+ title, err := domain.ParseMRTitle(tc.title)
231+ require.NoError(t, err)
232+ assert.Equal(t, tc.draft, domain.MergeRequest{Title: title}.IsDraft(), tc.title)
233+ ready, ok := domain.ReadyTitle(title)
234+ assert.Equal(t, tc.ready != "", ok, tc.title)
235+ assert.Equal(t, tc.ready, ready.String(), tc.title)
236+ }
237+}
@@ -211,3 +211,27 @@ func TestParseBranchName(t *testing.T) {
211 assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", bad)211 assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", bad)
212 }212 }
213 }213 }
214+
215+// A title that starts with "WIP:" or "[WIP]", in any case, is a draft; ready
216+// drops the prefix and the spaces after it.
217+func TestMergeRequestDraft(t *testing.T) {
218+ for _, tc := range []struct {
219+ title, ready string
220+ draft bool
221+ }{
222+ {"WIP: feat: drag and drop", "feat: drag and drop", true},
223+ {"wip:fix", "fix", true},
224+ {" [WIP] tidy", "tidy", true},
225+ {"WIP: [wip] both", "both", true}, // every prefix goes
226+ {"feat: wip: later", "", false},
227+ {"WIPE the cache", "", false},
228+ {"WIP:", "", true}, // a draft, but nothing would be left
229+ } {
230+ title, err := domain.ParseMRTitle(tc.title)
231+ require.NoError(t, err)
232+ assert.Equal(t, tc.draft, domain.MergeRequest{Title: title}.IsDraft(), tc.title)
233+ ready, ok := domain.ReadyTitle(title)
234+ assert.Equal(t, tc.ready != "", ok, tc.title)
235+ assert.Equal(t, tc.ready, ready.String(), tc.title)
236+ }
237+}
M internal/ports/dto.go +1 -1
@@ -141,7 +141,7 @@ type WatchEvent struct {
141141 Gap bool
142142 // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,
143143 // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed,
144- // CommentAdded, RunUpdated or JobUpdated.
144+ // MergeRequestReady, CommentAdded, RunUpdated or JobUpdated.
145145 Event domain.Event
146146 // Repo is the state of the repository at delivery, for RepoCreated and
147147 // RepoUpdated.
@@ -141,7 +141,7 @@ type WatchEvent struct {
141 Gap bool141 Gap bool
142 // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,142 // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,
143 // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed,143 // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed,
144- // CommentAdded, RunUpdated or JobUpdated.144+ // MergeRequestReady, CommentAdded, RunUpdated or JobUpdated.
145 Event domain.Event145 Event domain.Event
146 // Repo is the state of the repository at delivery, for RepoCreated and146 // Repo is the state of the repository at delivery, for RepoCreated and
147 // RepoUpdated.147 // RepoUpdated.
M internal/ports/mocks/MergeRequestService.go +150 -0
@@ -210,6 +210,78 @@ func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Cont
210210 return _c
211211 }
212212
213+// CountOpen provides a mock function for the type MergeRequestService
214+func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
215+ ret := _mock.Called(ctx, p, name)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for CountOpen")
219+ }
220+
221+ var r0 int
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (int, error)); ok {
224+ return returnFunc(ctx, p, name)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) int); ok {
227+ r0 = returnFunc(ctx, p, name)
228+ } else {
229+ r0 = ret.Get(0).(int)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
232+ r1 = returnFunc(ctx, p, name)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// MergeRequestService_CountOpen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountOpen'
240+type MergeRequestService_CountOpen_Call struct {
241+ *mock.Call
242+}
243+
244+// CountOpen is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - name domain.RepoName
248+func (_e *MergeRequestService_Expecter) CountOpen(ctx any, p any, name any) *MergeRequestService_CountOpen_Call {
249+ return &MergeRequestService_CountOpen_Call{Call: _e.mock.On("CountOpen", ctx, p, name)}
250+}
251+
252+func (_c *MergeRequestService_CountOpen_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *MergeRequestService_CountOpen_Call {
253+ _c.Call.Run(func(args mock.Arguments) {
254+ var arg0 context.Context
255+ if args[0] != nil {
256+ arg0 = args[0].(context.Context)
257+ }
258+ var arg1 domain.Principal
259+ if args[1] != nil {
260+ arg1 = args[1].(domain.Principal)
261+ }
262+ var arg2 domain.RepoName
263+ if args[2] != nil {
264+ arg2 = args[2].(domain.RepoName)
265+ }
266+ run(
267+ arg0,
268+ arg1,
269+ arg2,
270+ )
271+ })
272+ return _c
273+}
274+
275+func (_c *MergeRequestService_CountOpen_Call) Return(n int, err error) *MergeRequestService_CountOpen_Call {
276+ _c.Call.Return(n, err)
277+ return _c
278+}
279+
280+func (_c *MergeRequestService_CountOpen_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)) *MergeRequestService_CountOpen_Call {
281+ _c.Call.Return(run)
282+ return _c
283+}
284+
213285 // Create provides a mock function for the type MergeRequestService
214286 func (_mock *MergeRequestService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams) (domain.MergeRequest, error) {
215287 ret := _mock.Called(ctx, p, name, params)
@@ -534,6 +606,84 @@ func (_c *MergeRequestService_MarkRead_Call) RunAndReturn(run func(ctx context.C
534606 return _c
535607 }
536608
609+// MarkReady provides a mock function for the type MergeRequestService
610+func (_mock *MergeRequestService) MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
611+ ret := _mock.Called(ctx, p, name, number)
612+
613+ if len(ret) == 0 {
614+ panic("no return value specified for MarkReady")
615+ }
616+
617+ var r0 domain.MergeRequest
618+ var r1 error
619+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.MergeRequest, error)); ok {
620+ return returnFunc(ctx, p, name, number)
621+ }
622+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.MergeRequest); ok {
623+ r0 = returnFunc(ctx, p, name, number)
624+ } else {
625+ r0 = ret.Get(0).(domain.MergeRequest)
626+ }
627+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
628+ r1 = returnFunc(ctx, p, name, number)
629+ } else {
630+ r1 = ret.Error(1)
631+ }
632+ return r0, r1
633+}
634+
635+// MergeRequestService_MarkReady_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkReady'
636+type MergeRequestService_MarkReady_Call struct {
637+ *mock.Call
638+}
639+
640+// MarkReady is a helper method to define mock.On call
641+// - ctx context.Context
642+// - p domain.Principal
643+// - name domain.RepoName
644+// - number int64
645+func (_e *MergeRequestService_Expecter) MarkReady(ctx any, p any, name any, number any) *MergeRequestService_MarkReady_Call {
646+ return &MergeRequestService_MarkReady_Call{Call: _e.mock.On("MarkReady", ctx, p, name, number)}
647+}
648+
649+func (_c *MergeRequestService_MarkReady_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_MarkReady_Call {
650+ _c.Call.Run(func(args mock.Arguments) {
651+ var arg0 context.Context
652+ if args[0] != nil {
653+ arg0 = args[0].(context.Context)
654+ }
655+ var arg1 domain.Principal
656+ if args[1] != nil {
657+ arg1 = args[1].(domain.Principal)
658+ }
659+ var arg2 domain.RepoName
660+ if args[2] != nil {
661+ arg2 = args[2].(domain.RepoName)
662+ }
663+ var arg3 int64
664+ if args[3] != nil {
665+ arg3 = args[3].(int64)
666+ }
667+ run(
668+ arg0,
669+ arg1,
670+ arg2,
671+ arg3,
672+ )
673+ })
674+ return _c
675+}
676+
677+func (_c *MergeRequestService_MarkReady_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_MarkReady_Call {
678+ _c.Call.Return(mergeRequest, err)
679+ return _c
680+}
681+
682+func (_c *MergeRequestService_MarkReady_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)) *MergeRequestService_MarkReady_Call {
683+ _c.Call.Return(run)
684+ return _c
685+}
686+
537687 // Merge provides a mock function for the type MergeRequestService
538688 func (_mock *MergeRequestService) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
539689 ret := _mock.Called(ctx, p, name, number, params)
@@ -210,6 +210,78 @@ func (_c *MergeRequestService_Close_Call) RunAndReturn(run func(ctx context.Cont
210 return _c210 return _c
211 }211 }
212 212
213+// CountOpen provides a mock function for the type MergeRequestService
214+func (_mock *MergeRequestService) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
215+ ret := _mock.Called(ctx, p, name)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for CountOpen")
219+ }
220+
221+ var r0 int
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) (int, error)); ok {
224+ return returnFunc(ctx, p, name)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName) int); ok {
227+ r0 = returnFunc(ctx, p, name)
228+ } else {
229+ r0 = ret.Get(0).(int)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName) error); ok {
232+ r1 = returnFunc(ctx, p, name)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// MergeRequestService_CountOpen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountOpen'
240+type MergeRequestService_CountOpen_Call struct {
241+ *mock.Call
242+}
243+
244+// CountOpen is a helper method to define mock.On call
245+// - ctx context.Context
246+// - p domain.Principal
247+// - name domain.RepoName
248+func (_e *MergeRequestService_Expecter) CountOpen(ctx any, p any, name any) *MergeRequestService_CountOpen_Call {
249+ return &MergeRequestService_CountOpen_Call{Call: _e.mock.On("CountOpen", ctx, p, name)}
250+}
251+
252+func (_c *MergeRequestService_CountOpen_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName)) *MergeRequestService_CountOpen_Call {
253+ _c.Call.Run(func(args mock.Arguments) {
254+ var arg0 context.Context
255+ if args[0] != nil {
256+ arg0 = args[0].(context.Context)
257+ }
258+ var arg1 domain.Principal
259+ if args[1] != nil {
260+ arg1 = args[1].(domain.Principal)
261+ }
262+ var arg2 domain.RepoName
263+ if args[2] != nil {
264+ arg2 = args[2].(domain.RepoName)
265+ }
266+ run(
267+ arg0,
268+ arg1,
269+ arg2,
270+ )
271+ })
272+ return _c
273+}
274+
275+func (_c *MergeRequestService_CountOpen_Call) Return(n int, err error) *MergeRequestService_CountOpen_Call {
276+ _c.Call.Return(n, err)
277+ return _c
278+}
279+
280+func (_c *MergeRequestService_CountOpen_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)) *MergeRequestService_CountOpen_Call {
281+ _c.Call.Return(run)
282+ return _c
283+}
284+
213 // Create provides a mock function for the type MergeRequestService285 // Create provides a mock function for the type MergeRequestService
214 func (_mock *MergeRequestService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams) (domain.MergeRequest, error) {286 func (_mock *MergeRequestService) Create(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.CreateMergeRequestParams) (domain.MergeRequest, error) {
215 ret := _mock.Called(ctx, p, name, params)287 ret := _mock.Called(ctx, p, name, params)
@@ -534,6 +606,84 @@ func (_c *MergeRequestService_MarkRead_Call) RunAndReturn(run func(ctx context.C
534 return _c606 return _c
535 }607 }
536 608
609+// MarkReady provides a mock function for the type MergeRequestService
610+func (_mock *MergeRequestService) MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
611+ ret := _mock.Called(ctx, p, name, number)
612+
613+ if len(ret) == 0 {
614+ panic("no return value specified for MarkReady")
615+ }
616+
617+ var r0 domain.MergeRequest
618+ var r1 error
619+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.MergeRequest, error)); ok {
620+ return returnFunc(ctx, p, name, number)
621+ }
622+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.MergeRequest); ok {
623+ r0 = returnFunc(ctx, p, name, number)
624+ } else {
625+ r0 = ret.Get(0).(domain.MergeRequest)
626+ }
627+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
628+ r1 = returnFunc(ctx, p, name, number)
629+ } else {
630+ r1 = ret.Error(1)
631+ }
632+ return r0, r1
633+}
634+
635+// MergeRequestService_MarkReady_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MarkReady'
636+type MergeRequestService_MarkReady_Call struct {
637+ *mock.Call
638+}
639+
640+// MarkReady is a helper method to define mock.On call
641+// - ctx context.Context
642+// - p domain.Principal
643+// - name domain.RepoName
644+// - number int64
645+func (_e *MergeRequestService_Expecter) MarkReady(ctx any, p any, name any, number any) *MergeRequestService_MarkReady_Call {
646+ return &MergeRequestService_MarkReady_Call{Call: _e.mock.On("MarkReady", ctx, p, name, number)}
647+}
648+
649+func (_c *MergeRequestService_MarkReady_Call) Run(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64)) *MergeRequestService_MarkReady_Call {
650+ _c.Call.Run(func(args mock.Arguments) {
651+ var arg0 context.Context
652+ if args[0] != nil {
653+ arg0 = args[0].(context.Context)
654+ }
655+ var arg1 domain.Principal
656+ if args[1] != nil {
657+ arg1 = args[1].(domain.Principal)
658+ }
659+ var arg2 domain.RepoName
660+ if args[2] != nil {
661+ arg2 = args[2].(domain.RepoName)
662+ }
663+ var arg3 int64
664+ if args[3] != nil {
665+ arg3 = args[3].(int64)
666+ }
667+ run(
668+ arg0,
669+ arg1,
670+ arg2,
671+ arg3,
672+ )
673+ })
674+ return _c
675+}
676+
677+func (_c *MergeRequestService_MarkReady_Call) Return(mergeRequest domain.MergeRequest, err error) *MergeRequestService_MarkReady_Call {
678+ _c.Call.Return(mergeRequest, err)
679+ return _c
680+}
681+
682+func (_c *MergeRequestService_MarkReady_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)) *MergeRequestService_MarkReady_Call {
683+ _c.Call.Return(run)
684+ return _c
685+}
686+
537 // Merge provides a mock function for the type MergeRequestService687 // Merge provides a mock function for the type MergeRequestService
538 func (_mock *MergeRequestService) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {688 func (_mock *MergeRequestService) Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params ports.MergeParams) (ports.MergeResult, error) {
539 ret := _mock.Called(ctx, p, name, number, params)689 ret := _mock.Called(ctx, p, name, number, params)
M internal/ports/mocks/MergeRequestStore.go +66 -0
@@ -47,6 +47,72 @@ func (_m *MergeRequestStore) EXPECT() *MergeRequestStore_Expecter {
4747 return &MergeRequestStore_Expecter{mock: &_m.Mock}
4848 }
4949
50+// CountOpen provides a mock function for the type MergeRequestStore
51+func (_mock *MergeRequestStore) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {
52+ ret := _mock.Called(ctx, repo)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for CountOpen")
56+ }
57+
58+ var r0 int
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int, error)); ok {
61+ return returnFunc(ctx, repo)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int); ok {
64+ r0 = returnFunc(ctx, repo)
65+ } else {
66+ r0 = ret.Get(0).(int)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
69+ r1 = returnFunc(ctx, repo)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// MergeRequestStore_CountOpen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountOpen'
77+type MergeRequestStore_CountOpen_Call struct {
78+ *mock.Call
79+}
80+
81+// CountOpen is a helper method to define mock.On call
82+// - ctx context.Context
83+// - repo domain.RepoName
84+func (_e *MergeRequestStore_Expecter) CountOpen(ctx any, repo any) *MergeRequestStore_CountOpen_Call {
85+ return &MergeRequestStore_CountOpen_Call{Call: _e.mock.On("CountOpen", ctx, repo)}
86+}
87+
88+func (_c *MergeRequestStore_CountOpen_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *MergeRequestStore_CountOpen_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 domain.RepoName
95+ if args[1] != nil {
96+ arg1 = args[1].(domain.RepoName)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *MergeRequestStore_CountOpen_Call) Return(n int, err error) *MergeRequestStore_CountOpen_Call {
107+ _c.Call.Return(n, err)
108+ return _c
109+}
110+
111+func (_c *MergeRequestStore_CountOpen_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int, error)) *MergeRequestStore_CountOpen_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
115+
50116 // Create provides a mock function for the type MergeRequestStore
51117 func (_mock *MergeRequestStore) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
52118 ret := _mock.Called(ctx, mr)
@@ -47,6 +47,72 @@ func (_m *MergeRequestStore) EXPECT() *MergeRequestStore_Expecter {
47 return &MergeRequestStore_Expecter{mock: &_m.Mock}47 return &MergeRequestStore_Expecter{mock: &_m.Mock}
48 }48 }
49 49
50+// CountOpen provides a mock function for the type MergeRequestStore
51+func (_mock *MergeRequestStore) CountOpen(ctx context.Context, repo domain.RepoName) (int, error) {
52+ ret := _mock.Called(ctx, repo)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for CountOpen")
56+ }
57+
58+ var r0 int
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) (int, error)); ok {
61+ return returnFunc(ctx, repo)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) int); ok {
64+ r0 = returnFunc(ctx, repo)
65+ } else {
66+ r0 = ret.Get(0).(int)
67+ }
68+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
69+ r1 = returnFunc(ctx, repo)
70+ } else {
71+ r1 = ret.Error(1)
72+ }
73+ return r0, r1
74+}
75+
76+// MergeRequestStore_CountOpen_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountOpen'
77+type MergeRequestStore_CountOpen_Call struct {
78+ *mock.Call
79+}
80+
81+// CountOpen is a helper method to define mock.On call
82+// - ctx context.Context
83+// - repo domain.RepoName
84+func (_e *MergeRequestStore_Expecter) CountOpen(ctx any, repo any) *MergeRequestStore_CountOpen_Call {
85+ return &MergeRequestStore_CountOpen_Call{Call: _e.mock.On("CountOpen", ctx, repo)}
86+}
87+
88+func (_c *MergeRequestStore_CountOpen_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *MergeRequestStore_CountOpen_Call {
89+ _c.Call.Run(func(args mock.Arguments) {
90+ var arg0 context.Context
91+ if args[0] != nil {
92+ arg0 = args[0].(context.Context)
93+ }
94+ var arg1 domain.RepoName
95+ if args[1] != nil {
96+ arg1 = args[1].(domain.RepoName)
97+ }
98+ run(
99+ arg0,
100+ arg1,
101+ )
102+ })
103+ return _c
104+}
105+
106+func (_c *MergeRequestStore_CountOpen_Call) Return(n int, err error) *MergeRequestStore_CountOpen_Call {
107+ _c.Call.Return(n, err)
108+ return _c
109+}
110+
111+func (_c *MergeRequestStore_CountOpen_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) (int, error)) *MergeRequestStore_CountOpen_Call {
112+ _c.Call.Return(run)
113+ return _c
114+}
115+
50 // Create provides a mock function for the type MergeRequestStore116 // Create provides a mock function for the type MergeRequestStore
51 func (_mock *MergeRequestStore) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {117 func (_mock *MergeRequestStore) Create(ctx context.Context, mr domain.MergeRequest) (domain.MergeRequest, error) {
52 ret := _mock.Called(ctx, mr)118 ret := _mock.Called(ctx, mr)
M internal/ports/ports.go +3 -0
@@ -248,6 +248,9 @@ type MergeRequestStore interface {
248248 // ListOpen returns the open merge requests of the repository, highest
249249 // number first, reading only the open ones (through an index).
250250 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
251+ // CountOpen returns the number of open merge requests of the repository
252+ // from the index of keys: no request is read.
253+ CountOpen(ctx context.Context, repo domain.RepoName) (int, error)
251254 // OpenBySource returns the numbers of the open merge requests whose source
252255 // branch is one of branches (short names), from an index of keys: a push
253256 // looks up the branches it moved, whatever the number of merge requests the
@@ -248,6 +248,9 @@ type MergeRequestStore interface {
248 // ListOpen returns the open merge requests of the repository, highest248 // ListOpen returns the open merge requests of the repository, highest
249 // number first, reading only the open ones (through an index).249 // number first, reading only the open ones (through an index).
250 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)250 ListOpen(ctx context.Context, repo domain.RepoName) ([]domain.MergeRequest, error)
251+ // CountOpen returns the number of open merge requests of the repository
252+ // from the index of keys: no request is read.
253+ CountOpen(ctx context.Context, repo domain.RepoName) (int, error)
251 // OpenBySource returns the numbers of the open merge requests whose source254 // OpenBySource returns the numbers of the open merge requests whose source
252 // branch is one of branches (short names), from an index of keys: a push255 // branch is one of branches (short names), from an index of keys: a push
253 // looks up the branches it moved, whatever the number of merge requests the256 // looks up the branches it moved, whatever the number of merge requests the
M internal/ports/services.go +5 -0
@@ -114,6 +114,11 @@ type MergeRequestService interface {
114114 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
115115 MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)
116116 Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error)
117+ // MarkReady takes a draft out of draft: its title loses its "WIP:" prefix.
118+ MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
119+ // CountOpen is the number of open merge requests of a repository the
120+ // principal can read. It reads no Git: the tab of every page shows it.
121+ CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
117122 }
118123
119124 // SigningKeyService tells which key signs the commits of the forge.
@@ -114,6 +114,11 @@ type MergeRequestService interface {
114 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)114 Close(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
115 MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)115 MarkRead(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, head domain.ObjectID) (domain.ObjectID, error)
116 Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error)116 Merge(ctx context.Context, p domain.Principal, name domain.RepoName, number int64, params MergeParams) (MergeResult, error)
117+ // MarkReady takes a draft out of draft: its title loses its "WIP:" prefix.
118+ MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error)
119+ // CountOpen is the number of open merge requests of a repository the
120+ // principal can read. It reads no Git: the tab of every page shows it.
121+ CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error)
117 }122 }
118 123
119 // SigningKeyService tells which key signs the commits of the forge.124 // SigningKeyService tells which key signs the commits of the forge.
M internal/usecase/events.go +4 -0
@@ -174,6 +174,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
174174 return repoSnapshot(ev.Visibility, ev.CreatedAt)
175175 case domain.MergeRequestClosed:
176176 return repoSnapshot(ev.Visibility, ev.CreatedAt)
177+ case domain.MergeRequestReady:
178+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
177179 case domain.CommentAdded:
178180 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179181 case domain.RunUpdated:
@@ -481,6 +483,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
481483 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
482484 case domain.MergeRequestClosed:
483485 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
486+ case domain.MergeRequestReady:
487+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
484488 case domain.CommentAdded:
485489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
486490 case domain.RunUpdated:
@@ -174,6 +174,8 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
174 return repoSnapshot(ev.Visibility, ev.CreatedAt)174 return repoSnapshot(ev.Visibility, ev.CreatedAt)
175 case domain.MergeRequestClosed:175 case domain.MergeRequestClosed:
176 return repoSnapshot(ev.Visibility, ev.CreatedAt)176 return repoSnapshot(ev.Visibility, ev.CreatedAt)
177+ case domain.MergeRequestReady:
178+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
177 case domain.CommentAdded:179 case domain.CommentAdded:
178 return repoSnapshot(ev.Visibility, ev.CreatedAt)180 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179 case domain.RunUpdated:181 case domain.RunUpdated:
@@ -481,6 +483,8 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
481 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)483 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
482 case domain.MergeRequestClosed:484 case domain.MergeRequestClosed:
483 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)485 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
486+ case domain.MergeRequestReady:
487+ return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
484 case domain.CommentAdded:488 case domain.CommentAdded:
485 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)489 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
486 case domain.RunUpdated:490 case domain.RunUpdated:
M internal/usecase/mergerequests.go +55 -0
@@ -333,6 +333,58 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai
333333 return page, nil
334334 }
335335
336+// MarkReady implements ports.MergeRequestService: the title of an open draft
337+// loses its draft prefix, so the request can be merged. The principal needs
338+// mr:write. A request that is not open, or not a draft, gives
339+// domain.ErrPreconditionFailed.
340+func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
341+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
342+ if err != nil {
343+ return domain.MergeRequest{}, err
344+ }
345+ unlock, err := m.lockRepo(ctx, name) // a merge sees the request as it is after
346+ if err != nil {
347+ return domain.MergeRequest{}, err
348+ }
349+ defer unlock()
350+ for range maxWriteRetries {
351+ mr, err := m.store.Get(ctx, name, number)
352+ if err != nil {
353+ return domain.MergeRequest{}, err
354+ }
355+ if !mr.IsOpen() {
356+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
357+ }
358+ title, ok := domain.ReadyTitle(mr.Title)
359+ if !ok {
360+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is not a draft", domain.ErrPreconditionFailed, number)
361+ }
362+ mr.Title, mr.UpdatedAt = title, m.clock.Now().UTC()
363+ err = m.store.Update(ctx, mr)
364+ if errors.Is(err, domain.ErrConflict) {
365+ continue
366+ }
367+ if err != nil {
368+ return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
369+ }
370+ m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
371+ return m.withHead(ctx, mr)
372+ }
373+ return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
374+}
375+
376+// CountOpen implements ports.MergeRequestService.
377+func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
378+ if _, err := m.readable(ctx, p, name); err != nil {
379+ return 0, err
380+ }
381+ n, err := m.store.CountOpen(ctx, name)
382+ if err != nil {
383+ return 0, fmt.Errorf("count open merge requests of %q: %w", name, err)
384+ }
385+ return n, nil
386+}
387+
336388 func encodeMRToken(last domain.MergeRequest) string {
337389 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
338390 }
@@ -750,6 +802,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
750802 if !mr.IsOpen() {
751803 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
752804 }
805+ if mr.IsDraft() {
806+ return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
807+ }
753808 // The settings are read again under the lock: a change applies at once.
754809 repo, err = m.meta.Get(ctx, name)
755810 if err != nil {
@@ -333,6 +333,58 @@ func (m *MergeRequests) List(ctx context.Context, p domain.Principal, name domai
333 return page, nil333 return page, nil
334 }334 }
335 335
336+// MarkReady implements ports.MergeRequestService: the title of an open draft
337+// loses its draft prefix, so the request can be merged. The principal needs
338+// mr:write. A request that is not open, or not a draft, gives
339+// domain.ErrPreconditionFailed.
340+func (m *MergeRequests) MarkReady(ctx context.Context, p domain.Principal, name domain.RepoName, number int64) (domain.MergeRequest, error) {
341+ repo, err := m.writable(ctx, p, name, domain.ScopeMRWrite)
342+ if err != nil {
343+ return domain.MergeRequest{}, err
344+ }
345+ unlock, err := m.lockRepo(ctx, name) // a merge sees the request as it is after
346+ if err != nil {
347+ return domain.MergeRequest{}, err
348+ }
349+ defer unlock()
350+ for range maxWriteRetries {
351+ mr, err := m.store.Get(ctx, name, number)
352+ if err != nil {
353+ return domain.MergeRequest{}, err
354+ }
355+ if !mr.IsOpen() {
356+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
357+ }
358+ title, ok := domain.ReadyTitle(mr.Title)
359+ if !ok {
360+ return domain.MergeRequest{}, fmt.Errorf("%w: !%d is not a draft", domain.ErrPreconditionFailed, number)
361+ }
362+ mr.Title, mr.UpdatedAt = title, m.clock.Now().UTC()
363+ err = m.store.Update(ctx, mr)
364+ if errors.Is(err, domain.ErrConflict) {
365+ continue
366+ }
367+ if err != nil {
368+ return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, err)
369+ }
370+ m.bus.Publish(ctx, domain.MergeRequestReady{MergeRequest: mr, At: mr.UpdatedAt, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
371+ return m.withHead(ctx, mr)
372+ }
373+ return domain.MergeRequest{}, fmt.Errorf("mark !%d of %q ready: %w", number, name, domain.ErrConflict)
374+}
375+
376+// CountOpen implements ports.MergeRequestService.
377+func (m *MergeRequests) CountOpen(ctx context.Context, p domain.Principal, name domain.RepoName) (int, error) {
378+ if _, err := m.readable(ctx, p, name); err != nil {
379+ return 0, err
380+ }
381+ n, err := m.store.CountOpen(ctx, name)
382+ if err != nil {
383+ return 0, fmt.Errorf("count open merge requests of %q: %w", name, err)
384+ }
385+ return n, nil
386+}
387+
336 func encodeMRToken(last domain.MergeRequest) string {388 func encodeMRToken(last domain.MergeRequest) string {
337 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))389 return base64.RawURLEncoding.EncodeToString([]byte(mrTokenPrefix + strconv.FormatInt(last.Number, 10)))
338 }390 }
@@ -750,6 +802,9 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
750 if !mr.IsOpen() {802 if !mr.IsOpen() {
751 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)803 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
752 }804 }
805+ if mr.IsDraft() {
806+ return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is a draft: mark it ready for review first", domain.ErrPreconditionFailed, number)
807+ }
753 // The settings are read again under the lock: a change applies at once.808 // The settings are read again under the lock: a change applies at once.
754 repo, err = m.meta.Get(ctx, name)809 repo, err = m.meta.Get(ctx, name)
755 if err != nil {810 if err != nil {
M internal/usecase/mergerequests_integration_test.go +37 -0
@@ -889,6 +889,43 @@ func TestMergeFastForward(t *testing.T) {
889889 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
890890 }
891891
892+// A draft is not merged until it is marked ready; ready drops the prefix of
893+// the title, and only for an open draft. CountOpen counts drafts as open.
894+func TestDraftMergeRequest(t *testing.T) {
895+ r := newMRRig(t)
896+ r.seed()
897+ head := r.feature("feat")
898+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "WIP: add feat", Source: "feat"})
899+ require.NoError(t, err)
900+
901+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
902+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
903+ assert.Contains(t, err.Error(), "draft")
904+
905+ n, err := r.mrs.CountOpen(r.ctx, reader, r.repo)
906+ require.NoError(t, err)
907+ assert.Equal(t, 1, n)
908+
909+ _, err = r.mrs.MarkReady(r.ctx, reader, r.repo, mr.Number)
910+ require.Error(t, err, "mr:write is needed")
911+ r.drain()
912+ ready, err := r.mrs.MarkReady(r.ctx, mrW, r.repo, mr.Number)
913+ require.NoError(t, err)
914+ assert.Equal(t, "add feat", ready.Title.String())
915+ assert.False(t, ready.IsDraft())
916+ evs := r.drain()
917+ require.Len(t, evs, 1, "live pages learn the request is ready")
918+ assert.Equal(t, "add feat", evs[0].(domain.MergeRequestReady).MergeRequest.Title.String())
919+ _, err = r.mrs.MarkReady(r.ctx, mrW, r.repo, mr.Number)
920+ require.ErrorIs(t, err, domain.ErrPreconditionFailed, "not a draft any more")
921+
922+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
923+ require.NoError(t, err)
924+ n, err = r.mrs.CountOpen(r.ctx, reader, r.repo)
925+ require.NoError(t, err)
926+ assert.Zero(t, n)
927+}
928+
892929 // AC4: the checks of a merge.
893930 func TestMergeRefusals(t *testing.T) {
894931 t.Run("a branch that moved since the review", func(t *testing.T) {
@@ -889,6 +889,43 @@ func TestMergeFastForward(t *testing.T) {
889 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)889 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
890 }890 }
891 891
892+// A draft is not merged until it is marked ready; ready drops the prefix of
893+// the title, and only for an open draft. CountOpen counts drafts as open.
894+func TestDraftMergeRequest(t *testing.T) {
895+ r := newMRRig(t)
896+ r.seed()
897+ head := r.feature("feat")
898+ mr, err := r.mrs.Create(r.ctx, root, r.repo, ports.CreateMergeRequestParams{Title: "WIP: add feat", Source: "feat"})
899+ require.NoError(t, err)
900+
901+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
902+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
903+ assert.Contains(t, err.Error(), "draft")
904+
905+ n, err := r.mrs.CountOpen(r.ctx, reader, r.repo)
906+ require.NoError(t, err)
907+ assert.Equal(t, 1, n)
908+
909+ _, err = r.mrs.MarkReady(r.ctx, reader, r.repo, mr.Number)
910+ require.Error(t, err, "mr:write is needed")
911+ r.drain()
912+ ready, err := r.mrs.MarkReady(r.ctx, mrW, r.repo, mr.Number)
913+ require.NoError(t, err)
914+ assert.Equal(t, "add feat", ready.Title.String())
915+ assert.False(t, ready.IsDraft())
916+ evs := r.drain()
917+ require.Len(t, evs, 1, "live pages learn the request is ready")
918+ assert.Equal(t, "add feat", evs[0].(domain.MergeRequestReady).MergeRequest.Title.String())
919+ _, err = r.mrs.MarkReady(r.ctx, mrW, r.repo, mr.Number)
920+ require.ErrorIs(t, err, domain.ErrPreconditionFailed, "not a draft any more")
921+
922+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
923+ require.NoError(t, err)
924+ n, err = r.mrs.CountOpen(r.ctx, reader, r.repo)
925+ require.NoError(t, err)
926+ assert.Zero(t, n)
927+}
928+
892 // AC4: the checks of a merge.929 // AC4: the checks of a merge.
893 func TestMergeRefusals(t *testing.T) {930 func TestMergeRefusals(t *testing.T) {
894 t.Run("a branch that moved since the review", func(t *testing.T) {931 t.Run("a branch that moved since the review", func(t *testing.T) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 41

↑↓ moveenter openesc close