Commit 332a13feaa

332a13feaa444362bb4cc872ca95c62ccafcb64c

parent: 5e9732fb1d

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 15:01 UTC

control: the payloads other surfaces decode are named types

v1.10.0 named four of them (Created, MRCreated, IssueShow, MRShow) and
fixed the web's data["number"].(float64). The rest of the remedy is the
part that still drifts: httpd declared its own copy of what a command
emits, so a field added to the command is silently absent on the page.

buildView, jobView and profileView (with profileMember and
profileRepoRow) were those copies. They become BuildOut, JobOut,
ProfileOut, ProfileRepo and ProfileMember in control, imported. The
profile row keeps a wrapper in httpd for the two names the reporow
partial asks for that the payload does not carry — OwnerName and Name are
split from a path — and Desc, which the payload spells Description.

dashboard's payload was an anonymous struct inside runDashboard, and the
admin page hand-copied the two blocks it reads. Both are DashboardOut
now; the admin-only blocks are pointers, so the handler says what it does
when they are absent instead of dereferencing on faith.

runControlJSON had no callers and dispatchJSON's map was discarded by all
three of its own. dispatchJSON returns the code and the message; the
payload comes from runControlInto, which decodes into the command's type.

The 82 remaining map[string]any emits are single-key acknowledgements no
surface decodes ({"removed": "x"}). Naming a type each would be churn for
its own sake; what mattered was the shapes another surface reads.

TestPayloadFieldsAreTagged and TestNamedPayloadsRoundTrip guard the tags,
since an untagged field is a capitalised key and a renamed one is a
silent absence.

Closes #126

Layout: unified · split

internal/control/build.go +7 −7
@@ -69,7 +69,7 @@ func init() {
6969 Usage: "runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone})
7070}
7171
72type buildOut struct {
72type BuildOut struct {
7373 Number int64 `json:"number"`
7474 Job string `json:"job"`
7575 Status string `json:"status"`
@@ -79,8 +79,8 @@ type buildOut struct {
7979 FinishedAt string `json:"finished_at,omitempty"`
8080}
8181
82func buildToOut(b store.Build) buildOut {
83 return buildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}
82func buildToOut(b store.Build) BuildOut {
83 return BuildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt}
8484}
8585
8686func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) {
@@ -114,7 +114,7 @@ func runBuildList(c *Ctx, args []string) int {
114114 if err != nil {
115115 return c.fail(protocol.ExitFailure, "%v", err)
116116 }
117 var ds []buildOut
117 var ds []BuildOut
118118 for _, b := range builds {
119119 ds = append(ds, buildToOut(b))
120120 }
@@ -153,7 +153,7 @@ func runBuildLog(c *Ctx, args []string) int {
153153 return protocol.ExitOK
154154}
155155
156type jobOut struct {
156type JobOut struct {
157157 Name string `json:"name"`
158158 Schedule string `json:"schedule,omitempty"`
159159 Tags string `json:"tags,omitempty"`
@@ -192,9 +192,9 @@ func runBuildJobs(c *Ctx, args []string) int {
192192 if code >= 0 {
193193 return code
194194 }
195 out := make([]jobOut, 0, len(jobs))
195 out := make([]JobOut, 0, len(jobs))
196196 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})
198198 }
199199 return c.emit(out, func(w io.Writer) {
200200 for _, j := range out {
internal/control/dashboard.go +64 −55
@@ -24,9 +24,9 @@ func init() {
2424 ReadOnly: true, Run: runFeed})
2525}
2626
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
2828// client renders the aggregate without further reads.
29type dashboardItem struct {
29type DashboardItem struct {
3030 Repo string `json:"repo"`
3131 Number int64 `json:"number"`
3232 Title string `json:"title"`
@@ -35,51 +35,60 @@ type dashboardItem struct {
3535 UpdatedAt string `json:"updated_at"`
3636}
3737
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
3885func runDashboard(c *Ctx, args []string) int {
3986 if len(args) != 0 {
4087 return c.fail(protocol.ExitUsage, "usage: dashboard")
4188 }
42 type pinnedOut struct {
43 Path string `json:"path"`
44 Visibility string `json:"visibility"`
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{},
89 d := DashboardOut{
90 Reviews: []DashboardItem{}, Assigned: []DashboardItem{}, MRs: []DashboardItem{},
91 Issues: []DashboardItem{}, Pinned: []PinnedOut{}, Activity: []FeedOut{}, Builds: []DashboardBuild{},
8392 }
8493
8594 pinned, err := c.Store.PinnedRepos(c.User.ID)
@@ -95,7 +104,7 @@ func runDashboard(c *Ctx, args []string) int {
95104 continue
96105 }
97106 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})
99108 }
100109
101110 mrs, err := c.Store.DashboardMRs(c.User.ID)
@@ -103,7 +112,7 @@ func runDashboard(c *Ctx, args []string) int {
103112 return c.fail(protocol.ExitFailure, "%v", err)
104113 }
105114 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})
107116 }
108117
109118 reviews, err := c.Store.ReviewQueue(c.User.ID)
@@ -111,7 +120,7 @@ func runDashboard(c *Ctx, args []string) int {
111120 return c.fail(protocol.ExitFailure, "%v", err)
112121 }
113122 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})
115124 }
116125
117126 assigned, err := c.Store.AssignedIssues(c.User.ID)
@@ -119,7 +128,7 @@ func runDashboard(c *Ctx, args []string) int {
119128 return c.fail(protocol.ExitFailure, "%v", err)
120129 }
121130 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})
123132 }
124133
125134 issues, err := c.Store.DashboardIssues(c.User.ID)
@@ -127,7 +136,7 @@ func runDashboard(c *Ctx, args []string) int {
127136 return c.fail(protocol.ExitFailure, "%v", err)
128137 }
129138 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})
131140 }
132141
133142 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
@@ -141,11 +150,11 @@ func runDashboard(c *Ctx, args []string) int {
141150 return c.fail(protocol.ExitFailure, "%v", err)
142151 }
143152 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})
145154 }
146155 d.Unread = c.Store.UnreadNotices(c.User.ID)
147156 if c.User.IsAdmin {
148 d.Server = &serverOut{Commit: buildinfo.String()}
157 d.Server = &ServerOut{Commit: buildinfo.String()}
149158 q, err := c.Store.QueueStatus()
150159 if err != nil {
151160 return c.fail(protocol.ExitFailure, "%v", err)
@@ -210,7 +219,7 @@ func runDashboard(c *Ctx, args []string) int {
210219 })
211220}
212221
213func printDashboardItems(w io.Writer, items []dashboardItem, marker string) {
222func printDashboardItems(w io.Writer, items []DashboardItem, marker string) {
214223 for _, item := range items {
215224 fmt.Fprintf(w, " %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author)
216225 }
@@ -220,7 +229,7 @@ func printDashboardItems(w io.Writer, items []dashboardItem, marker string) {
220229// back.
221230const feedDefaultLimit = 50
222231
223type feedOut struct {
232type FeedOut struct {
224233 ID int64 `json:"id"`
225234 Repo string `json:"repo"`
226235 Actor string `json:"actor,omitempty"`
@@ -229,10 +238,10 @@ type feedOut struct {
229238 CreatedAt string `json:"created_at"`
230239}
231240
232func feedOutputs(events []store.FeedEvent) []feedOut {
233 ds := make([]feedOut, 0, len(events))
241func feedOutputs(events []store.FeedEvent) []FeedOut {
242 ds := make([]FeedOut, 0, len(events))
234243 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}
236245 if json.Valid([]byte(e.Data)) {
237246 d.Data = json.RawMessage(e.Data)
238247 }
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) {
162162 return p, nil
163163}
164164
165type profileOut struct {
165type ProfileOut struct {
166166 Name string `json:"name"`
167167 Kind string `json:"kind"`
168168 Description string `json:"description,omitempty"`
@@ -176,24 +176,24 @@ type profileOut struct {
176176 // they own that you can see, and how active they have been. The web
177177 // read these straight out of the store, which kept them off every
178178 // other surface.
179 Orgs []profileMember `json:"orgs,omitempty"` // for a user
180 Members []profileMember `json:"members,omitempty"` // for an org
181 Repos []profileRepo `json:"repos"`
182 Activity []activityDay `json:"activity,omitempty"`
179 Orgs []ProfileMember `json:"orgs,omitempty"` // for a user
180 Members []ProfileMember `json:"members,omitempty"` // for an org
181 Repos []ProfileRepo `json:"repos"`
182 Activity []ActivityDay `json:"activity,omitempty"`
183183 // ActivityTotal counts the same window the days cover.
184184 ActivityTotal int `json:"activity_total"`
185185}
186186
187type profileMember struct {
187type ProfileMember struct {
188188 Name string `json:"name"`
189189 Role string `json:"role,omitempty"`
190190}
191191
192// profileRepo is one repository as a profile lists it. The listing
192// ProfileRepo is one repository as a profile lists it. The listing
193193// metadata — topics, license, last commit — is here because a profile is
194194// a listing: a client that renders repositories without it is showing
195195// less than the web does, which is why the web kept its own copy.
196type profileRepo struct {
196type ProfileRepo struct {
197197 Path string `json:"path"`
198198 Visibility string `json:"visibility"`
199199 Description string `json:"description,omitempty"`
@@ -204,9 +204,9 @@ type profileRepo struct {
204204 Archived bool `json:"archived,omitempty"`
205205}
206206
207// activityDay is one day's contribution count. Days with nothing are
207// ActivityDay is one day's contribution count. Days with nothing are
208208// omitted; a client fills the calendar it wants to draw.
209type activityDay struct {
209type ActivityDay struct {
210210 Date string `json:"date"`
211211 Count int `json:"count"`
212212}
@@ -219,7 +219,7 @@ func ActivityWindow() string {
219219 return end.AddDate(0, 0, -53*7+1).Format("2006-01-02")
220220}
221221
222func emitProfile(c *Ctx, d profileOut) int {
222func emitProfile(c *Ctx, d ProfileOut) int {
223223 return c.emit(d, func(w io.Writer) {
224224 fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind)
225225 if d.Description != "" {
@@ -268,8 +268,8 @@ func runProfileShow(c *Ctx, args []string) int {
268268 if err != nil {
269269 return c.fail(protocol.ExitFailure, "%v", err)
270270 }
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{}}
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{}}
273273
274274 // Who they work with. Both lists are public on a profile — the web
275275 // has always shown them — and neither exposes anything a member
@@ -280,7 +280,7 @@ func runProfileShow(c *Ctx, args []string) int {
280280 return c.fail(protocol.ExitFailure, "%v", err)
281281 }
282282 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})
284284 }
285285 } else {
286286 members, err := c.Store.OrgMembers(id)
@@ -288,7 +288,7 @@ func runProfileShow(c *Ctx, args []string) int {
288288 return c.fail(protocol.ExitFailure, "%v", err)
289289 }
290290 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})
292292 }
293293 }
294294
@@ -311,7 +311,7 @@ func runProfileShow(c *Ctx, args []string) int {
311311 if err != nil {
312312 return c.fail(protocol.ExitFailure, "%v", err)
313313 }
314 d.Repos = append(d.Repos, profileRepo{
314 d.Repos = append(d.Repos, ProfileRepo{
315315 Path: repo.Path(),
316316 Visibility: repo.Visibility,
317317 Description: gitutil.ReadDescription(dir),
@@ -338,7 +338,7 @@ func runProfileShow(c *Ctx, args []string) int {
338338 }
339339 sort.Strings(days)
340340 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]})
342342 d.ActivityTotal += counts[day]
343343 }
344344 return emitProfile(c, d)
@@ -367,9 +367,9 @@ func runProfileSet(c *Ctx, args []string) int {
367367 if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil {
368368 return c.fail(protocol.ExitFailure, "%v", err)
369369 }
370 return emitProfile(c, profileOut{Name: c.User.Username, Kind: "user",
370 return emitProfile(c, ProfileOut{Name: c.User.Username, Kind: "user",
371371 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{}})
373373}
374374
375375func runOrgProfile(c *Ctx, args []string) int {
@@ -400,7 +400,7 @@ func runOrgProfile(c *Ctx, args []string) int {
400400 if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil {
401401 return c.fail(protocol.ExitFailure, "%v", err)
402402 }
403 return emitProfile(c, profileOut{Name: org.Name, Kind: "org",
403 return emitProfile(c, ProfileOut{Name: org.Name, Kind: "org",
404404 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{}})
406406}
internal/httpd/accounts.go +3 −3
@@ -289,7 +289,7 @@ func (s *Server) issueEditSubmit(w http.ResponseWriter, r *http.Request, u store
289289 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
290290 n := r.PathValue("n")
291291 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"))
293293 if code != protocol.ExitOK {
294294 http.Error(w, msg, statusForExit(code))
295295 return
@@ -322,7 +322,7 @@ func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.Us
322322 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
323323 n := r.PathValue("n")
324324 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"))
326326 if code != protocol.ExitOK {
327327 http.Error(w, msg, statusForExit(code))
328328 return
@@ -341,7 +341,7 @@ func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store
341341func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) {
342342 repoPath := r.PathValue("owner") + "/" + r.PathValue("repo")
343343 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")))
345345 if code != protocol.ExitOK {
346346 http.Error(w, msg, statusForExit(code))
347347 return
internal/httpd/admin.go +13 −7
@@ -3,6 +3,7 @@ package httpd
33import (
44 "net/http"
55
6 "gitbay.org/gitbay/internal/control"
67 "gitbay.org/gitbay/internal/store"
78)
89
@@ -15,20 +16,25 @@ func (s *Server) adminPage(w http.ResponseWriter, r *http.Request, viewer store.
1516 s.notFound(w, r)
1617 return
1718 }
18 var d struct {
19 Server struct {
20 Commit string `json:"commit"`
21 } `json:"server"`
22 Queues store.Queues `json:"queues"`
23 }
19 var d control.DashboardOut
2420 if msg, ok := s.runControlInto(viewer, []string{"dashboard"}, &d); !ok {
2521 http.Error(w, msg, http.StatusInternalServerError)
2622 return
2723 }
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 }
2834 s.render(w, "admin.html", struct {
2935 basePage
3036 Tab string
3137 Commit string
3238 Queues store.Queues
33 }{s.baseFor(viewer), "admin", d.Server.Commit, d.Queues})
39 }{s.baseFor(viewer), "admin", commit, queues})
3440}
internal/httpd/buildpages_test.go +7 −6
@@ -1,6 +1,7 @@
11package httpd
22
33import (
4 "gitbay.org/gitbay/internal/control"
45 "strings"
56 "testing"
67
@@ -22,18 +23,18 @@ func TestBuildsPageRendersCommandOutput(t *testing.T) {
2223 var sb strings.Builder
2324 err := web.Render(&sb, "builds.html", struct {
2425 repoPage
25 Builds []buildView
26 Jobs []jobView
26 Builds []control.BuildOut
27 Jobs []control.JobOut
2728 CanWrite bool
2829 Notice string
2930 }{
3031 testRepoPage(),
31 []buildView{{
32 []control.BuildOut{{
3233 Number: 60, Job: "build", Status: "success",
3334 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b",
3435 Ref: "cli-coverage", CreatedAt: "2026-08-28T04:42:54Z",
3536 }},
36 []jobView{{Name: "build"}, {Name: "nightly", Schedule: "0 3 * * *"}},
37 []control.JobOut{{Name: "build"}, {Name: "nightly", Schedule: "0 3 * * *"}},
3738 true, "",
3839 })
3940 if err != nil {
@@ -54,11 +55,11 @@ func TestBuildPageRendersCommandOutput(t *testing.T) {
5455 var sb strings.Builder
5556 err := web.Render(&sb, "build.html", struct {
5657 repoPage
57 Build buildView
58 Build control.BuildOut
5859 Log string
5960 }{
6061 testRepoPage(),
61 buildView{
62 control.BuildOut{
6263 Number: 60, Job: "build", Status: "success",
6364 SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "cli-coverage",
6465 CreatedAt: "2026-08-28T04:42:54Z", FinishedAt: "2026-08-28T04:43:06Z",
internal/httpd/builds.go +8 −24
@@ -3,25 +3,9 @@ package httpd
33import (
44 "net/http"
55 "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}
196
20type jobView struct {
21 Name string `json:"name"`
22 Schedule string `json:"schedule"`
23 Tags string `json:"tags"`
24}
7 "gitbay.org/gitbay/internal/control"
8)
259
2610func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
2711 p, ok := s.repoFor(w, r, "")
@@ -31,18 +15,18 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) {
3115 p.Tab = "builds"
3216 viewer := s.webViewer(r)
3317
34 var builds []buildView
18 var builds []control.BuildOut
3519 s.runControlInto(viewer, []string{"build", "list", p.Repo.Path()}, &builds)
3620
3721 // The jobs a trigger can name. A repo without a CI config has none;
3822 // that is not an error for this page.
39 var jobs []jobView
23 var jobs []control.JobOut
4024 s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs)
4125
4226 s.render(w, "builds.html", struct {
4327 repoPage
44 Builds []buildView
45 Jobs []jobView
28 Builds []control.BuildOut
29 Jobs []control.JobOut
4630 CanWrite bool
4731 Notice string
4832 }{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) {
6145 n := r.PathValue("n")
6246 viewer := s.webViewer(r)
6347
64 var b buildView
48 var b control.BuildOut
6549 if _, ok := s.runControlInto(viewer, []string{"build", "show", p.Repo.Path(), n}, &b); !ok {
6650 s.notFound(w, r)
6751 return
@@ -70,7 +54,7 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) {
7054
7155 s.render(w, "build.html", struct {
7256 repoPage
73 Build buildView
57 Build control.BuildOut
7458 Log string
7559 }{p, b, log})
7660}
internal/httpd/control.go +10 −16
@@ -153,18 +153,13 @@ func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, ta
153153 return protocol.ExitOK, ""
154154}
155155
156// runControlJSON runs a command in JSON mode and returns its data object.
157// In JSON mode a failure is an envelope carrying the message rather than
158// stderr text, so both paths are read from the same envelope.
159func (s *Server) runControlJSON(u store.User, argv []string) (data map[string]any, msg string, ok bool) {
160 code, data, msg := s.dispatchJSON(u, argv, "")
161 return data, msg, code == protocol.ExitOK
162}
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) {
156// dispatchJSON runs a command in JSON mode with stdin and returns its exit
157// code and, on failure, the message. In JSON mode a failure is an envelope
158// carrying the message rather than stderr text, so both paths are read
159// from the same envelope. A handler that wants the payload uses
160// runControlInto, which decodes into the command's own type instead of a
161// map nothing type-checks.
162func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, msg string) {
168163 var stdout, stderr bytes.Buffer
169164 ctx := &control.Ctx{
170165 User: u,
@@ -180,8 +175,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i
180175 }
181176 code = control.Dispatch(ctx, argv)
182177 var env struct {
183 Data map[string]any `json:"data"`
184 Error string `json:"error"`
178 Error string `json:"error"`
185179 }
186180 json.Unmarshal(stdout.Bytes(), &env)
187181 if code != protocol.ExitOK {
@@ -192,9 +186,9 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i
192186 if m == "" {
193187 m = "the command failed"
194188 }
195 return code, nil, m
189 return code, m
196190 }
197 return code, env.Data, ""
191 return code, ""
198192}
199193
200194// 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 {
375375// profileView is profile show's payload, shaped for the templates. The
376376// repo rows carry the same names the reporow partial reads, so a profile
377377// 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.
378381type profileView struct {
379 Name string `json:"name"`
380 Kind string `json:"kind"`
381 Description string `json:"description"`
382 Website string `json:"website"`
383 About string `json:"about"`
384 AboutFormat string `json:"about_format"`
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.
382 control.ProfileOut
383 Repos []profileRepoRow `json:"repos"`
384}
385
386// profileRepoRow is one repository row on a profile. The partial asks for
387// OwnerName, Name and Desc; the payload carries a path and a description.
402388type profileRepoRow struct {
403 Path string `json:"path"`
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"`
389 control.ProfileRepo
411390}
412391
413392func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
414393func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name }
394func (p profileRepoRow) Desc() string { return p.Description }
415395
416396// ownerPage renders /{owner} for users and orgs: the repositories the
417397// 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) {
454434 Profile store.Profile
455435 AboutHTML template.HTML
456436 Repos []profileRepoRow
457 Members []profileMember
458 Orgs []profileMember
437 Members []control.ProfileMember
438 Orgs []control.ProfileMember
459439 Activity []activityWeek
460440 ActivityTotal int
461441 Teams []teamView