control: the payloads other surfaces decode are named types !232

merged merged by cmc on 2026-09-04 15:58 UTC · krz/gitbay:named-payloads into main

10 files changed, +203 −172

Layout: unified · split

internal/control/build.go +7 −7
@@ -69,7 +69,7 @@ func init() {
69 Usage: "runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone}) 69 Usage: "runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone})
70} 70}
71 71
72type buildOut struct { 72type BuildOut struct {
73 Number int64 `json:"number"` 73 Number int64 `json:"number"`
74 Job string `json:"job"` 74 Job string `json:"job"`
75 Status string `json:"status"` 75 Status string `json:"status"`
@@ -79,8 +79,8 @@ type buildOut struct {
79 FinishedAt string `json:"finished_at,omitempty"` 79 FinishedAt string `json:"finished_at,omitempty"`
80} 80}
81 81
82func buildToOut(b store.Build) buildOut { 82func buildToOut(b store.Build) BuildOut {
83 return buildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt} 83 return BuildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}
84} 84}
85 85
86func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { 86func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
@@ -114,7 +114,7 @@ func runBuildList(c *Ctx, args []string) int {
114 if err != nil { 114 if err != nil {
115 return c.fail(protocol.ExitFailure, "%v", err) 115 return c.fail(protocol.ExitFailure, "%v", err)
116 } 116 }
117 var ds []buildOut 117 var ds []BuildOut
118 for _, b := range builds { 118 for _, b := range builds {
119 ds = append(ds, buildToOut(b)) 119 ds = append(ds, buildToOut(b))
120 } 120 }
@@ -153,7 +153,7 @@ func runBuildLog(c *Ctx, args []string) int {
153 return protocol.ExitOK 153 return protocol.ExitOK
154} 154}
155 155
156type jobOut struct { 156type JobOut struct {
157 Name string `json:"name"` 157 Name string `json:"name"`
158 Schedule string `json:"schedule,omitempty"` 158 Schedule string `json:"schedule,omitempty"`
159 Tags string `json:"tags,omitempty"` 159 Tags string `json:"tags,omitempty"`
@@ -192,9 +192,9 @@ func runBuildJobs(c *Ctx, args []string) int {
192 if code >= 0 { 192 if code >= 0 {
193 return code 193 return code
194 } 194 }
195 out := make([]jobOut, 0, len(jobs)) 195 out := make([]JobOut, 0, len(jobs))
196 for _, j := range jobs { 196 for _, j := range jobs {
197 out = append(out, jobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags}) 197 out = append(out, JobOut{Name: j.Name, Schedule: j.Schedule, Tags: j.Tags})
198 } 198 }
199 return c.emit(out, func(w io.Writer) { 199 return c.emit(out, func(w io.Writer) {
200 for _, j := range out { 200 for _, j := range out {
internal/control/dashboard.go +64 −55
@@ -24,9 +24,9 @@ func init() {
24 ReadOnly: true, Run: runFeed}) 24 ReadOnly: true, Run: runFeed})
25} 25}
26 26
27// dashboardItem is one open issue or MR row, with its repo resolved so a 27// DashboardItem is one open issue or MR row, with its repo resolved so a
28// client renders the aggregate without further reads. 28// client renders the aggregate without further reads.
29type dashboardItem struct { 29type DashboardItem struct {
30 Repo string `json:"repo"` 30 Repo string `json:"repo"`
31 Number int64 `json:"number"` 31 Number int64 `json:"number"`
32 Title string `json:"title"` 32 Title string `json:"title"`
@@ -35,51 +35,60 @@ type dashboardItem struct {
35 UpdatedAt string `json:"updated_at"` 35 UpdatedAt string `json:"updated_at"`
36} 36}
37 37
38// PinnedOut is one pinned repository on the dashboard.
39type PinnedOut struct {
40 Path string `json:"path"`
41 Visibility string `json:"visibility"`
42 Description string `json:"description,omitempty"`
43 Archived bool `json:"archived,omitempty"`
44}
45
46// DashboardBuild is a build with its repository resolved, which is what
47// separates it from BuildOut: the dashboard spans repositories.
48type DashboardBuild struct {
49 Repo string `json:"repo"`
50 Number int64 `json:"number"`
51 Job string `json:"job"`
52 Status string `json:"status"`
53 SHA string `json:"sha"`
54 Ref string `json:"ref"`
55 CreatedAt string `json:"created_at"`
56 FinishedAt string `json:"finished_at,omitempty"`
57}
58
59// ServerOut is admin-only. The exact build a host is running narrows down
60// which known issues apply to it, so it is not everyone's to read; the
61// person who needs it is the operator.
62type ServerOut struct {
63 Commit string `json:"commit"`
64}
65
66// DashboardOut is what dashboard emits: the whole account aggregate in
67// one read.
68type DashboardOut struct {
69 Reviews []DashboardItem `json:"review_queue"`
70 Assigned []DashboardItem `json:"assigned_issues"`
71 MRs []DashboardItem `json:"open_mrs"`
72 Issues []DashboardItem `json:"open_issues"`
73 Pinned []PinnedOut `json:"pinned"`
74 Activity []FeedOut `json:"recent_activity"`
75 Builds []DashboardBuild `json:"builds"`
76 // Unread is the notification inbox badge, so a client showing one
77 // does not need a second read to fill it.
78 Unread int `json:"unread"`
79 Server *ServerOut `json:"server,omitempty"`
80 // Queues is admin-only: every background worker's backlog and
81 // failures, the operator's view of what is stuck.
82 Queues *store.Queues `json:"queues,omitempty"`
83}
84
38func runDashboard(c *Ctx, args []string) int { 85func runDashboard(c *Ctx, args []string) int {
39 if len(args) != 0 { 86 if len(args) != 0 {
40 return c.fail(protocol.ExitUsage, "usage: dashboard") 87 return c.fail(protocol.ExitUsage, "usage: dashboard")
41 } 88 }
42 type pinnedOut struct { 89 d := DashboardOut{
43 Path string `json:"path"` 90 Reviews: []DashboardItem{}, Assigned: []DashboardItem{}, MRs: []DashboardItem{},
44 Visibility string `json:"visibility"` 91 Issues: []DashboardItem{}, Pinned: []PinnedOut{}, Activity: []FeedOut{}, Builds: []DashboardBuild{},
45 Description string `json:"description,omitempty"`
46 Archived bool `json:"archived,omitempty"`
47 }
48 type buildOut struct {
49 Repo string `json:"repo"`
50 Number int64 `json:"number"`
51 Job string `json:"job"`
52 Status string `json:"status"`
53 SHA string `json:"sha"`
54 Ref string `json:"ref"`
55 CreatedAt string `json:"created_at"`
56 FinishedAt string `json:"finished_at,omitempty"`
57 }
58 // serverOut is admin-only. The exact build a host is running narrows down
59 // which known issues apply to it, so it is not everyone's to read; the
60 // person who needs it is the operator.
61 type serverOut struct {
62 Commit string `json:"commit"`
63 }
64 type out struct {
65 Reviews []dashboardItem `json:"review_queue"`
66 Assigned []dashboardItem `json:"assigned_issues"`
67 MRs []dashboardItem `json:"open_mrs"`
68 Issues []dashboardItem `json:"open_issues"`
69 Pinned []pinnedOut `json:"pinned"`
70 Activity []feedOut `json:"recent_activity"`
71 Builds []buildOut `json:"builds"`
72 // Unread is the notification inbox badge, so a client showing one
73 // does not need a second read to fill it.
74 Unread int `json:"unread"`
75 Server *serverOut `json:"server,omitempty"`
76 // Queues is admin-only: every background worker's backlog and
77 // failures, the operator's view of what is stuck.
78 Queues *store.Queues `json:"queues,omitempty"`
79 }
80 d := out{
81 Reviews: []dashboardItem{}, Assigned: []dashboardItem{}, MRs: []dashboardItem{},
82 Issues: []dashboardItem{}, Pinned: []pinnedOut{}, Activity: []feedOut{}, Builds: []buildOut{},
83 } 92 }
84 93
85 pinned, err := c.Store.PinnedRepos(c.User.ID) 94 pinned, err := c.Store.PinnedRepos(c.User.ID)
@@ -95,7 +104,7 @@ func runDashboard(c *Ctx, args []string) int {
95 continue 104 continue
96 } 105 }
97 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name)) 106 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
98 d.Pinned = append(d.Pinned, pinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived}) 107 d.Pinned = append(d.Pinned, PinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived})
99 } 108 }
100 109
101 mrs, err := c.Store.DashboardMRs(c.User.ID) 110 mrs, err := c.Store.DashboardMRs(c.User.ID)
@@ -103,7 +112,7 @@ func runDashboard(c *Ctx, args []string) int {
103 return c.fail(protocol.ExitFailure, "%v", err) 112 return c.fail(protocol.ExitFailure, "%v", err)
104 } 113 }
105 for _, m := range mrs { 114 for _, m := range mrs {
106 d.MRs = append(d.MRs, dashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt}) 115 d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
107 } 116 }
108 117
109 reviews, err := c.Store.ReviewQueue(c.User.ID) 118 reviews, err := c.Store.ReviewQueue(c.User.ID)
@@ -111,7 +120,7 @@ func runDashboard(c *Ctx, args []string) int {
111 return c.fail(protocol.ExitFailure, "%v", err) 120 return c.fail(protocol.ExitFailure, "%v", err)
112 } 121 }
113 for _, m := range reviews { 122 for _, m := range reviews {
114 d.Reviews = append(d.Reviews, dashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt}) 123 d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
115 } 124 }
116 125
117 assigned, err := c.Store.AssignedIssues(c.User.ID) 126 assigned, err := c.Store.AssignedIssues(c.User.ID)
@@ -119,7 +128,7 @@ func runDashboard(c *Ctx, args []string) int {
119 return c.fail(protocol.ExitFailure, "%v", err) 128 return c.fail(protocol.ExitFailure, "%v", err)
120 } 129 }
121 for _, i := range assigned { 130 for _, i := range assigned {
122 d.Assigned = append(d.Assigned, dashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt}) 131 d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
123 } 132 }
124 133
125 issues, err := c.Store.DashboardIssues(c.User.ID) 134 issues, err := c.Store.DashboardIssues(c.User.ID)
@@ -127,7 +136,7 @@ func runDashboard(c *Ctx, args []string) int {
127 return c.fail(protocol.ExitFailure, "%v", err) 136 return c.fail(protocol.ExitFailure, "%v", err)
128 } 137 }
129 for _, i := range issues { 138 for _, i := range issues {
130 d.Issues = append(d.Issues, dashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt}) 139 d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
131 } 140 }
132 141
133 events, err := c.Store.RecentEvents(c.User.ID, 20, 0) 142 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
@@ -141,11 +150,11 @@ func runDashboard(c *Ctx, args []string) int {
141 return c.fail(protocol.ExitFailure, "%v", err) 150 return c.fail(protocol.ExitFailure, "%v", err)
142 } 151 }
143 for _, b := range builds { 152 for _, b := range builds {
144 d.Builds = append(d.Builds, buildOut{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}) 153 d.Builds = append(d.Builds, DashboardBuild{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
145 } 154 }
146 d.Unread = c.Store.UnreadNotices(c.User.ID) 155 d.Unread = c.Store.UnreadNotices(c.User.ID)
147 if c.User.IsAdmin { 156 if c.User.IsAdmin {
148 d.Server = &serverOut{Commit: buildinfo.String()} 157 d.Server = &ServerOut{Commit: buildinfo.String()}
149 q, err := c.Store.QueueStatus() 158 q, err := c.Store.QueueStatus()
150 if err != nil { 159 if err != nil {
151 return c.fail(protocol.ExitFailure, "%v", err) 160 return c.fail(protocol.ExitFailure, "%v", err)
@@ -210,7 +219,7 @@ func runDashboard(c *Ctx, args []string) int {
210 }) 219 })
211} 220}
212 221
213func printDashboardItems(w io.Writer, items []dashboardItem, marker string) { 222func printDashboardItems(w io.Writer, items []DashboardItem, marker string) {
214 for _, item := range items { 223 for _, item := range items {
215 fmt.Fprintf(w, " %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author) 224 fmt.Fprintf(w, " %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author)
216 } 225 }
@@ -220,7 +229,7 @@ func printDashboardItems(w io.Writer, items []dashboardItem, marker string) {
220// back. 229// back.
221const feedDefaultLimit = 50 230const feedDefaultLimit = 50
222 231
223type feedOut struct { 232type FeedOut struct {
224 ID int64 `json:"id"` 233 ID int64 `json:"id"`
225 Repo string `json:"repo"` 234 Repo string `json:"repo"`
226 Actor string `json:"actor,omitempty"` 235 Actor string `json:"actor,omitempty"`
@@ -229,10 +238,10 @@ type feedOut struct {
229 CreatedAt string `json:"created_at"` 238 CreatedAt string `json:"created_at"`
230} 239}
231 240
232func feedOutputs(events []store.FeedEvent) []feedOut { 241func feedOutputs(events []store.FeedEvent) []FeedOut {
233 ds := make([]feedOut, 0, len(events)) 242 ds := make([]FeedOut, 0, len(events))
234 for _, e := range events { 243 for _, e := range events {
235 d := feedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt} 244 d := FeedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
236 if json.Valid([]byte(e.Data)) { 245 if json.Valid([]byte(e.Data)) {
237 d.Data = json.RawMessage(e.Data) 246 d.Data = json.RawMessage(e.Data)
238 } 247 }
internal/control/output_test.go added +57
@@ -0,0 +1,57 @@
1package control
2
3import (
4 "encoding/json"
5 "reflect"
6 "testing"
7)
8
9// TestNamedPayloadsRoundTrip asserts that every named payload survives a
10// marshal/unmarshal cycle with no field lost. A field added to the struct
11// but given no JSON tag, or given one that collides with another, drops
12// silently: the command still emits, the client still decodes, and the
13// value is simply missing. This is the failure #126 is about.
14func TestNamedPayloadsRoundTrip(t *testing.T) {
15 payloads := []any{
16 &Created{}, &MRCreated{}, &IssueShow{}, &MRShow{},
17 &BuildOut{}, &JobOut{}, &ProfileOut{}, &ProfileRepo{}, &ProfileMember{},
18 &DashboardOut{}, &DashboardItem{}, &DashboardBuild{}, &PinnedOut{},
19 &FeedOut{}, &ActivityDay{}, &SearchResult{}, &ReviewOut{}, &CheckOut{}, &CommitOut{},
20 }
21 for _, p := range payloads {
22 name := reflect.TypeOf(p).Elem().Name()
23 raw, err := json.Marshal(p)
24 if err != nil {
25 t.Errorf("%s does not marshal: %v", name, err)
26 continue
27 }
28 fresh := reflect.New(reflect.TypeOf(p).Elem()).Interface()
29 if err := json.Unmarshal(raw, fresh); err != nil {
30 t.Errorf("%s does not decode its own output: %v", name, err)
31 }
32 }
33}
34
35// TestPayloadFieldsAreTagged asserts every exported field on a payload
36// carries a json tag. Without one the key is the Go field name, which is
37// capitalised and drifts the moment the field is renamed.
38func TestPayloadFieldsAreTagged(t *testing.T) {
39 types := []any{
40 Created{}, MRCreated{}, BuildOut{}, JobOut{}, ProfileOut{}, ProfileRepo{},
41 ProfileMember{}, DashboardOut{}, DashboardItem{}, DashboardBuild{},
42 PinnedOut{}, FeedOut{}, ActivityDay{}, SearchResult{}, ReviewOut{},
43 CheckOut{}, CommitOut{}, ServerOut{},
44 }
45 for _, v := range types {
46 ty := reflect.TypeOf(v)
47 for i := 0; i < ty.NumField(); i++ {
48 f := ty.Field(i)
49 if !f.IsExported() || f.Anonymous {
50 continue
51 }
52 if _, ok := f.Tag.Lookup("json"); !ok {
53 t.Errorf("%s.%s has no json tag", ty.Name(), f.Name)
54 }
55 }
56 }
57}
internal/control/profile.go +21 −21
@@ -162,7 +162,7 @@ func applyProfile(p store.Profile, e profileEdit) (store.Profile, error) {
162 return p, nil 162 return p, nil
163} 163}
164 164
165type profileOut struct { 165type ProfileOut struct {
166 Name string `json:"name"` 166 Name string `json:"name"`
167 Kind string `json:"kind"` 167 Kind string `json:"kind"`
168 Description string `json:"description,omitempty"` 168 Description string `json:"description,omitempty"`
@@ -176,24 +176,24 @@ type profileOut struct {
176 // they own that you can see, and how active they have been. The web 176 // they own that you can see, and how active they have been. The web
177 // read these straight out of the store, which kept them off every 177 // read these straight out of the store, which kept them off every
178 // other surface. 178 // other surface.
179 Orgs []profileMember `json:"orgs,omitempty"` // for a user 179 Orgs []ProfileMember `json:"orgs,omitempty"` // for a user
180 Members []profileMember `json:"members,omitempty"` // for an org 180 Members []ProfileMember `json:"members,omitempty"` // for an org
181 Repos []profileRepo `json:"repos"` 181 Repos []ProfileRepo `json:"repos"`
182 Activity []activityDay `json:"activity,omitempty"` 182 Activity []ActivityDay `json:"activity,omitempty"`
183 // ActivityTotal counts the same window the days cover. 183 // ActivityTotal counts the same window the days cover.
184 ActivityTotal int `json:"activity_total"` 184 ActivityTotal int `json:"activity_total"`
185} 185}
186 186
187type profileMember struct { 187type ProfileMember struct {
188 Name string `json:"name"` 188 Name string `json:"name"`
189 Role string `json:"role,omitempty"` 189 Role string `json:"role,omitempty"`
190} 190}
191 191
192// profileRepo is one repository as a profile lists it. The listing 192// ProfileRepo is one repository as a profile lists it. The listing
193// metadata — topics, license, last commit — is here because a profile is 193// metadata — topics, license, last commit — is here because a profile is
194// a listing: a client that renders repositories without it is showing 194// a listing: a client that renders repositories without it is showing
195// less than the web does, which is why the web kept its own copy. 195// less than the web does, which is why the web kept its own copy.
196type profileRepo struct { 196type ProfileRepo struct {
197 Path string `json:"path"` 197 Path string `json:"path"`
198 Visibility string `json:"visibility"` 198 Visibility string `json:"visibility"`
199 Description string `json:"description,omitempty"` 199 Description string `json:"description,omitempty"`
@@ -204,9 +204,9 @@ type profileRepo struct {
204 Archived bool `json:"archived,omitempty"` 204 Archived bool `json:"archived,omitempty"`
205} 205}
206 206
207// activityDay is one day's contribution count. Days with nothing are 207// ActivityDay is one day's contribution count. Days with nothing are
208// omitted; a client fills the calendar it wants to draw. 208// omitted; a client fills the calendar it wants to draw.
209type activityDay struct { 209type ActivityDay struct {
210 Date string `json:"date"` 210 Date string `json:"date"`
211 Count int `json:"count"` 211 Count int `json:"count"`
212} 212}
@@ -219,7 +219,7 @@ func ActivityWindow() string {
219 return end.AddDate(0, 0, -53*7+1).Format("2006-01-02") 219 return end.AddDate(0, 0, -53*7+1).Format("2006-01-02")
220} 220}
221 221
222func emitProfile(c *Ctx, d profileOut) int { 222func emitProfile(c *Ctx, d ProfileOut) int {
223 return c.emit(d, func(w io.Writer) { 223 return c.emit(d, func(w io.Writer) {
224 fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind) 224 fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind)
225 if d.Description != "" { 225 if d.Description != "" {
@@ -268,8 +268,8 @@ func runProfileShow(c *Ctx, args []string) int {
268 if err != nil { 268 if err != nil {
269 return c.fail(protocol.ExitFailure, "%v", err) 269 return c.fail(protocol.ExitFailure, "%v", err)
270 } 270 }
271 d := profileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website, 271 d := ProfileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website,
272 About: p.About, AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}} 272 About: p.About, AboutFormat: p.AboutFormat, Links: p.Links, Repos: []ProfileRepo{}}
273 273
274 // Who they work with. Both lists are public on a profile — the web 274 // Who they work with. Both lists are public on a profile — the web
275 // has always shown them — and neither exposes anything a member 275 // has always shown them — and neither exposes anything a member
@@ -280,7 +280,7 @@ func runProfileShow(c *Ctx, args []string) int {
280 return c.fail(protocol.ExitFailure, "%v", err) 280 return c.fail(protocol.ExitFailure, "%v", err)
281 } 281 }
282 for _, o := range orgs { 282 for _, o := range orgs {
283 d.Orgs = append(d.Orgs, profileMember{Name: o.Username, Role: o.Role}) 283 d.Orgs = append(d.Orgs, ProfileMember{Name: o.Username, Role: o.Role})
284 } 284 }
285 } else { 285 } else {
286 members, err := c.Store.OrgMembers(id) 286 members, err := c.Store.OrgMembers(id)
@@ -288,7 +288,7 @@ func runProfileShow(c *Ctx, args []string) int {
288 return c.fail(protocol.ExitFailure, "%v", err) 288 return c.fail(protocol.ExitFailure, "%v", err)
289 } 289 }
290 for _, m := range members { 290 for _, m := range members {
291 d.Members = append(d.Members, profileMember{Name: m.Username, Role: m.Role}) 291 d.Members = append(d.Members, ProfileMember{Name: m.Username, Role: m.Role})
292 } 292 }
293 } 293 }
294 294
@@ -311,7 +311,7 @@ func runProfileShow(c *Ctx, args []string) int {
311 if err != nil { 311 if err != nil {
312 return c.fail(protocol.ExitFailure, "%v", err) 312 return c.fail(protocol.ExitFailure, "%v", err)
313 } 313 }
314 d.Repos = append(d.Repos, profileRepo{ 314 d.Repos = append(d.Repos, ProfileRepo{
315 Path: repo.Path(), 315 Path: repo.Path(),
316 Visibility: repo.Visibility, 316 Visibility: repo.Visibility,
317 Description: gitutil.ReadDescription(dir), 317 Description: gitutil.ReadDescription(dir),
@@ -338,7 +338,7 @@ func runProfileShow(c *Ctx, args []string) int {
338 } 338 }
339 sort.Strings(days) 339 sort.Strings(days)
340 for _, day := range days { 340 for _, day := range days {
341 d.Activity = append(d.Activity, activityDay{Date: day, Count: counts[day]}) 341 d.Activity = append(d.Activity, ActivityDay{Date: day, Count: counts[day]})
342 d.ActivityTotal += counts[day] 342 d.ActivityTotal += counts[day]
343 } 343 }
344 return emitProfile(c, d) 344 return emitProfile(c, d)
@@ -367,9 +367,9 @@ func runProfileSet(c *Ctx, args []string) int {
367 if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil { 367 if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil {
368 return c.fail(protocol.ExitFailure, "%v", err) 368 return c.fail(protocol.ExitFailure, "%v", err)
369 } 369 }
370 return emitProfile(c, profileOut{Name: c.User.Username, Kind: "user", 370 return emitProfile(c, ProfileOut{Name: c.User.Username, Kind: "user",
371 Description: p.Description, Website: p.Website, About: p.About, 371 Description: p.Description, Website: p.Website, About: p.About,
372 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}}) 372 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []ProfileRepo{}})
373} 373}
374 374
375func runOrgProfile(c *Ctx, args []string) int { 375func runOrgProfile(c *Ctx, args []string) int {
@@ -400,7 +400,7 @@ func runOrgProfile(c *Ctx, args []string) int {
400 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil { 400 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
401 return c.fail(protocol.ExitFailure, "%v", err) 401 return c.fail(protocol.ExitFailure, "%v", err)
402 } 402 }
403 return emitProfile(c, profileOut{Name: org.Name, Kind: "org", 403 return emitProfile(c, ProfileOut{Name: org.Name, Kind: "org",
404 Description: p.Description, Website: p.Website, About: p.About, 404 Description: p.Description, Website: p.Website, About: p.About,
405 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}}) 405 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []ProfileRepo{}})
406} 406}
internal/httpd/accounts.go +3 −3
@@ -289,7 +289,7 @@ func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store
289 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") 289 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
290 n := r.PathValue("n") 290 n := r.PathValue("n")
291 title := strings.TrimSpace(r.FormValue("title")) 291 title := strings.TrimSpace(r.FormValue("title"))
292 code, _, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body")) 292 code, msg := s.dispatchJSON(u, []string{"issue", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
293 if code != protocol.ExitOK { 293 if code != protocol.ExitOK {
294 http.Error(w, msg, statusForExit(code)) 294 http.Error(w, msg, statusForExit(code))
295 return 295 return
@@ -322,7 +322,7 @@ func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.Us
322 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") 322 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
323 n := r.PathValue("n") 323 n := r.PathValue("n")
324 title := strings.TrimSpace(r.FormValue("title")) 324 title := strings.TrimSpace(r.FormValue("title"))
325 code, _, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body")) 325 code, msg := s.dispatchJSON(u, []string{"mr", "edit", repoPath, n, "--title", title, "--file", "-"}, r.FormValue("body"))
326 if code != protocol.ExitOK { 326 if code != protocol.ExitOK {
327 http.Error(w, msg, statusForExit(code)) 327 http.Error(w, msg, statusForExit(code))
328 return 328 return
@@ -341,7 +341,7 @@ func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store
341func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) { 341func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
342 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") 342 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
343 n := r.PathValue("n") 343 n := r.PathValue("n")
344 code, _, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body"))) 344 code, msg := s.dispatchJSON(u, []string{noun, "comment", repoPath, n, "--file", "-"}, strings.TrimSpace(r.FormValue("body")))
345 if code != protocol.ExitOK { 345 if code != protocol.ExitOK {
346 http.Error(w, msg, statusForExit(code)) 346 http.Error(w, msg, statusForExit(code))
347 return 347 return
internal/httpd/admin.go +13 −7
@@ -3,6 +3,7 @@ package httpd
3import ( 3import (
4 "net/http" 4 "net/http"
5 5
6 "gitbay.org/gitbay/internal/control"
6 "gitbay.org/gitbay/internal/store" 7 "gitbay.org/gitbay/internal/store"
7) 8)
8 9
@@ -15,20 +16,25 @@ func (s *Server) adminPage(w http.ResponseWriter, r *http.Request, viewer store.
15 s.notFound(w, r) 16 s.notFound(w, r)
16 return 17 return
17 } 18 }
18 var d struct { 19 var d control.DashboardOut
19 Server struct {
20 Commit string `json:"commit"`
21 } `json:"server"`
22 Queues store.Queues `json:"queues"`
23 }
24 if msg, ok := s.runControlInto(viewer, []string{"dashboard"}, &d); !ok { 20 if msg, ok := s.runControlInto(viewer, []string{"dashboard"}, &d); !ok {
25 http.Error(w, msg, http.StatusInternalServerError) 21 http.Error(w, msg, http.StatusInternalServerError)
26 return 22 return
27 } 23 }
24 // Both blocks are admin-only and this handler is admin-gated, so they
25 // are present; the payload types them as optional because everyone
26 // else's dashboard omits them.
27 commit, queues := "", store.Queues{}
28 if d.Server != nil {
29 commit = d.Server.Commit
30 }
31 if d.Queues != nil {
32 queues = *d.Queues
33 }
28 s.render(w, "admin.html", struct { 34 s.render(w, "admin.html", struct {
29 basePage 35 basePage
30 Tab string 36 Tab string
31 Commit string 37 Commit string
32 Queues store.Queues 38 Queues store.Queues
33 }{s.baseFor(viewer), "admin", d.Server.Commit, d.Queues}) 39 }{s.baseFor(viewer), "admin", commit, queues})
34} 40}
internal/httpd/buildpages_test.go +7 −6
@@ -1,6 +1,7 @@
1package httpd 1package httpd
2 2
3import ( 3import (
4 "gitbay.org/gitbay/internal/control"
4 "strings" 5 "strings"
5 "testing" 6 "testing"
6 7
@@ -22,18 +23,18 @@ func TestBuildsPageRendersCommandOutput(t *testing.T) {
22 var sb strings.Builder 23 var sb strings.Builder
23 err := web.Render(&sb, "builds.html", struct { 24 err := web.Render(&sb, "builds.html", struct {
24 repoPage 25 repoPage
25 Builds []buildView 26 Builds []control.BuildOut
26 Jobs []jobView 27 Jobs []control.JobOut
27 CanWrite bool 28 CanWrite bool
28 Notice string 29 Notice string
29 }{ 30 }{
30 testRepoPage(), 31 testRepoPage(),
31 []buildView{{ 32 []control.BuildOut{{
32 Number: 60, Job: "build", Status: "success", 33 Number: 60, Job: "build", Status: "success",
33 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", 34 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b",
34 Ref: "cli-coverage", CreatedAt: "2026-08-28T04:42:54Z", 35 Ref: "cli-coverage", CreatedAt: "2026-08-28T04:42:54Z",
35 }}, 36 }},
36 []jobView{{Name: "build"}, {Name: "nightly", Schedule: "0 3 * * *"}}, 37 []control.JobOut{{Name: "build"}, {Name: "nightly", Schedule: "0 3 * * *"}},
37 true, "", 38 true, "",
38 }) 39 })
39 if err != nil { 40 if err != nil {
@@ -54,11 +55,11 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
54 var sb strings.Builder 55 var sb strings.Builder
55 err := web.Render(&sb, "build.html", struct { 56 err := web.Render(&sb, "build.html", struct {
56 repoPage 57 repoPage
57 Build buildView 58 Build control.BuildOut
58 Log string 59 Log string
59 }{ 60 }{
60 testRepoPage(), 61 testRepoPage(),
61 buildView{ 62 control.BuildOut{
62 Number: 60, Job: "build", Status: "success", 63 Number: 60, Job: "build", Status: "success",
63 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "cli-coverage", 64 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "cli-coverage",
64 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z", 65 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z",
internal/httpd/builds.go +8 −24
@@ -3,25 +3,9 @@ package httpd
3import ( 3import (
4 "net/http" 4 "net/http"
5 "strconv" 5 "strconv"
6)
7
8// buildView mirrors the build commands' JSON. The templates read these
9// names; nothing here touches the store.
10type buildView struct {
11 Number int64 `json:"number"`
12 Job string `json:"job"`
13 Status string `json:"status"`
14 SHA string `json:"sha"`
15 Ref string `json:"ref"`
16 CreatedAt string `json:"created_at"`
17 FinishedAt string `json:"finished_at"`
18}
19 6
20type jobView struct { 7 "gitbay.org/gitbay/internal/control"
21 Name string `json:"name"` 8)
22 Schedule string `json:"schedule"`
23 Tags string `json:"tags"`
24}
25 9
26func (s *Server) builds(w http.ResponseWriter, r *http.Request) { 10func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
27 p, ok := s.repoFor(w, r, "") 11 p, ok := s.repoFor(w, r, "")
@@ -31,18 +15,18 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
31 p.Tab = "builds" 15 p.Tab = "builds"
32 viewer := s.webViewer(r) 16 viewer := s.webViewer(r)
33 17
34 var builds []buildView 18 var builds []control.BuildOut
35 s.runControlInto(viewer, []string{"build", "list", p.Repo.Path()}, &builds) 19 s.runControlInto(viewer, []string{"build", "list", p.Repo.Path()}, &builds)
36 20
37 // The jobs a trigger can name. A repo without a CI config has none; 21 // The jobs a trigger can name. A repo without a CI config has none;
38 // that is not an error for this page. 22 // that is not an error for this page.
39 var jobs []jobView 23 var jobs []control.JobOut
40 s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs) 24 s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs)
41 25
42 s.render(w, "builds.html", struct { 26 s.render(w, "builds.html", struct {
43 repoPage 27 repoPage
44 Builds []buildView 28 Builds []control.BuildOut
45 Jobs []jobView 29 Jobs []control.JobOut
46 CanWrite bool 30 CanWrite bool
47 Notice string 31 Notice string
48 }{p, builds, jobs, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)}) 32 }{p, builds, jobs, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
@@ -61,7 +45,7 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) {
61 n := r.PathValue("n") 45 n := r.PathValue("n")
62 viewer := s.webViewer(r) 46 viewer := s.webViewer(r)
63 47
64 var b buildView 48 var b control.BuildOut
65 if _, ok := s.runControlInto(viewer, []string{"build", "show", p.Repo.Path(), n}, &b); !ok { 49 if _, ok := s.runControlInto(viewer, []string{"build", "show", p.Repo.Path(), n}, &b); !ok {
66 s.notFound(w, r) 50 s.notFound(w, r)
67 return 51 return
@@ -70,7 +54,7 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) {
70 54
71 s.render(w, "build.html", struct { 55 s.render(w, "build.html", struct {
72 repoPage 56 repoPage
73 Build buildView 57 Build control.BuildOut
74 Log string 58 Log string
75 }{p, b, log}) 59 }{p, b, log})
76} 60}
internal/httpd/control.go +10 −16
@@ -153,18 +153,13 @@ func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, ta
153 return protocol.ExitOK, "" 153 return protocol.ExitOK, ""
154} 154}
155 155
156// runControlJSON runs a command in JSON mode and returns its data object. 156// dispatchJSON runs a command in JSON mode with stdin and returns its exit
157// In JSON mode a failure is an envelope carrying the message rather than 157// code and, on failure, the message. In JSON mode a failure is an envelope
158// stderr text, so both paths are read from the same envelope. 158// carrying the message rather than stderr text, so both paths are read
159func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]any, msg string, ok bool) { 159// from the same envelope. A handler that wants the payload uses
160 code, data, msg := s.dispatchJSON(u, argv, "") 160// runControlInto, which decodes into the command's own type instead of a
161 return data, msg, code == protocol.ExitOK 161// map nothing type-checks.
162} 162func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, msg string) {
163
164// dispatchJSON runs a command in JSON mode with stdin, and returns its
165// exit code with the decoded data or the failure message. Handlers that
166// answer a form use the code to pick an HTTP status.
167func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, data map[string]any, msg string) {
168 var stdout, stderr bytes.Buffer 163 var stdout, stderr bytes.Buffer
169 ctx := &control.Ctx{ 164 ctx := &control.Ctx{
170 User: u, 165 User: u,
@@ -180,8 +175,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i
180 } 175 }
181 code = control.Dispatch(ctx, argv) 176 code = control.Dispatch(ctx, argv)
182 var env struct { 177 var env struct {
183 Data map[string]any `json:"data"` 178 Error string `json:"error"`
184 Error string `json:"error"`
185 } 179 }
186 json.Unmarshal(stdout.Bytes(), &env) 180 json.Unmarshal(stdout.Bytes(), &env)
187 if code != protocol.ExitOK { 181 if code != protocol.ExitOK {
@@ -192,9 +186,9 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i
192 if m == "" { 186 if m == "" {
193 m = "the command failed" 187 m = "the command failed"
194 } 188 }
195 return code, nil, m 189 return code, m
196 } 190 }
197 return code, env.Data, "" 191 return code, ""
198} 192}
199 193
200// authorNames maps commit author addresses to account names for one 194// authorNames maps commit author addresses to account names for one
internal/httpd/web.go +13 −33
@@ -375,43 +375,23 @@ func crumbs(p repoPage, kind, filePath string) []crumb {
375// profileView is profile show's payload, shaped for the templates. The 375// profileView is profile show's payload, shaped for the templates. The
376// repo rows carry the same names the reporow partial reads, so a profile 376// repo rows carry the same names the reporow partial reads, so a profile
377// listing renders identically to explore's. 377// listing renders identically to explore's.
378// profileView is profile show's payload with the repository rows wrapped
379// so the reporow partial can reach them. The fields themselves are the
380// command's: a field it gains appears here without being re-declared.
378type profileView struct { 381type profileView struct {
379 Name string `json:"name"` 382 control.ProfileOut
380 Kind string `json:"kind"` 383 Repos []profileRepoRow `json:"repos"`
381 Description string `json:"description"` 384}
382 Website string `json:"website"` 385
383 About string `json:"about"` 386// profileRepoRow is one repository row on a profile. The partial asks for
384 AboutFormat string `json:"about_format"` 387// OwnerName, Name and Desc; the payload carries a path and a description.
385 Links []store.ProfileLink `json:"links"`
386 Orgs []profileMember `json:"orgs"`
387 Members []profileMember `json:"members"`
388 Repos []profileRepoRow `json:"repos"`
389 Activity []struct {
390 Date string `json:"date"`
391 Count int `json:"count"`
392 } `json:"activity"`
393}
394
395type profileMember struct {
396 Name string `json:"name"`
397 Role string `json:"role"`
398}
399
400// profileRepoRow is one repository row on a profile. Path arrives as
401// owner/name; OwnerName and Name are split out for the partial.
402type profileRepoRow struct { 388type profileRepoRow struct {
403 Path string `json:"path"` 389 control.ProfileRepo
404 Visibility string `json:"visibility"`
405 Desc string `json:"description"`
406 DefaultBranch string `json:"default_branch"`
407 Topics []string `json:"topics"`
408 License string `json:"license"`
409 Updated string `json:"updated"`
410 Archived bool `json:"archived"`
411} 390}
412 391
413func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner } 392func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
414func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name } 393func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name }
394func (p profileRepoRow) Desc() string { return p.Description }
415 395
416// ownerPage renders /{owner} for users and orgs: the repositories the 396// ownerPage renders /{owner} for users and orgs: the repositories the
417// viewer may see, org membership either direction. Owner names are not 397// viewer may see, org membership either direction. Owner names are not
@@ -454,8 +434,8 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
454 Profile store.Profile 434 Profile store.Profile
455 AboutHTML template.HTML 435 AboutHTML template.HTML
456 Repos []profileRepoRow 436 Repos []profileRepoRow
457 Members []profileMember 437 Members []control.ProfileMember
458 Orgs []profileMember 438 Orgs []control.ProfileMember
459 Activity []activityWeek 439 Activity []activityWeek
460 ActivityTotal int 440 ActivityTotal int
461 Teams []teamView 441 Teams []teamView