Commit 9002a0bf3f

9002a0bf3f250233fe369ee8a5977657ee79323a

parent: 0666c9f622

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-28 06:25 UTC

control: move the feed-line sentence renderer from httpd, so the CLI can share it

Ref #265

Layout: unified · split

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