detached 706d0b6

Merge 453499267f41ae8b3e4bd97a7445ec1c9de27425 into 70c00ad2d5a23676a0759d7ae2dcab5e5dcd4425

commit
706d0b61b508f5c0485934d8dd848119ff88e380
author
ermite · · 2026-10-19 19:24 UTC
merge of
70c00ad + 4534992 · diff against the first parent
signature
unsigned
8 files · +213 -45
M internal/adapters/web/actions.templ +3 -0
@@ -456,6 +456,9 @@ templ MRChecks(c ChecksSection) {
456456 } else {
457457 <ul class="checks">
458458 for _, r := range c.Rows {
459+ if r.GroupLabel != "" {
460+ <li class="ck-group"><a href={ templ.SafeURL(r.GroupHref) }>{ r.GroupLabel }</a></li>
461+ }
459462 <li>
460463 @CheckIcon(r.Kind, r.State)
461464 if r.Href != "" {
@@ -456,6 +456,9 @@ templ MRChecks(c ChecksSection) {
456 } else {456 } else {
457 <ul class="checks">457 <ul class="checks">
458 for _, r := range c.Rows {458 for _, r := range c.Rows {
459+ if r.GroupLabel != "" {
460+ <li class="ck-group"><a href={ templ.SafeURL(r.GroupHref) }>{ r.GroupLabel }</a></li>
461+ }
459 <li>462 <li>
460 @CheckIcon(r.Kind, r.State)463 @CheckIcon(r.Kind, r.State)
461 if r.Href != "" {464 if r.Href != "" {
M internal/adapters/web/actions_templ.go +66 -34 generated file: collapsed
M internal/adapters/web/checks.go +38 -10
@@ -25,6 +25,7 @@ type ChecksSummary struct {
2525 type CheckRow struct {
2626 Name, Kind, State, Href string
2727 Waiting bool
28+ GroupLabel, GroupHref string
2829 }
2930
3031 // ChecksSection is the checks of the head of a merge request.
@@ -82,17 +83,28 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C
8283 // the repository that applies to them.
8384 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
8485 sec := ChecksSection{}
85- for _, c := range checks {
86- row := CheckRow{
87- Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
88- Waiting: c.WaitingForRunner,
89- }
90- if c.JobID != "" {
91- row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
92- } else {
93- row.Href = runHref(repo.Name.String(), c.RunID)
86+ groups := checksByRun(checks)
87+ for _, group := range groups {
88+ for i, c := range group {
89+ row := CheckRow{
90+ Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
91+ Waiting: c.WaitingForRunner,
92+ }
93+ if c.JobID != "" {
94+ row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
95+ } else {
96+ row.Href = runHref(repo.Name.String(), c.RunID)
97+ }
98+ if len(groups) > 1 && i == 0 {
99+ event := visibleText(c.Event)
100+ if event == "" {
101+ event = fmt.Sprintf("run %d", c.RunID)
102+ }
103+ row.GroupLabel = visibleText(c.WorkflowName) + " · " + event
104+ row.GroupHref = runHref(repo.Name.String(), c.RunID)
105+ }
106+ sec.Rows = append(sec.Rows, row)
94107 }
95- sec.Rows = append(sec.Rows, row)
96108 }
97109 switch {
98110 case len(checks) == 0:
@@ -104,3 +116,19 @@ func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
104116 }
105117 return sec
106118 }
119+
120+// checksByRun keeps first-appearance order and the check order within each run.
121+func checksByRun(checks []ports.CheckView) [][]ports.CheckView {
122+ groups := make([][]ports.CheckView, 0)
123+ indices := make(map[int64]int)
124+ for _, c := range checks {
125+ i, ok := indices[c.RunID]
126+ if !ok {
127+ i = len(groups)
128+ indices[c.RunID] = i
129+ groups = append(groups, nil)
130+ }
131+ groups[i] = append(groups[i], c)
132+ }
133+ return groups
134+}
@@ -25,6 +25,7 @@ type ChecksSummary struct {
25 type CheckRow struct {25 type CheckRow struct {
26 Name, Kind, State, Href string26 Name, Kind, State, Href string
27 Waiting bool27 Waiting bool
28+ GroupLabel, GroupHref string
28 }29 }
29 30
30 // ChecksSection is the checks of the head of a merge request.31 // ChecksSection is the checks of the head of a merge request.
@@ -82,17 +83,28 @@ func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) C
82 // the repository that applies to them.83 // the repository that applies to them.
83 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {84 func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
84 sec := ChecksSection{}85 sec := ChecksSection{}
85- for _, c := range checks {86+ groups := checksByRun(checks)
86- row := CheckRow{87+ for _, group := range groups {
87- Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),88+ for i, c := range group {
88- Waiting: c.WaitingForRunner,89+ row := CheckRow{
89- }90+ Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
90- if c.JobID != "" {91+ Waiting: c.WaitingForRunner,
91- row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)92+ }
92- } else {93+ if c.JobID != "" {
93- row.Href = runHref(repo.Name.String(), c.RunID)94+ row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
95+ } else {
96+ row.Href = runHref(repo.Name.String(), c.RunID)
97+ }
98+ if len(groups) > 1 && i == 0 {
99+ event := visibleText(c.Event)
100+ if event == "" {
101+ event = fmt.Sprintf("run %d", c.RunID)
102+ }
103+ row.GroupLabel = visibleText(c.WorkflowName) + " · " + event
104+ row.GroupHref = runHref(repo.Name.String(), c.RunID)
105+ }
106+ sec.Rows = append(sec.Rows, row)
94 }107 }
95- sec.Rows = append(sec.Rows, row)
96 }108 }
97 switch {109 switch {
98 case len(checks) == 0:110 case len(checks) == 0:
@@ -104,3 +116,19 @@ func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
104 }116 }
105 return sec117 return sec
106 }118 }
119+
120+// checksByRun keeps first-appearance order and the check order within each run.
121+func checksByRun(checks []ports.CheckView) [][]ports.CheckView {
122+ groups := make([][]ports.CheckView, 0)
123+ indices := make(map[int64]int)
124+ for _, c := range checks {
125+ i, ok := indices[c.RunID]
126+ if !ok {
127+ i = len(groups)
128+ indices[c.RunID] = i
129+ groups = append(groups, nil)
130+ }
131+ groups[i] = append(groups[i], c)
132+ }
133+ return groups
134+}
A internal/adapters/web/checks_test.go +49 -0
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "context"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+func TestChecksSectionRuns(t *testing.T) {
18+ t.Parallel()
19+ repo := domain.Repo{Name: repoName(t, "proj")}
20+ check := func(run int64, name, event string) ports.CheckView {
21+ return ports.CheckView{CICheck: domain.CICheck{RunID: run, Name: name, WorkflowName: "Smoke", Status: domain.CICompleted, Conclusion: domain.CISuccess}, Event: event}
22+ }
23+ a, b, c := check(7, "build", "push"), check(8, "build", "pull_request"), check(7, "test", "push")
24+ single := checksSection(repo, []ports.CheckView{a, c})
25+ require.Len(t, single.Rows, 2)
26+ assert.Equal(t, []CheckRow{
27+ {Name: "build", Kind: "ok", State: "success", Href: runHref("proj", 7)},
28+ {Name: "test", Kind: "ok", State: "success", Href: runHref("proj", 7)},
29+ }, single.Rows)
30+ var flat bytes.Buffer
31+ require.NoError(t, MRChecks(single).Render(context.Background(), &flat))
32+ assert.NotContains(t, flat.String(), "ck-group")
33+
34+ multi := checksSection(repo, []ports.CheckView{a, b, c})
35+ require.Len(t, multi.Rows, 3)
36+ assert.Equal(t, "Smoke · push", multi.Rows[0].GroupLabel)
37+ assert.Equal(t, "test", multi.Rows[1].Name)
38+ assert.Empty(t, multi.Rows[1].GroupLabel)
39+ assert.Equal(t, "Smoke · pull_request", multi.Rows[2].GroupLabel)
40+ assert.Equal(t, runHref("proj", 8), multi.Rows[2].GroupHref)
41+ var html bytes.Buffer
42+ require.NoError(t, MRChecks(multi).Render(context.Background(), &html))
43+ assert.Contains(t, html.String(), `Checks <span class="count">3</span>`)
44+ assert.Contains(t, html.String(), `<li class="ck-group"><a href="`+runHref("proj", 7)+`">Smoke · push</a></li>`)
45+ t.Log(html.String())
46+ b.Event = ""
47+ fallback := checksSection(repo, []ports.CheckView{a, b})
48+ assert.Equal(t, "Smoke · run 8", fallback.Rows[1].GroupLabel)
49+}
@@ -0,0 +1,49 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bytes"
7+ "context"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+)
16+
17+func TestChecksSectionRuns(t *testing.T) {
18+ t.Parallel()
19+ repo := domain.Repo{Name: repoName(t, "proj")}
20+ check := func(run int64, name, event string) ports.CheckView {
21+ return ports.CheckView{CICheck: domain.CICheck{RunID: run, Name: name, WorkflowName: "Smoke", Status: domain.CICompleted, Conclusion: domain.CISuccess}, Event: event}
22+ }
23+ a, b, c := check(7, "build", "push"), check(8, "build", "pull_request"), check(7, "test", "push")
24+ single := checksSection(repo, []ports.CheckView{a, c})
25+ require.Len(t, single.Rows, 2)
26+ assert.Equal(t, []CheckRow{
27+ {Name: "build", Kind: "ok", State: "success", Href: runHref("proj", 7)},
28+ {Name: "test", Kind: "ok", State: "success", Href: runHref("proj", 7)},
29+ }, single.Rows)
30+ var flat bytes.Buffer
31+ require.NoError(t, MRChecks(single).Render(context.Background(), &flat))
32+ assert.NotContains(t, flat.String(), "ck-group")
33+
34+ multi := checksSection(repo, []ports.CheckView{a, b, c})
35+ require.Len(t, multi.Rows, 3)
36+ assert.Equal(t, "Smoke · push", multi.Rows[0].GroupLabel)
37+ assert.Equal(t, "test", multi.Rows[1].Name)
38+ assert.Empty(t, multi.Rows[1].GroupLabel)
39+ assert.Equal(t, "Smoke · pull_request", multi.Rows[2].GroupLabel)
40+ assert.Equal(t, runHref("proj", 8), multi.Rows[2].GroupHref)
41+ var html bytes.Buffer
42+ require.NoError(t, MRChecks(multi).Render(context.Background(), &html))
43+ assert.Contains(t, html.String(), `Checks <span class="count">3</span>`)
44+ assert.Contains(t, html.String(), `<li class="ck-group"><a href="`+runHref("proj", 7)+`">Smoke · push</a></li>`)
45+ t.Log(html.String())
46+ b.Event = ""
47+ fallback := checksSection(repo, []ports.CheckView{a, b})
48+ assert.Equal(t, "Smoke · run 8", fallback.Rows[1].GroupLabel)
49+}
M internal/adapters/web/static/css/65-ci.css +3 -0
@@ -60,6 +60,9 @@
6060 .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
6161 .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }
6262 .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
63+ .checks .ck-group { font-size: var(--fs-xs); color: var(--ink-dim); margin-top: var(--s2); }
64+ .checks .ck-group:first-child { margin-top: 0; }
65+ .checks .ck-group a { color: inherit; }
6366 /* the merge rule under the checks: a note about them, not one of them */
6467 .rail section > .rule-note {
6568 display: flex; gap: 6px; margin-top: var(--s3); padding-top: var(--s2); border-top: 1px dashed var(--border);
@@ -60,6 +60,9 @@
60 .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }60 .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
61 .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }61 .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }
62 .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }62 .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
63+ .checks .ck-group { font-size: var(--fs-xs); color: var(--ink-dim); margin-top: var(--s2); }
64+ .checks .ck-group:first-child { margin-top: 0; }
65+ .checks .ck-group a { color: inherit; }
63 /* the merge rule under the checks: a note about them, not one of them */66 /* the merge rule under the checks: a note about them, not one of them */
64 .rail section > .rule-note {67 .rail section > .rule-note {
65 display: flex; gap: 6px; margin-top: var(--s3); padding-top: var(--s2); border-top: 1px dashed var(--border);68 display: flex; gap: 6px; margin-top: var(--s3); padding-top: var(--s2); border-top: 1px dashed var(--border);
M internal/ports/ci.go +2 -0
@@ -695,6 +695,8 @@ type GetJobLogParams struct {
695695 // CheckView is a check with what owners need to read it.
696696 type CheckView struct {
697697 domain.CICheck
698+ // Event is the event of the run of the check (push, pull_request, ...), empty if unknown.
699+ Event string
698700 // WaitingForRunner is set for a queued check whose job no registered
699701 // runner can take (no runner has all its labels).
700702 WaitingForRunner bool
@@ -695,6 +695,8 @@ type GetJobLogParams struct {
695 // CheckView is a check with what owners need to read it.695 // CheckView is a check with what owners need to read it.
696 type CheckView struct {696 type CheckView struct {
697 domain.CICheck697 domain.CICheck
698+ // Event is the event of the run of the check (push, pull_request, ...), empty if unknown.
699+ Event string
698 // WaitingForRunner is set for a queued check whose job no registered700 // WaitingForRunner is set for a queued check whose job no registered
699 // runner can take (no runner has all its labels).701 // runner can take (no runner has all its labels).
700 WaitingForRunner bool702 WaitingForRunner bool
M internal/usecase/ci_runner_unit_test.go +39 -0
@@ -421,3 +421,42 @@ func TestLogLimitEndsTheJob(t *testing.T) {
421421 assert.Equal(t, domain.CIFailure, written.Jobs[0].Conclusion)
422422 assert.Equal(t, domain.CIStepCompleted, written.Jobs[0].Steps[0].State, "an open step is completed with the job")
423423 }
424+
425+func TestListChecksRunEvents(t *testing.T) {
426+ t.Parallel()
427+ for _, tc := range []struct {
428+ name string
429+ err error
430+ }{
431+ {name: "events"}, {name: "pruned", err: domain.ErrNotFound}, {name: "store error", err: errors.New("store unavailable")},
432+ } {
433+ t.Run(tc.name, func(t *testing.T) {
434+ t.Parallel()
435+ u := newCIUnit(t, nil)
436+ repo := name(t, "proj")
437+ sha := "0123456789012345678901234567890123456789"
438+ u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
439+ u.store.EXPECT().ListChecks(mock.Anything, repo, sha, usecase.MaxCIChecks).Return([]domain.CICheck{
440+ {RunID: 7, Status: domain.CICompleted}, {RunID: 8, Status: domain.CICompleted}, {RunID: 7, Status: domain.CICompleted},
441+ }, false, nil).Once()
442+ u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Event: "push"}, tc.err).Once()
443+ if tc.err == nil || errors.Is(tc.err, domain.ErrNotFound) {
444+ u.store.EXPECT().GetRun(mock.Anything, repo, int64(8)).Return(domain.CIRun{Event: "pull_request"}, nil).Once()
445+ }
446+ res, err := u.ci.ListChecks(context.Background(), domain.Anonymous(), repo, sha)
447+ if tc.err != nil && !errors.Is(tc.err, domain.ErrNotFound) {
448+ require.ErrorIs(t, err, tc.err)
449+ return
450+ }
451+ require.NoError(t, err)
452+ require.Len(t, res.Checks, 3)
453+ event := "push"
454+ if tc.err != nil {
455+ event = ""
456+ }
457+ assert.Equal(t, event, res.Checks[0].Event)
458+ assert.Equal(t, "pull_request", res.Checks[1].Event)
459+ assert.Equal(t, event, res.Checks[2].Event)
460+ })
461+ }
462+}
@@ -421,3 +421,42 @@ func TestLogLimitEndsTheJob(t *testing.T) {
421 assert.Equal(t, domain.CIFailure, written.Jobs[0].Conclusion)421 assert.Equal(t, domain.CIFailure, written.Jobs[0].Conclusion)
422 assert.Equal(t, domain.CIStepCompleted, written.Jobs[0].Steps[0].State, "an open step is completed with the job")422 assert.Equal(t, domain.CIStepCompleted, written.Jobs[0].Steps[0].State, "an open step is completed with the job")
423 }423 }
424+
425+func TestListChecksRunEvents(t *testing.T) {
426+ t.Parallel()
427+ for _, tc := range []struct {
428+ name string
429+ err error
430+ }{
431+ {name: "events"}, {name: "pruned", err: domain.ErrNotFound}, {name: "store error", err: errors.New("store unavailable")},
432+ } {
433+ t.Run(tc.name, func(t *testing.T) {
434+ t.Parallel()
435+ u := newCIUnit(t, nil)
436+ repo := name(t, "proj")
437+ sha := "0123456789012345678901234567890123456789"
438+ u.meta.EXPECT().Get(mock.Anything, repo).Return(domain.Repo{Name: repo, Visibility: domain.VisibilityPublic}, nil)
439+ u.store.EXPECT().ListChecks(mock.Anything, repo, sha, usecase.MaxCIChecks).Return([]domain.CICheck{
440+ {RunID: 7, Status: domain.CICompleted}, {RunID: 8, Status: domain.CICompleted}, {RunID: 7, Status: domain.CICompleted},
441+ }, false, nil).Once()
442+ u.store.EXPECT().GetRun(mock.Anything, repo, int64(7)).Return(domain.CIRun{Event: "push"}, tc.err).Once()
443+ if tc.err == nil || errors.Is(tc.err, domain.ErrNotFound) {
444+ u.store.EXPECT().GetRun(mock.Anything, repo, int64(8)).Return(domain.CIRun{Event: "pull_request"}, nil).Once()
445+ }
446+ res, err := u.ci.ListChecks(context.Background(), domain.Anonymous(), repo, sha)
447+ if tc.err != nil && !errors.Is(tc.err, domain.ErrNotFound) {
448+ require.ErrorIs(t, err, tc.err)
449+ return
450+ }
451+ require.NoError(t, err)
452+ require.Len(t, res.Checks, 3)
453+ event := "push"
454+ if tc.err != nil {
455+ event = ""
456+ }
457+ assert.Equal(t, event, res.Checks[0].Event)
458+ assert.Equal(t, "pull_request", res.Checks[1].Event)
459+ assert.Equal(t, event, res.Checks[2].Event)
460+ })
461+ }
462+}
M internal/usecase/ci_runs.go +13 -1
@@ -519,8 +519,20 @@ func (c *CI) ListChecks(ctx context.Context, p domain.Principal, name domain.Rep
519519 }
520520 res := ports.ChecksResult{Truncated: more}
521521 var runners []domain.CIRunner
522+ events := make(map[int64]string)
522523 for _, ck := range checks {
523- v := ports.CheckView{CICheck: ck}
524+ event, ok := events[ck.RunID]
525+ if !ok {
526+ run, err := c.cfg.Store.GetRun(ctx, name, ck.RunID)
527+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
528+ return ports.ChecksResult{}, err
529+ }
530+ if err == nil {
531+ event = run.Event
532+ }
533+ events[ck.RunID] = event
534+ }
535+ v := ports.CheckView{CICheck: ck, Event: event}
524536 if ck.Status == domain.CIQueued {
525537 if runners == nil {
526538 runners, _ = c.cfg.Store.ListRunners(ctx)
@@ -519,8 +519,20 @@ func (c *CI) ListChecks(ctx context.Context, p domain.Principal, name domain.Rep
519 }519 }
520 res := ports.ChecksResult{Truncated: more}520 res := ports.ChecksResult{Truncated: more}
521 var runners []domain.CIRunner521 var runners []domain.CIRunner
522+ events := make(map[int64]string)
522 for _, ck := range checks {523 for _, ck := range checks {
523- v := ports.CheckView{CICheck: ck}524+ event, ok := events[ck.RunID]
525+ if !ok {
526+ run, err := c.cfg.Store.GetRun(ctx, name, ck.RunID)
527+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
528+ return ports.ChecksResult{}, err
529+ }
530+ if err == nil {
531+ event = run.Event
532+ }
533+ events[ck.RunID] = event
534+ }
535+ v := ports.CheckView{CICheck: ck, Event: event}
524 if ck.Status == domain.CIQueued {536 if ck.Status == domain.CIQueued {
525 if runners == nil {537 if runners == nil {
526 runners, _ = c.cfg.Store.ListRunners(ctx)538 runners, _ = c.cfg.Store.ListRunners(ctx)

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 45

↑↓ moveenter openesc close