Commit 7720b4d5d5
Verified · cmc ci/build: success ci/test: success
internal/httpd/buildpages_test.go +33
| @@ -91,3 +91,36 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | ||
| 91 | 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 | 129 | // buildRun is one commit's builds, grouped for display: the builds tab |
| 130 | 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 | 133 | type buildRun struct { |
| 133 | 134 | SHA string |
| 134 | 135 | Ref string |
| @@ -174,13 +175,22 @@ func worstStatus(statuses []string) string { | ||
| 174 | 175 | return "success" |
| 175 | 176 | } |
| 176 | 177 | |
| 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. | |
| 180 | 190 | func groupRuns(builds []control.BuildOut) []buildRun { |
| 181 | 191 | var runs []buildRun |
| 182 | 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 | 194 | runs[n-1].Builds = append(runs[n-1].Builds, b) |
| 185 | 195 | continue |
| 186 | 196 | } |
internal/httpd/builds_test.go +30
| @@ -208,3 +208,33 @@ func TestBuildFacetsCapsBranches(t *testing.T) { | ||
| 208 | 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 | 3 | import ( |
| 4 | 4 | "encoding/json" |
| 5 | 5 | "fmt" |
| 6 | "slices" | |
| 6 | 7 | "strings" |
| 7 | 8 | "time" |
| 8 | 9 | |
| @@ -28,6 +29,13 @@ type feedLine struct { | ||
| 28 | 29 | // Build events on the same commit, adjacent in the input, fold into one |
| 29 | 30 | // "run" line (D04): its State is the worst of the folded jobs' outcomes, |
| 30 | 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 | 39 | func feedLines(events []store.FeedEvent) []feedLine { |
| 32 | 40 | out := make([]feedLine, 0, len(events)) |
| 33 | 41 | statuses := make([][]string, 0, len(events)) |
| @@ -42,7 +50,8 @@ func feedLines(events []store.FeedEvent) []feedLine { | ||
| 42 | 50 | kind, rest, _ := strings.Cut(e.Kind, ".") |
| 43 | 51 | |
| 44 | 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 | 55 | i := n - 1 |
| 47 | 56 | out[i].Jobs = append(out[i].Jobs, d.Job) |
| 48 | 57 | statuses[i] = append(statuses[i], rest) |
internal/httpd/feed_test.go +49 −1
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "fmt" | |
| 5 | "reflect" | |
| 4 | 6 | "testing" |
| 5 | 7 | "time" |
| 6 | 8 | |
| @@ -121,8 +123,9 @@ func TestFeedLinesRunStatePrecedence(t *testing.T) { | ||
| 121 | 123 | for _, tc := range cases { |
| 122 | 124 | events := make([]store.FeedEvent, len(tc.statuses)) |
| 123 | 125 | for i, s := range tc.statuses { |
| 126 | // Distinct job names: a repeat would split the line (#240). | |
| 124 | 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 | 130 | lines := feedLines(events) |
| 128 | 131 | if len(lines) != 1 || lines[0].State != tc.want { |
| @@ -154,3 +157,48 @@ func TestFeedLinesParsesWhenT(t *testing.T) { | ||
| 154 | 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 | 38 | <pre class="code" tabindex="0">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> |
| 39 | 39 | <p class="meta">Add <code>?job=name</code> for one job.</p> |
| 40 | 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 | 42 | <ul class="loglist rows"> |
| 43 | 43 | {{range .Runs}}<li> |
| 44 | 44 | <div class="commitmain"> |