builds: separate runs queued at different times !452

merged merged by cmc on 2026-09-21 05:51 UTC · krz/gitbay:builds-run-split into main

6 files changed, +138 −8

Layout: unified · split

internal/httpd/buildpages_test.go +33
@@ -91,3 +91,36 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
91 t.Error("build.html offers cancel on a finished build") 91 t.Error("build.html offers cancel on a finished build")
92 } 92 }
93} 93}
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 {
128 128
129// buildRun is one commit's builds, grouped for display: the builds tab 129// buildRun is one commit's builds, grouped for display: the builds tab
130// reads by commit, not by job, so a push that runs three jobs shows as one 130// 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).
132type buildRun struct { 133type buildRun struct {
133 SHA string 134 SHA string
134 Ref string 135 Ref string
@@ -174,13 +175,22 @@ func worstStatus(statuses []string) string {
174 return "success" 175 return "success"
175} 176}
176 177
177// groupRuns folds consecutive builds of the same commit into one run. 178// groupRuns folds consecutive builds of the same commit and the same
178// build list orders builds newest first, so one push's jobs are adjacent; 179// created_at into one run. build list orders builds newest first, so one
179// this does not sort or otherwise assume anything beyond that adjacency. 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.
180func groupRuns(builds []control.BuildOut) []buildRun { 190func groupRuns(builds []control.BuildOut) []buildRun {
181 var runs []buildRun 191 var runs []buildRun
182 for _, b := range builds { 192 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 {
184 runs[n-1].Builds = append(runs[n-1].Builds, b) 194 runs[n-1].Builds = append(runs[n-1].Builds, b)
185 continue 195 continue
186 } 196 }
internal/httpd/builds_test.go +30
@@ -208,3 +208,33 @@ func TestBuildFacetsCapsBranches(t *testing.T) {
208 t.Errorf("active ref past the cap: %+v", branches) 208 t.Errorf("active ref past the cap: %+v", branches)
209 } 209 }
210} 210}
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
3import ( 3import (
4 "encoding/json" 4 "encoding/json"
5 "fmt" 5 "fmt"
6 "slices"
6 "strings" 7 "strings"
7 "time" 8 "time"
8 9
@@ -28,6 +29,13 @@ type feedLine struct {
28// Build events on the same commit, adjacent in the input, fold into one 29// Build events on the same commit, adjacent in the input, fold into one
29// "run" line (D04): its State is the worst of the folded jobs' outcomes, 30// "run" line (D04): its State is the worst of the folded jobs' outcomes,
30// via worstStatus — the same rule the builds tab uses for a run's status. 31// 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.
31func feedLines(events []store.FeedEvent) []feedLine { 39func feedLines(events []store.FeedEvent) []feedLine {
32 out := make([]feedLine, 0, len(events)) 40 out := make([]feedLine, 0, len(events))
33 statuses := make([][]string, 0, len(events)) 41 statuses := make([][]string, 0, len(events))
@@ -42,7 +50,8 @@ func feedLines(events []store.FeedEvent) []feedLine {
42 kind, rest, _ := strings.Cut(e.Kind, ".") 50 kind, rest, _ := strings.Cut(e.Kind, ".")
43 51
44 if kind == "build" && d.SHA != "" { 52 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) {
46 i := n - 1 55 i := n - 1
47 out[i].Jobs = append(out[i].Jobs, d.Job) 56 out[i].Jobs = append(out[i].Jobs, d.Job)
48 statuses[i] = append(statuses[i], rest) 57 statuses[i] = append(statuses[i], rest)
internal/httpd/feed_test.go +49 −1
@@ -1,6 +1,8 @@
1package httpd 1package httpd
2 2
3import ( 3import (
4 "fmt"
5 "reflect"
4 "testing" 6 "testing"
5 "time" 7 "time"
6 8
@@ -121,8 +123,9 @@ func TestFeedLinesRunStatePrecedence(t *testing.T) {
121 for _, tc := range cases { 123 for _, tc := range cases {
122 events := make([]store.FeedEvent, len(tc.statuses)) 124 events := make([]store.FeedEvent, len(tc.statuses))
123 for i, s := range tc.statuses { 125 for i, s := range tc.statuses {
126 // Distinct job names: a repeat would split the line (#240).
124 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s, 127 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)}
126 } 129 }
127 lines := feedLines(events) 130 lines := feedLines(events)
128 if len(lines) != 1 || lines[0].State != tc.want { 131 if len(lines) != 1 || lines[0].State != tc.want {
@@ -154,3 +157,48 @@ func TestFeedLinesParsesWhenT(t *testing.T) {
154 t.Errorf("When = %q", lines[0].When) 157 t.Errorf("When = %q", lines[0].When)
155 } 158 }
156} 159}
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 @@
38<pre class="code" tabindex="0">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> 38<pre class="code" tabindex="0">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre>
39<p class="meta">Add <code>?job=name</code> for one job.</p> 39<p class="meta">Add <code>?job=name</code> for one job.</p>
40</details> 40</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>
42<ul class="loglist rows"> 42<ul class="loglist rows">
43{{range .Runs}}<li> 43{{range .Runs}}<li>
44 <div class="commitmain"> 44 <div class="commitmain">