Commit 7720b4d5d5

7720b4d5d5b52df90db911430a13ccb490cdf124

parent: 9c84807c66

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-21 05:28 UTC

builds: separate runs queued at different times

groupRuns keyed on the commit, so a scheduled job firing against an
unchanged branch tip collapsed a week of daily runs into one row carrying
the newest timestamp and status. Key on the commit and created_at: a
push's jobs are queued in one loop and share it to the second, a
schedule's are a day apart. The count line names builds and runs, which
were being read as the same number.

feedLines has the same fold and no queue moment to key on — its events
are recorded per job at finish — so it splits on a repeated job name
instead. A test records what that cannot do.

Closes #240
internal/httpd/buildpages_test.go +33
@@ -91,3 +91,36 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
9191 t.Error("build.html offers cancel on a finished build")
9292 }
9393}
94
95// The count under the heading said "15 runs" while listing 23 builds
96// (#240). It names both numbers now, so neither is mistaken for the other.
97func TestBuildsPageCountsBuildsAndRuns(t *testing.T) {
98 builds := []control.BuildOut{
99 {Number: 4, Job: "instances", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-20T06:00:00Z"},
100 {Number: 3, Job: "instances", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-19T06:00:00Z"},
101 {Number: 2, Job: "lint", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-12T11:20:03Z"},
102 {Number: 1, Job: "unit", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "2026-09-12T11:20:03Z"},
103 }
104 var sb strings.Builder
105 filter := buildFilter{}
106 err := web.Render(&sb, "builds.html", struct {
107 repoPage
108 Builds []control.BuildOut
109 Jobs []control.JobOut
110 Runs []buildRun
111 Filter buildFilter
112 Facets []facetGroup
113 Refs []string
114 CanWrite bool
115 Notice string
116 }{
117 testRepoPage(), builds, nil, groupRuns(builds), filter, nil,
118 distinctRefs(builds, filter.Ref), true, "",
119 })
120 if err != nil {
121 t.Fatalf("render: %v", err)
122 }
123 if !strings.Contains(sb.String(), "4 builds in 3 runs") {
124 t.Errorf("builds.html count line: %q", sb.String())
125 }
126}
internal/httpd/builds.go +15 −5
@@ -128,7 +128,8 @@ func distinctRefs(builds []control.BuildOut, current string) []string {
128128
129129// buildRun is one commit's builds, grouped for display: the builds tab
130130// reads by commit, not by job, so a push that runs three jobs shows as one
131// row with three chips rather than three unrelated rows (#224).
131// row with three chips rather than three unrelated rows (#224). A run is
132// one queueing of a commit, not the commit — see groupRuns (#240).
132133type buildRun struct {
133134 SHA string
134135 Ref string
@@ -174,13 +175,22 @@ func worstStatus(statuses []string) string {
174175 return "success"
175176}
176177
177// groupRuns folds consecutive builds of the same commit into one run.
178// build list orders builds newest first, so one push's jobs are adjacent;
179// this does not sort or otherwise assume anything beyond that adjacency.
178// groupRuns folds consecutive builds of the same commit and the same
179// created_at into one run. build list orders builds newest first, so one
180// push's jobs are adjacent; this does not sort or otherwise assume anything
181// beyond that adjacency.
182//
183// The commit alone is not the run. A scheduled job fires against the same
184// sha every tick for as long as the branch tip does not move, so keying on
185// the sha collapsed a week of daily runs into one row carrying the newest
186// timestamp and status (#240). What separates them is when they were
187// queued: a push's jobs are queued in one loop and share a created_at to
188// the second, a schedule's are hours or days apart. A queue loop that
189// straddles a second boundary shows as two rows for one push.
180190func groupRuns(builds []control.BuildOut) []buildRun {
181191 var runs []buildRun
182192 for _, b := range builds {
183 if n := len(runs); n > 0 && runs[n-1].SHA == b.SHA {
193 if n := len(runs); n > 0 && runs[n-1].SHA == b.SHA && runs[n-1].CreatedAt == b.CreatedAt {
184194 runs[n-1].Builds = append(runs[n-1].Builds, b)
185195 continue
186196 }
internal/httpd/builds_test.go +30
@@ -208,3 +208,33 @@ func TestBuildFacetsCapsBranches(t *testing.T) {
208208 t.Errorf("active ref past the cap: %+v", branches)
209209 }
210210}
211
212// A scheduled job runs against the same sha every tick for as long as the
213// branch tip does not move, so the sha alone is not the run (#240). Three
214// daily runs of "instances" on one commit are three rows, each with its own
215// timestamp and status; the push that set the tip queued lint and unit
216// together, so those stay one row.
217func TestGroupRunsSeparatesRepeatedSchedule(t *testing.T) {
218 builds := []control.BuildOut{
219 {Number: 5, Job: "instances", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "t5"},
220 {Number: 4, Job: "instances", Status: "failure", SHA: "aaa", Ref: "main", CreatedAt: "t4"},
221 {Number: 3, Job: "instances", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "t3"},
222 {Number: 2, Job: "lint", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "t2"},
223 {Number: 1, Job: "unit", Status: "success", SHA: "aaa", Ref: "main", CreatedAt: "t2"},
224 }
225 runs := groupRuns(builds)
226 if len(runs) != 4 {
227 t.Fatalf("groupRuns returned %d runs, want 4: %+v", len(runs), runs)
228 }
229 for i, want := range []struct {
230 when string
231 status string
232 jobs int
233 }{{"t5", "success", 1}, {"t4", "failure", 1}, {"t3", "success", 1}, {"t2", "success", 2}} {
234 got := runs[i]
235 if got.CreatedAt != want.when || got.Status != want.status || len(got.Builds) != want.jobs {
236 t.Errorf("run %d: %+v, want CreatedAt %q status %q with %d builds",
237 i, got, want.when, want.status, want.jobs)
238 }
239 }
240}
internal/httpd/feed.go +10 −1
@@ -3,6 +3,7 @@ package httpd
33import (
44 "encoding/json"
55 "fmt"
6 "slices"
67 "strings"
78 "time"
89
@@ -28,6 +29,13 @@ type feedLine struct {
2829// Build events on the same commit, adjacent in the input, fold into one
2930// "run" line (D04): its State is the worst of the folded jobs' outcomes,
3031// via worstStatus — the same rule the builds tab uses for a run's status.
32// A repeated job name ends the line and starts the next, so a scheduled
33// job firing daily on an unchanged tip reads as one line a day rather than
34// "ran 9 jobs on" one commit (#240). groupRuns separates its runs by
35// created_at instead; these events are recorded when a build finishes, one
36// per job, so there is no queue moment here to key on. The cost is that
37// the oldest scheduled line on a commit folds in the push's jobs, which
38// have not been seen yet on that line.
3139func feedLines(events []store.FeedEvent) []feedLine {
3240 out := make([]feedLine, 0, len(events))
3341 statuses := make([][]string, 0, len(events))
@@ -42,7 +50,8 @@ func feedLines(events []store.FeedEvent) []feedLine {
4250 kind, rest, _ := strings.Cut(e.Kind, ".")
4351
4452 if kind == "build" && d.SHA != "" {
45 if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath {
53 if n := len(out); n > 0 && out[n-1].sha == d.SHA && out[n-1].Repo == e.RepoPath &&
54 !slices.Contains(out[n-1].Jobs, d.Job) {
4655 i := n - 1
4756 out[i].Jobs = append(out[i].Jobs, d.Job)
4857 statuses[i] = append(statuses[i], rest)
internal/httpd/feed_test.go +49 −1
@@ -1,6 +1,8 @@
11package httpd
22
33import (
4 "fmt"
5 "reflect"
46 "testing"
57 "time"
68
@@ -121,8 +123,9 @@ func TestFeedLinesRunStatePrecedence(t *testing.T) {
121123 for _, tc := range cases {
122124 events := make([]store.FeedEvent, len(tc.statuses))
123125 for i, s := range tc.statuses {
126 // Distinct job names: a repeat would split the line (#240).
124127 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s,
125 Data: `{"number":1,"job":"j","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}
128 Data: fmt.Sprintf(`{"number":1,"job":"j%d","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`, i)}
126129 }
127130 lines := feedLines(events)
128131 if len(lines) != 1 || lines[0].State != tc.want {
@@ -154,3 +157,48 @@ func TestFeedLinesParsesWhenT(t *testing.T) {
154157 t.Errorf("When = %q", lines[0].When)
155158 }
156159}
160
161// A scheduled job firing daily on an unchanged tip is a separate event
162// each tick, not another job of one run (#240): a repeated job name starts
163// a new line, so three days read as three lines rather than "ran 3 jobs on"
164// one commit.
165func TestFeedLinesSplitsRepeatedJob(t *testing.T) {
166 const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
167 var events []store.FeedEvent
168 for _, status := range []string{"success", "failure", "success"} {
169 events = append(events, store.FeedEvent{RepoPath: "alice/app", Actor: "alice",
170 Kind: "build." + status, Data: `{"number":1,"job":"instances","sha":"` + sha + `"}`})
171 }
172 lines := feedLines(events)
173 if len(lines) != 3 {
174 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
175 }
176 for i, want := range []string{"success", "failure", "success"} {
177 if lines[i].Verb != "build "+want || lines[i].Ref != "instances" {
178 t.Errorf("line %d: %+v, want Verb %q on job instances", i, lines[i], "build "+want)
179 }
180 }
181}
182
183// What the job-name rule cannot do, documented so the limit is not
184// rediscovered as a bug: these events are recorded per job at finish time,
185// so the oldest scheduled line on a commit folds in the push's jobs. The
186// builds tab does not have this problem — groupRuns has created_at.
187func TestFeedLinesScheduleAbsorbsPushJobs(t *testing.T) {
188 const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
189 events := []store.FeedEvent{
190 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
191 Data: `{"number":3,"job":"instances","sha":"` + sha + `"}`},
192 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
193 Data: `{"number":2,"job":"instances","sha":"` + sha + `"}`},
194 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
195 Data: `{"number":1,"job":"lint","sha":"` + sha + `"}`},
196 }
197 lines := feedLines(events)
198 if len(lines) != 2 {
199 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
200 }
201 if !reflect.DeepEqual(lines[1].Jobs, []string{"instances", "lint"}) {
202 t.Errorf("second line jobs: %+v, want the schedule and the push folded", lines[1].Jobs)
203 }
204}
internal/web/templates/builds.html +1 −1
@@ -38,7 +38,7 @@
3838<pre class="code" tabindex="0">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre>
3939<p class="meta">Add <code>?job=name</code> for one job.</p>
4040</details>
41<p class="meta">{{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p>
41<p class="meta">{{len .Builds}} build{{if ne (len .Builds) 1}}s{{end}} in {{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p>
4242<ul class="loglist rows">
4343{{range .Runs}}<li>
4444 <div class="commitmain">