builds: separate runs queued at different times !452
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. | ||
| 97 | func 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). | ||
| 132 | type buildRun struct { | 133 | type 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. | ||
| 180 | func groupRuns(builds []control.BuildOut) []buildRun { | 190 | func 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. | ||
| 217 | func 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 | |||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 31 | func feedLines(events []store.FeedEvent) []feedLine { | 39 | func 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 @@ | |||
| 1 | package httpd | 1 | package httpd |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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. | ||
| 165 | func 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. | ||
| 187 | func 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">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> | 38 | <pre class="code" tabindex="0">[](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"> |