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() {
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