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