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
22
33import (
44 "encoding/json"
@@ -10,8 +10,8 @@ import (
1010 "gitbay.org/gitbay/internal/store"
1111)
1212
13// feedLine is one activity entry, already phrased and linked.
14type feedLine struct {
13// FeedLine is one activity entry, already phrased and linked.
14type FeedLine struct {
1515 Actor string
1616 Verb string // "opened issue", "merged", "ran 2 jobs on"
1717 Ref string // "#12", "!35", "v0.4.0", a short sha
@@ -24,11 +24,11 @@ type feedLine struct {
2424 sha string // the commit a build event fired on, for fold-matching
2525}
2626
27// feedLines turns stored events into readable lines. An unknown kind
27// FeedLines turns stored events into readable lines. An unknown kind
2828// still shows: the feed says what happened even for events added later.
2929// Build events on the same commit, adjacent in the input, fold into one
3030// "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.
3232// A repeated job name ends the line and starts the next, so a scheduled
3333// job firing daily on an unchanged tip reads as one line a day rather than
3434// "ran 9 jobs on" one commit (#240). groupRuns separates its runs by
@@ -36,8 +36,8 @@ type feedLine struct {
3636// per job, so there is no queue moment here to key on. The cost is that
3737// the oldest scheduled line on a commit folds in the push's jobs, which
3838// have not been seen yet on that line.
39func feedLines(events []store.FeedEvent) []feedLine {
40 out := make([]feedLine, 0, len(events))
39func FeedLines(events []store.FeedEvent) []FeedLine {
40 out := make([]FeedLine, 0, len(events))
4141 statuses := make([][]string, 0, len(events))
4242 for _, e := range events {
4343 var d struct {
@@ -58,12 +58,12 @@ func feedLines(events []store.FeedEvent) []feedLine {
5858 out[i].Verb = fmt.Sprintf("ran %d jobs on", len(out[i].Jobs))
5959 out[i].Ref = fmt.Sprintf("%.10s", d.SHA)
6060 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])
6262 continue
6363 }
6464 }
6565
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)}
6767 if l.Actor == "" {
6868 l.Actor = "gitbay"
6969 }
@@ -100,6 +100,33 @@ func feedLines(events []store.FeedEvent) []feedLine {
100100 return out
101101}
102102
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
103130// parseEventTime parses a stored RFC3339 timestamp for ago/whenT
104131// rendering; an unparseable value (or none) comes back zero rather than
105132// guessing.
internal/httpd/feed_test.go → internal/control/feedline_test.go renamed +10 −10
@@ -1,4 +1,4 @@
1package httpd
1package control
22
33import (
44 "fmt"
@@ -18,7 +18,7 @@ func TestFeedLinesFoldsBuildRunsBySHA(t *testing.T) {
1818 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
1919 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
2020 }
21 lines := feedLines(events)
21 lines := FeedLines(events)
2222 if len(lines) != 1 {
2323 t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines)
2424 }
@@ -47,7 +47,7 @@ func TestFeedLinesSingleBuildGetsState(t *testing.T) {
4747 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
4848 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
4949 }
50 lines := feedLines(events)
50 lines := FeedLines(events)
5151 if len(lines) != 1 {
5252 t.Fatalf("feedLines returned %d lines, want 1", len(lines))
5353 }
@@ -68,7 +68,7 @@ func TestFeedLinesDoesNotFoldAcrossDifferentSHAs(t *testing.T) {
6868 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
6969 Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`},
7070 }
71 lines := feedLines(events)
71 lines := FeedLines(events)
7272 if len(lines) != 2 {
7373 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
7474 }
@@ -82,7 +82,7 @@ func TestFeedLinesNoSHANeverFolds(t *testing.T) {
8282 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`},
8383 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`},
8484 }
85 lines := feedLines(events)
85 lines := FeedLines(events)
8686 if len(lines) != 2 {
8787 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
8888 }
@@ -101,7 +101,7 @@ func TestFeedLinesNonBuildEventBreaksFold(t *testing.T) {
101101 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
102102 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
103103 }
104 lines := feedLines(events)
104 lines := FeedLines(events)
105105 if len(lines) != 3 {
106106 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
107107 }
@@ -127,7 +127,7 @@ func TestFeedLinesRunStatePrecedence(t *testing.T) {
127127 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s,
128128 Data: fmt.Sprintf(`{"number":1,"job":"j%d","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`, i)}
129129 }
130 lines := feedLines(events)
130 lines := FeedLines(events)
131131 if len(lines) != 1 || lines[0].State != tc.want {
132132 t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want)
133133 }
@@ -144,7 +144,7 @@ func TestFeedLinesParsesWhenT(t *testing.T) {
144144 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created",
145145 Data: `{"number":2}`, CreatedAt: "not-a-time"},
146146 }
147 lines := feedLines(events)
147 lines := FeedLines(events)
148148 want, _ := time.Parse(time.RFC3339Nano, "2026-09-10T12:00:00Z")
149149 if !lines[0].WhenT.Equal(want) {
150150 t.Errorf("WhenT = %v, want %v", lines[0].WhenT, want)
@@ -169,7 +169,7 @@ func TestFeedLinesSplitsRepeatedJob(t *testing.T) {
169169 events = append(events, store.FeedEvent{RepoPath: "alice/app", Actor: "alice",
170170 Kind: "build." + status, Data: `{"number":1,"job":"instances","sha":"` + sha + `"}`})
171171 }
172 lines := feedLines(events)
172 lines := FeedLines(events)
173173 if len(lines) != 3 {
174174 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
175175 }
@@ -194,7 +194,7 @@ func TestFeedLinesScheduleAbsorbsPushJobs(t *testing.T) {
194194 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
195195 Data: `{"number":1,"job":"lint","sha":"` + sha + `"}`},
196196 }
197 lines := feedLines(events)
197 lines := FeedLines(events)
198198 if len(lines) != 2 {
199199 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
200200 }
internal/httpd/anchors_test.go +2 −1
@@ -7,6 +7,7 @@ import (
77 "testing"
88
99 "gitbay.org/gitbay/internal/config"
10 "gitbay.org/gitbay/internal/control"
1011 "gitbay.org/gitbay/internal/store"
1112 "gitbay.org/gitbay/internal/web"
1213)
@@ -58,7 +59,7 @@ func TestStylesheetURLCarriesTheBuildHash(t *testing.T) {
5859 Assigned []store.DashboardItem
5960 MRs []store.DashboardItem
6061 Issues []store.DashboardItem
61 Feed []feedLine
62 Feed []control.FeedLine
6263 }{base, "dashboard", nil, nil, nil, nil, nil, nil})
6364 if err != nil {
6465 t.Fatal(err)
internal/httpd/builds.go +1 −28
@@ -147,10 +147,6 @@ type buildRun struct {
147147 Builds []control.BuildOut
148148}
149149
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
154150// combinedStatus is the run's status: the worst of its builds' statuses,
155151// success only when every one of them is.
156152func combinedStatus(builds []control.BuildOut) string {
@@ -158,30 +154,7 @@ func combinedStatus(builds []control.BuildOut) string {
158154 for i, b := range builds {
159155 statuses[i] = b.Status
160156 }
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.
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"
157 return control.WorstStatus(statuses)
185158}
186159
187160// groupRuns folds consecutive builds of the same commit and the same
internal/httpd/dashpins_test.go +2 −1
@@ -4,6 +4,7 @@ import (
44 "strings"
55 "testing"
66
7 "gitbay.org/gitbay/internal/control"
78 "gitbay.org/gitbay/internal/store"
89 "gitbay.org/gitbay/internal/web"
910)
@@ -23,7 +24,7 @@ func TestDashboardTilesAndPins(t *testing.T) {
2324 Assigned []store.DashboardItem
2425 MRs []store.DashboardItem
2526 Issues []store.DashboardItem
26 Feed []feedLine
27 Feed []control.FeedLine
2728 }{base, "dashboard", []pinnedRow{
2829 {Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"},
2930 {Owner: "krz", Name: "old", Build: "cancelled"},
internal/httpd/ownerpage_test.go +2 −2
@@ -55,7 +55,7 @@ func TestOwnerPageTabOrder(t *testing.T) {
5555// it — not the whole history, which is what the atom feed is for.
5656func TestOwnerPageAboutCarriesTheGraphAndLog(t *testing.T) {
5757 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"}}
5959 out := renderOwner(t, "about", d)
6060 for _, want := range []string{"Christian Cleberg", "actgraph", "opened issue", "#12", "/cmc/activity.atom"} {
6161 if !strings.Contains(out, want) {
@@ -149,7 +149,7 @@ type ownerFixtureData struct {
149149 Self bool
150150 Snippets int
151151 Members []control.ProfileMember
152 Log []feedLine
152 Log []control.FeedLine
153153 Bookmarks []control.BookmarkOut
154154 SnippetRows []snippetRow
155155}
internal/httpd/web.go +5 −5
@@ -216,8 +216,8 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
216216 Assigned []store.DashboardItem
217217 MRs []store.DashboardItem
218218 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)})
221221}
222222
223223func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -467,7 +467,7 @@ const profileEvents = 30
467467// ActivityByDay and OrgActivityByDay respectively — a log that counted
468468// something else would contradict the total printed over it. Only the
469469// 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 {
471471 if tab != "about" {
472472 return nil
473473 }
@@ -490,7 +490,7 @@ func (s *Server) ownerFeed(tab, kind, name string) []feedLine {
490490 if err != nil {
491491 return nil
492492 }
493 return feedLines(events)
493 return control.FeedLines(events)
494494}
495495
496496// ownerPage is what owner.html renders against. It is a named type
@@ -508,7 +508,7 @@ type ownerPage struct {
508508 Orgs []control.ProfileMember
509509 Activity []activityWeek
510510 ActivityTotal int
511 Log []feedLine
511 Log []control.FeedLine
512512 Bookmarks []control.BookmarkOut
513513 SnippetRows []snippetRow
514514 SnippetsAll bool