Commit 982171c76d

982171c76db8043a23afdfba7776803cfa233f02

parent: f8cd1cbd69

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-18 19:31 UTC

dashboard: build events fold into runs per commit, worst status emphasised

Build events now record the commit sha; feedLines folds adjacent
build events on the same commit into one "ran N jobs on" line, whose
State is the worst of the folded jobs' outcomes via a new worstStatus
helper factored out of the builds tab's combinedStatus. The dashboard
template marks the line with a status dot (bad/ok/pend) matching the
builds tab's own colors (D04).

Ref #222
internal/control/build.go +3 −3
@@ -593,7 +593,7 @@ func runRunnerDone(c *Ctx, args []string) int {
593593 return c.fail(protocol.ExitFailure, "%v", err)
594594 }
595595 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+args[1],
596 fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
596 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
597597 // A red build mails the repo's notify targets with the log tail — a
598598 // failed scheduled job must not wait to be noticed.
599599 if args[1] == "failure" {
@@ -829,7 +829,7 @@ func cancelOrphanedBuild(c *Ctx, repo store.Repo, b store.Build) int {
829829 c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf(
830830 "cancelled: %.10s is not reachable from any ref; the sha was likely orphaned by a force-push\n", b.SHA)))
831831 resolveCancelledCommitStatus(c, repo, b)
832 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
832 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
833833 return -1
834834}
835835
@@ -858,7 +858,7 @@ func runBuildCancel(c *Ctx, args []string) int {
858858 }
859859 // The queued status replaced whatever the commit had for this job.
860860 resolveCancelledCommitStatus(c, repo, b)
861 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q}`, b.Number, b.Job))
861 c.Store.RecordEvent(repo.ID, c.User.ID, "build.cancelled", fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
862862 return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) {
863863 if b.Status == "running" {
864864 fmt.Fprintf(w, "cancelled %s build %d (%s); the runner stops at its next check\n", repo.Path(), b.Number, b.Job)
internal/httpd/builds.go +13 −2
@@ -99,9 +99,20 @@ var runStatusPriority = []string{"failure", "cancelled", "running", "pending"}
9999// combinedStatus is the run's status: the worst of its builds' statuses,
100100// success only when every one of them is.
101101func combinedStatus(builds []control.BuildOut) string {
102 statuses := make([]string, len(builds))
103 for i, b := range builds {
104 statuses[i] = b.Status
105 }
106 return worstStatus(statuses)
107}
108
109// worstStatus is combinedStatus's ordering rule, factored out so the
110// dashboard feed can apply the same worst-first precedence to a folded
111// build run (D04).
112func worstStatus(statuses []string) string {
102113 has := map[string]bool{}
103 for _, b := range builds {
104 has[b.Status] = true
114 for _, s := range statuses {
115 has[s] = true
105116 }
106117 for _, s := range runStatusPriority {
107118 if has[s] {
internal/httpd/feed.go +33 −4
@@ -11,30 +11,52 @@ import (
1111// feedLine is one activity entry, already phrased and linked.
1212type feedLine struct {
1313 Actor string
14 Verb string // "opened issue", "merged"
15 Ref string // "#12", "!35", "v0.4.0"
14 Verb string // "opened issue", "merged", "ran 2 jobs on"
15 Ref string // "#12", "!35", "v0.4.0", a short sha
1616 Repo string
1717 URL string
18 When string
18 When string // the stored timestamp, for anything still reading it raw
19 State string // a build run's combined status; empty for anything else
20 Jobs []string // job names folded into a build run
21 sha string // the commit a build event fired on, for fold-matching
1922}
2023
2124// feedLines turns stored events into readable lines. An unknown kind
2225// still shows: the feed says what happened even for events added later.
26// Build events on the same commit, adjacent in the input, fold into one
27// "run" line (D04): its State is the worst of the folded jobs' outcomes,
28// via worstStatus — the same rule the builds tab uses for a run's status.
2329func feedLines(events []store.FeedEvent) []feedLine {
2430 out := make([]feedLine, 0, len(events))
31 statuses := make([][]string, 0, len(events))
2532 for _, e := range events {
2633 var d struct {
2734 Number int64 `json:"number"`
2835 Job string `json:"job"`
2936 Tag string `json:"tag"`
37 SHA string `json:"sha"`
3038 }
3139 json.Unmarshal([]byte(e.Data), &d)
40 kind, rest, _ := strings.Cut(e.Kind, ".")
41
42 if kind == "build" && d.SHA != "" {
43 if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath {
44 i := n - 1
45 out[i].Jobs = append(out[i].Jobs, d.Job)
46 statuses[i] = append(statuses[i], rest)
47 out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs))
48 out[i].Ref = fmt.Sprintf("%.10s", d.SHA)
49 out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA)
50 out[i].State = worstStatus(statuses[i])
51 continue
52 }
53 }
3254
3355 l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt}
3456 if l.Actor == "" {
3557 l.Actor = "gitbay"
3658 }
37 kind, rest, _ := strings.Cut(e.Kind, ".")
59 var st []string
3860 switch kind {
3961 case "issue":
4062 l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number)
@@ -45,6 +67,12 @@ func feedLines(events []store.FeedEvent) []feedLine {
4567 case "build":
4668 l.Verb, l.Ref = "build "+rest, d.Job
4769 l.URL = fmt.Sprintf("/%s/builds/%d", e.RepoPath, d.Number)
70 if d.SHA != "" {
71 l.sha = d.SHA
72 l.Jobs = []string{d.Job}
73 l.State = rest
74 st = []string{rest}
75 }
4876 case "release":
4977 l.Verb, l.Ref = "released", d.Tag
5078 l.URL = fmt.Sprintf("/%s/releases", e.RepoPath)
@@ -56,6 +84,7 @@ func feedLines(events []store.FeedEvent) []feedLine {
5684 l.URL = "/" + e.RepoPath
5785 }
5886 out = append(out, l)
87 statuses = append(statuses, st)
5988 }
6089 return out
6190}
internal/httpd/feed_test.go added +131
@@ -0,0 +1,131 @@
1package httpd
2
3import (
4 "testing"
5
6 "gitbay.org/gitbay/internal/store"
7)
8
9// D04: build events on the same commit fold into one feed line, a "run",
10// whose State is the worst of the folded jobs' outcomes.
11func TestFeedLinesFoldsBuildRunsBySHA(t *testing.T) {
12 events := []store.FeedEvent{
13 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
14 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
15 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
16 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
17 }
18 lines := feedLines(events)
19 if len(lines) != 1 {
20 t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines)
21 }
22 l := lines[0]
23 if l.State != "failure" {
24 t.Errorf("State = %q, want failure", l.State)
25 }
26 if l.Verb != "ran 2 jobs on" {
27 t.Errorf("Verb = %q, want %q", l.Verb, "ran 2 jobs on")
28 }
29 if l.Ref != "aaaaaaaaaa" {
30 t.Errorf("Ref = %q, want short sha", l.Ref)
31 }
32 if l.URL != "/alice/app/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
33 t.Errorf("URL = %q", l.URL)
34 }
35 if len(l.Jobs) != 2 || l.Jobs[0] != "unit" || l.Jobs[1] != "lint" {
36 t.Errorf("Jobs = %+v", l.Jobs)
37 }
38}
39
40// A single build event with a sha still gets a State, and keeps its
41// ordinary verb/ref/url — a run of one job reads the same as before.
42func TestFeedLinesSingleBuildGetsState(t *testing.T) {
43 events := []store.FeedEvent{
44 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
45 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
46 }
47 lines := feedLines(events)
48 if len(lines) != 1 {
49 t.Fatalf("feedLines returned %d lines, want 1", len(lines))
50 }
51 l := lines[0]
52 if l.State != "success" {
53 t.Errorf("State = %q, want success", l.State)
54 }
55 if l.Verb != "build success" || l.Ref != "unit" || l.URL != "/alice/app/builds/1" {
56 t.Errorf("single build line changed shape: %+v", l)
57 }
58}
59
60// Two different commits never fold, even back to back.
61func TestFeedLinesDoesNotFoldAcrossDifferentSHAs(t *testing.T) {
62 events := []store.FeedEvent{
63 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
64 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
65 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
66 Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`},
67 }
68 lines := feedLines(events)
69 if len(lines) != 2 {
70 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
71 }
72}
73
74// An event with no sha (an older event recorded before this field existed)
75// never folds into anything, even when it shares a repo with an adjacent
76// build event.
77func TestFeedLinesNoSHANeverFolds(t *testing.T) {
78 events := []store.FeedEvent{
79 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`},
80 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`},
81 }
82 lines := feedLines(events)
83 if len(lines) != 2 {
84 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
85 }
86 if lines[0].State != "" || lines[1].State != "" {
87 t.Errorf("shaless build lines got a State: %+v", lines)
88 }
89}
90
91// A non-build event between two builds of the same commit breaks the
92// fold: only adjacent build events on the same commit combine.
93func TestFeedLinesNonBuildEventBreaksFold(t *testing.T) {
94 events := []store.FeedEvent{
95 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
96 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
97 {RepoPath: "alice/app", Actor: "bob", Kind: "issue.created", Data: `{"number":1}`},
98 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
99 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
100 }
101 lines := feedLines(events)
102 if len(lines) != 3 {
103 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
104 }
105}
106
107// worstStatus governs the run's combined State the same way it governs
108// combinedStatus for the builds tab.
109func TestFeedLinesRunStatePrecedence(t *testing.T) {
110 cases := []struct {
111 statuses []string
112 want string
113 }{
114 {[]string{"success", "success"}, "success"},
115 {[]string{"success", "pending"}, "pending"},
116 {[]string{"pending", "running"}, "running"},
117 {[]string{"running", "cancelled"}, "cancelled"},
118 {[]string{"cancelled", "failure"}, "failure"},
119 }
120 for _, tc := range cases {
121 events := make([]store.FeedEvent, len(tc.statuses))
122 for i, s := range tc.statuses {
123 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s,
124 Data: `{"number":1,"job":"j","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}
125 }
126 lines := feedLines(events)
127 if len(lines) != 1 || lines[0].State != tc.want {
128 t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want)
129 }
130 }
131}
internal/web/templates/dashboard.html +1 −1
@@ -36,7 +36,7 @@
3636<aside class="aside">
3737 <div class="grp">
3838 <h2>Recent activity</h2>
39 {{range .Feed}}<p class="row feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}">{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p>
39 {{range .Feed}}<p class="row feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · {{when .When}}</span></p>
4040 {{else}}<p class="none">No activity yet</p>{{end}}
4141 </div>
4242</aside>