Commit 9002a0bf3f
Verified · cmc
Layout: unified · split
internal/httpd/feed.go → internal/control/feedline.go renamed +36 −9
| @@ -1,4 +1,4 @@ | ||
| 1 | package httpd | |
| 1 | package control | |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "encoding/json" |
| @@ -10,8 +10,8 @@ import ( | ||
| 10 | 10 | "gitbay.org/gitbay/internal/store" |
| 11 | 11 | ) |
| 12 | 12 | |
| 13 | // feedLine is one activity entry, already phrased and linked. | |
| 14 | type feedLine struct { | |
| 13 | // FeedLine is one activity entry, already phrased and linked. | |
| 14 | type FeedLine struct { | |
| 15 | 15 | Actor string |
| 16 | 16 | Verb string // "opened issue", "merged", "ran 2 jobs on" |
| 17 | 17 | Ref string // "#12", "!35", "v0.4.0", a short sha |
| @@ -24,11 +24,11 @@ type feedLine struct { | ||
| 24 | 24 | sha string // the commit a build event fired on, for fold-matching |
| 25 | 25 | } |
| 26 | 26 | |
| 27 | // feedLines turns stored events into readable lines. An unknown kind | |
| 27 | // FeedLines turns stored events into readable lines. An unknown kind | |
| 28 | 28 | // still shows: the feed says what happened even for events added later. |
| 29 | 29 | // Build events on the same commit, adjacent in the input, fold into one |
| 30 | 30 | // "run" line (D04): its State is the worst of the folded jobs' outcomes, |
| 31 | // 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 | 32 | // A repeated job name ends the line and starts the next, so a scheduled |
| 33 | 33 | // job firing daily on an unchanged tip reads as one line a day rather than |
| 34 | 34 | // "ran 9 jobs on" one commit (#240). groupRuns separates its runs by |
| @@ -36,8 +36,8 @@ type feedLine struct { | ||
| 36 | 36 | // per job, so there is no queue moment here to key on. The cost is that |
| 37 | 37 | // the oldest scheduled line on a commit folds in the push's jobs, which |
| 38 | 38 | // have not been seen yet on that line. |
| 39 | func feedLines(events []store.FeedEvent) []feedLine { | |
| 40 | out := make([]feedLine, 0, len(events)) | |
| 39 | func FeedLines(events []store.FeedEvent) []FeedLine { | |
| 40 | out := make([]FeedLine, 0, len(events)) | |
| 41 | 41 | statuses := make([][]string, 0, len(events)) |
| 42 | 42 | for _, e := range events { |
| 43 | 43 | var d struct { |
| @@ -58,12 +58,12 @@ func feedLines(events []store.FeedEvent) []feedLine { | ||
| 58 | 58 | out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs)) |
| 59 | 59 | out[i].Ref = fmt.Sprintf("%.10s", d.SHA) |
| 60 | 60 | out[i].URL = fmt.Sprintf("/%s/commit/%s", e.RepoPath, d.SHA) |
| 61 | out[i].State = worstStatus(statuses[i]) | |
| 61 | out[i].State = WorstStatus(statuses[i]) | |
| 62 | 62 | continue |
| 63 | 63 | } |
| 64 | 64 | } |
| 65 | 65 | |
| 66 | l := feedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)} | |
| 66 | l := FeedLine{Actor: e.Actor, Repo: e.RepoPath, When: e.CreatedAt, WhenT: parseEventTime(e.CreatedAt)} | |
| 67 | 67 | if l.Actor == "" { |
| 68 | 68 | l.Actor = "gitbay" |
| 69 | 69 | } |
| @@ -100,6 +100,33 @@ func feedLines(events []store.FeedEvent) []feedLine { | ||
| 100 | 100 | return out |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | // runStatusPriority orders WorstStatus's worst-first check: a run reads | |
| 104 | // as its least finished or least successful build. | |
| 105 | var runStatusPriority = []string{"failure", "cancelled", "running", "pending"} | |
| 106 | ||
| 107 | // WorstStatus is the worst of a set of build statuses, success only when | |
| 108 | // every one of them is. It backs both the builds tab's combinedStatus and | |
| 109 | // FeedLines' folded build run (D04). A status outside runStatusPriority (a | |
| 110 | // future state such as "skipped") is still not "success": it is returned | |
| 111 | // unchanged rather than falling through and reading as green. | |
| 112 | func WorstStatus(statuses []string) string { | |
| 113 | has := map[string]bool{} | |
| 114 | for _, s := range statuses { | |
| 115 | has[s] = true | |
| 116 | } | |
| 117 | for _, s := range runStatusPriority { | |
| 118 | if has[s] { | |
| 119 | return s | |
| 120 | } | |
| 121 | } | |
| 122 | for _, s := range statuses { | |
| 123 | if s != "success" { | |
| 124 | return s | |
| 125 | } | |
| 126 | } | |
| 127 | return "success" | |
| 128 | } | |
| 129 | ||
| 103 | 130 | // parseEventTime parses a stored RFC3339 timestamp for ago/whenT |
| 104 | 131 | // rendering; an unparseable value (or none) comes back zero rather than |
| 105 | 132 | // guessing. |
internal/httpd/feed_test.go → internal/control/feedline_test.go renamed +10 −10
| @@ -1,4 +1,4 @@ | ||
| 1 | package httpd | |
| 1 | package control | |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "fmt" |
| @@ -18,7 +18,7 @@ func TestFeedLinesFoldsBuildRunsBySHA(t *testing.T) { | ||
| 18 | 18 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure", |
| 19 | 19 | Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, |
| 20 | 20 | } |
| 21 | lines := feedLines(events) | |
| 21 | lines := FeedLines(events) | |
| 22 | 22 | if len(lines) != 1 { |
| 23 | 23 | t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines) |
| 24 | 24 | } |
| @@ -47,7 +47,7 @@ func TestFeedLinesSingleBuildGetsState(t *testing.T) { | ||
| 47 | 47 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", |
| 48 | 48 | Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, |
| 49 | 49 | } |
| 50 | lines := feedLines(events) | |
| 50 | lines := FeedLines(events) | |
| 51 | 51 | if len(lines) != 1 { |
| 52 | 52 | t.Fatalf("feedLines returned %d lines, want 1", len(lines)) |
| 53 | 53 | } |
| @@ -68,7 +68,7 @@ func TestFeedLinesDoesNotFoldAcrossDifferentSHAs(t *testing.T) { | ||
| 68 | 68 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", |
| 69 | 69 | Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`}, |
| 70 | 70 | } |
| 71 | lines := feedLines(events) | |
| 71 | lines := FeedLines(events) | |
| 72 | 72 | if len(lines) != 2 { |
| 73 | 73 | t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines) |
| 74 | 74 | } |
| @@ -82,7 +82,7 @@ func TestFeedLinesNoSHANeverFolds(t *testing.T) { | ||
| 82 | 82 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`}, |
| 83 | 83 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`}, |
| 84 | 84 | } |
| 85 | lines := feedLines(events) | |
| 85 | lines := FeedLines(events) | |
| 86 | 86 | if len(lines) != 2 { |
| 87 | 87 | t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines) |
| 88 | 88 | } |
| @@ -101,7 +101,7 @@ func TestFeedLinesNonBuildEventBreaksFold(t *testing.T) { | ||
| 101 | 101 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure", |
| 102 | 102 | Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`}, |
| 103 | 103 | } |
| 104 | lines := feedLines(events) | |
| 104 | lines := FeedLines(events) | |
| 105 | 105 | if len(lines) != 3 { |
| 106 | 106 | t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines) |
| 107 | 107 | } |
| @@ -127,7 +127,7 @@ func TestFeedLinesRunStatePrecedence(t *testing.T) { | ||
| 127 | 127 | events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s, |
| 128 | 128 | Data: fmt.Sprintf(`{"number":1,"job":"j%d","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`, i)} |
| 129 | 129 | } |
| 130 | lines := feedLines(events) | |
| 130 | lines := FeedLines(events) | |
| 131 | 131 | if len(lines) != 1 || lines[0].State != tc.want { |
| 132 | 132 | t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want) |
| 133 | 133 | } |
| @@ -144,7 +144,7 @@ func TestFeedLinesParsesWhenT(t *testing.T) { | ||
| 144 | 144 | {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created", |
| 145 | 145 | Data: `{"number":2}`, CreatedAt: "not-a-time"}, |
| 146 | 146 | } |
| 147 | lines := feedLines(events) | |
| 147 | lines := FeedLines(events) | |
| 148 | 148 | want, _ := time.Parse(time.RFC3339Nano, "2026-09-10T12:00:00Z") |
| 149 | 149 | if !lines[0].WhenT.Equal(want) { |
| 150 | 150 | t.Errorf("WhenT = %v, want %v", lines[0].WhenT, want) |
| @@ -169,7 +169,7 @@ func TestFeedLinesSplitsRepeatedJob(t *testing.T) { | ||
| 169 | 169 | events = append(events, store.FeedEvent{RepoPath: "alice/app", Actor: "alice", |
| 170 | 170 | Kind: "build." + status, Data: `{"number":1,"job":"instances","sha":"` + sha + `"}`}) |
| 171 | 171 | } |
| 172 | lines := feedLines(events) | |
| 172 | lines := FeedLines(events) | |
| 173 | 173 | if len(lines) != 3 { |
| 174 | 174 | t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines) |
| 175 | 175 | } |
| @@ -194,7 +194,7 @@ func TestFeedLinesScheduleAbsorbsPushJobs(t *testing.T) { | ||
| 194 | 194 | {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", |
| 195 | 195 | Data: `{"number":1,"job":"lint","sha":"` + sha + `"}`}, |
| 196 | 196 | } |
| 197 | lines := feedLines(events) | |
| 197 | lines := FeedLines(events) | |
| 198 | 198 | if len(lines) != 2 { |
| 199 | 199 | t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines) |
| 200 | 200 | } |
internal/httpd/anchors_test.go +2 −1
| @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | "testing" |
| 8 | 8 | |
| 9 | 9 | "gitbay.org/gitbay/internal/config" |
| 10 | "gitbay.org/gitbay/internal/control" | |
| 10 | 11 | "gitbay.org/gitbay/internal/store" |
| 11 | 12 | "gitbay.org/gitbay/internal/web" |
| 12 | 13 | ) |
| @@ -58,7 +59,7 @@ func TestStylesheetURLCarriesTheBuildHash(t *testing.T) { | ||
| 58 | 59 | Assigned []store.DashboardItem |
| 59 | 60 | MRs []store.DashboardItem |
| 60 | 61 | Issues []store.DashboardItem |
| 61 | Feed []feedLine | |
| 62 | Feed []control.FeedLine | |
| 62 | 63 | }{base, "dashboard", nil, nil, nil, nil, nil, nil}) |
| 63 | 64 | if err != nil { |
| 64 | 65 | t.Fatal(err) |
internal/httpd/builds.go +1 −28
| @@ -147,10 +147,6 @@ type buildRun struct { | ||
| 147 | 147 | Builds []control.BuildOut |
| 148 | 148 | } |
| 149 | 149 | |
| 150 | // runStatusPriority orders combinedStatus's worst-first check: a run reads | |
| 151 | // as its least finished or least successful build. | |
| 152 | var runStatusPriority = []string{"failure", "cancelled", "running", "pending"} | |
| 153 | ||
| 154 | 150 | // combinedStatus is the run's status: the worst of its builds' statuses, |
| 155 | 151 | // success only when every one of them is. |
| 156 | 152 | func combinedStatus(builds []control.BuildOut) string { |
| @@ -158,30 +154,7 @@ func combinedStatus(builds []control.BuildOut) string { | ||
| 158 | 154 | for i, b := range builds { |
| 159 | 155 | statuses[i] = b.Status |
| 160 | 156 | } |
| 161 | return worstStatus(statuses) | |
| 162 | } | |
| 163 | ||
| 164 | // worstStatus is combinedStatus's ordering rule, factored out so the | |
| 165 | // dashboard feed can apply the same worst-first precedence to a folded | |
| 166 | // build run (D04). A status outside runStatusPriority (a future state | |
| 167 | // such as "skipped") is still not "success": it is returned unchanged | |
| 168 | // rather than falling through and reading as green. | |
| 169 | func worstStatus(statuses []string) string { | |
| 170 | has := map[string]bool{} | |
| 171 | for _, s := range statuses { | |
| 172 | has[s] = true | |
| 173 | } | |
| 174 | for _, s := range runStatusPriority { | |
| 175 | if has[s] { | |
| 176 | return s | |
| 177 | } | |
| 178 | } | |
| 179 | for _, s := range statuses { | |
| 180 | if s != "success" { | |
| 181 | return s | |
| 182 | } | |
| 183 | } | |
| 184 | return "success" | |
| 157 | return control.WorstStatus(statuses) | |
| 185 | 158 | } |
| 186 | 159 | |
| 187 | 160 | // groupRuns folds consecutive builds of the same commit and the same |
internal/httpd/dashpins_test.go +2 −1
| @@ -4,6 +4,7 @@ import ( | ||
| 4 | 4 | "strings" |
| 5 | 5 | "testing" |
| 6 | 6 | |
| 7 | "gitbay.org/gitbay/internal/control" | |
| 7 | 8 | "gitbay.org/gitbay/internal/store" |
| 8 | 9 | "gitbay.org/gitbay/internal/web" |
| 9 | 10 | ) |
| @@ -23,7 +24,7 @@ func TestDashboardTilesAndPins(t *testing.T) { | ||
| 23 | 24 | Assigned []store.DashboardItem |
| 24 | 25 | MRs []store.DashboardItem |
| 25 | 26 | Issues []store.DashboardItem |
| 26 | Feed []feedLine | |
| 27 | Feed []control.FeedLine | |
| 27 | 28 | }{base, "dashboard", []pinnedRow{ |
| 28 | 29 | {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}, |
| 29 | 30 | {Owner: "krz", Name: "old", Build: "cancelled"}, |
internal/httpd/ownerpage_test.go +2 −2
| @@ -55,7 +55,7 @@ func TestOwnerPageTabOrder(t *testing.T) { | ||
| 55 | 55 | // it — not the whole history, which is what the atom feed is for. |
| 56 | 56 | func TestOwnerPageAboutCarriesTheGraphAndLog(t *testing.T) { |
| 57 | 57 | d := ownerFixture() |
| 58 | d.Log = []feedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}} | |
| 58 | d.Log = []control.FeedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}} | |
| 59 | 59 | out := renderOwner(t, "about", d) |
| 60 | 60 | for _, want := range []string{"Christian Cleberg", "actgraph", "opened issue", "#12", "/cmc/activity.atom"} { |
| 61 | 61 | if !strings.Contains(out, want) { |
| @@ -149,7 +149,7 @@ type ownerFixtureData struct { | ||
| 149 | 149 | Self bool |
| 150 | 150 | Snippets int |
| 151 | 151 | Members []control.ProfileMember |
| 152 | Log []feedLine | |
| 152 | Log []control.FeedLine | |
| 153 | 153 | Bookmarks []control.BookmarkOut |
| 154 | 154 | SnippetRows []snippetRow |
| 155 | 155 | } |
internal/httpd/web.go +5 −5
| @@ -216,8 +216,8 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store. | ||
| 216 | 216 | Assigned []store.DashboardItem |
| 217 | 217 | MRs []store.DashboardItem |
| 218 | 218 | Issues []store.DashboardItem |
| 219 | Feed []feedLine | |
| 220 | }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)}) | |
| 219 | Feed []control.FeedLine | |
| 220 | }{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, control.FeedLines(events)}) | |
| 221 | 221 | } |
| 222 | 222 | |
| 223 | 223 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
| @@ -467,7 +467,7 @@ const profileEvents = 30 | ||
| 467 | 467 | // ActivityByDay and OrgActivityByDay respectively — a log that counted |
| 468 | 468 | // something else would contradict the total printed over it. Only the |
| 469 | 469 | // About tab renders it, so no other tab pays for the query. |
| 470 | func (s *Server) ownerFeed(tab, kind, name string) []feedLine { | |
| 470 | func (s *Server) ownerFeed(tab, kind, name string) []control.FeedLine { | |
| 471 | 471 | if tab != "about" { |
| 472 | 472 | return nil |
| 473 | 473 | } |
| @@ -490,7 +490,7 @@ func (s *Server) ownerFeed(tab, kind, name string) []feedLine { | ||
| 490 | 490 | if err != nil { |
| 491 | 491 | return nil |
| 492 | 492 | } |
| 493 | return feedLines(events) | |
| 493 | return control.FeedLines(events) | |
| 494 | 494 | } |
| 495 | 495 | |
| 496 | 496 | // ownerPage is what owner.html renders against. It is a named type |
| @@ -508,7 +508,7 @@ type ownerPage struct { | ||
| 508 | 508 | Orgs []control.ProfileMember |
| 509 | 509 | Activity []activityWeek |
| 510 | 510 | ActivityTotal int |
| 511 | Log []feedLine | |
| 511 | Log []control.FeedLine | |
| 512 | 512 | Bookmarks []control.BookmarkOut |
| 513 | 513 | SnippetRows []snippetRow |
| 514 | 514 | SnippetsAll bool |