Commit 982171c76d
Verified · cmc
internal/control/build.go +3 −3
| @@ -593,7 +593,7 @@ func runRunnerDone(c *Ctx, args []string) int { | ||
| 593 | 593 | return c.fail(protocol.ExitFailure, "%v", err) |
| 594 | 594 | } |
| 595 | 595 | 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)) | |
| 597 | 597 | // A red build mails the repo's notify targets with the log tail — a |
| 598 | 598 | // failed scheduled job must not wait to be noticed. |
| 599 | 599 | if args[1] == "failure" { |
| @@ -829,7 +829,7 @@ func cancelOrphanedBuild(c *Ctx, repo store.Repo, b store.Build) int { | ||
| 829 | 829 | c.Store.AppendBuildLog(b.ID, []byte(fmt.Sprintf( |
| 830 | 830 | "cancelled: %.10s is not reachable from any ref; the sha was likely orphaned by a force-push\n", b.SHA))) |
| 831 | 831 | 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)) | |
| 833 | 833 | return -1 |
| 834 | 834 | } |
| 835 | 835 | |
| @@ -858,7 +858,7 @@ func runBuildCancel(c *Ctx, args []string) int { | ||
| 858 | 858 | } |
| 859 | 859 | // The queued status replaced whatever the commit had for this job. |
| 860 | 860 | 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)) | |
| 862 | 862 | return c.emit(map[string]any{"number": b.Number, "job": b.Job, "status": "cancelled", "was": b.Status}, func(w io.Writer) { |
| 863 | 863 | if b.Status == "running" { |
| 864 | 864 | 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"} | ||
| 99 | 99 | // combinedStatus is the run's status: the worst of its builds' statuses, |
| 100 | 100 | // success only when every one of them is. |
| 101 | 101 | func 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). | |
| 112 | func worstStatus(statuses []string) string { | |
| 102 | 113 | has := map[string]bool{} |
| 103 | for _, b := range builds { | |
| 104 | has[b.Status] = true | |
| 114 | for _, s := range statuses { | |
| 115 | has[s] = true | |
| 105 | 116 | } |
| 106 | 117 | for _, s := range runStatusPriority { |
| 107 | 118 | if has[s] { |
internal/httpd/feed.go +33 −4
| @@ -11,30 +11,52 @@ import ( | ||
| 11 | 11 | // feedLine is one activity entry, already phrased and linked. |
| 12 | 12 | type feedLine struct { |
| 13 | 13 | 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 | |
| 16 | 16 | Repo string |
| 17 | 17 | 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 | |
| 19 | 22 | } |
| 20 | 23 | |
| 21 | 24 | // feedLines turns stored events into readable lines. An unknown kind |
| 22 | 25 | // 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. | |
| 23 | 29 | func feedLines(events []store.FeedEvent) []feedLine { |
| 24 | 30 | out := make([]feedLine, 0, len(events)) |
| 31 | statuses := make([][]string, 0, len(events)) | |
| 25 | 32 | for _, e := range events { |
| 26 | 33 | var d struct { |
| 27 | 34 | Number int64 `json:"number"` |
| 28 | 35 | Job string `json:"job"` |
| 29 | 36 | Tag string `json:"tag"` |
| 37 | SHA string `json:"sha"` | |
| 30 | 38 | } |
| 31 | 39 | 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 | } | |
| 32 | 54 | |
| 33 | 55 | l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt} |
| 34 | 56 | if l.Actor == "" { |
| 35 | 57 | l.Actor = "gitbay" |
| 36 | 58 | } |
| 37 | kind, rest, _ := strings.Cut(e.Kind, ".") | |
| 59 | var st []string | |
| 38 | 60 | switch kind { |
| 39 | 61 | case "issue": |
| 40 | 62 | l.Verb, l.Ref = issueVerb(rest), fmt.Sprintf("#%d", d.Number) |
| @@ -45,6 +67,12 @@ func feedLines(events []store.FeedEvent) []feedLine { | ||
| 45 | 67 | case "build": |
| 46 | 68 | l.Verb, l.Ref = "build "+rest, d.Job |
| 47 | 69 | 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 | } | |
| 48 | 76 | case "release": |
| 49 | 77 | l.Verb, l.Ref = "released", d.Tag |
| 50 | 78 | l.URL = fmt.Sprintf("/%s/releases", e.RepoPath) |
| @@ -56,6 +84,7 @@ func feedLines(events []store.FeedEvent) []feedLine { | ||
| 56 | 84 | l.URL = "/" + e.RepoPath |
| 57 | 85 | } |
| 58 | 86 | out = append(out, l) |
| 87 | statuses = append(statuses, st) | |
| 59 | 88 | } |
| 60 | 89 | return out |
| 61 | 90 | } |
internal/httpd/feed_test.go added +131
| @@ -0,0 +1,131 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 11 | func 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. | |
| 42 | func 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. | |
| 61 | func 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. | |
| 77 | func 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. | |
| 93 | func 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. | |
| 109 | func 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 @@ | ||
| 36 | 36 | <aside class="aside"> |
| 37 | 37 | <div class="grp"> |
| 38 | 38 | <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> | |
| 40 | 40 | {{else}}<p class="none">No activity yet</p>{{end}} |
| 41 | 41 | </div> |
| 42 | 42 | </aside> |