Commit 332a13feaa
Verified · cmc ci/build: success ci/test: success ci/vuln: success
Layout: unified · split
internal/control/build.go +7 −7
| @@ -69,7 +69,7 @@ func init() { | ||
| 69 | 69 | Usage: "runner done <build-id> success|failure", SSHOnly: true, Run: runRunnerDone}) |
| 70 | 70 | } |
| 71 | 71 | |
| 72 | type buildOut struct { | |
| 72 | type BuildOut struct { | |
| 73 | 73 | Number int64 `json:"number"` |
| 74 | 74 | Job string `json:"job"` |
| 75 | 75 | Status string `json:"status"` |
| @@ -79,8 +79,8 @@ type buildOut struct { | ||
| 79 | 79 | FinishedAt string `json:"finished_at,omitempty"` |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | func buildToOut(b store.Build) buildOut { | |
| 83 | return buildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt} | |
| 82 | func buildToOut(b store.Build) BuildOut { | |
| 83 | return BuildOut{b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt} | |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | 86 | func buildRef(c *Ctx, args []string) (store.Repo, store.Build, int) { |
| @@ -114,7 +114,7 @@ func runBuildList(c *Ctx, args []string) int { | ||
| 114 | 114 | if err != nil { |
| 115 | 115 | return c.fail(protocol.ExitFailure, "%v", err) |
| 116 | 116 | } |
| 117 | var ds []buildOut | |
| 117 | var ds []BuildOut | |
| 118 | 118 | for _, b := range builds { |
| 119 | 119 | ds = append(ds, buildToOut(b)) |
| 120 | 120 | } |
| @@ -153,7 +153,7 @@ func runBuildLog(c *Ctx, args []string) int { | ||
| 153 | 153 | return protocol.ExitOK |
| 154 | 154 | } |
| 155 | 155 | |
| 156 | type jobOut struct { | |
| 156 | type JobOut struct { | |
| 157 | 157 | Name string `json:"name"` |
| 158 | 158 | Schedule string `json:"schedule,omitempty"` |
| 159 | 159 | Tags string `json:"tags,omitempty"` |
| @@ -192,9 +192,9 @@ func runBuildJobs(c *Ctx, args []string) int { | ||
| 192 | 192 | if code >= 0 { |
| 193 | 193 | return code |
| 194 | 194 | } |
| 195 | out := make([]jobOut, 0, len(jobs)) | |
| 195 | out := make([]JobOut, 0, len(jobs)) | |
| 196 | 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 | 199 | return c.emit(out, func(w io.Writer) { |
| 200 | 200 | for _, j := range out { |
internal/control/dashboard.go +64 −55
| @@ -24,9 +24,9 @@ func init() { | ||
| 24 | 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 | 28 | // client renders the aggregate without further reads. |
| 29 | type dashboardItem struct { | |
| 29 | type DashboardItem struct { | |
| 30 | 30 | Repo string `json:"repo"` |
| 31 | 31 | Number int64 `json:"number"` |
| 32 | 32 | Title string `json:"title"` |
| @@ -35,51 +35,60 @@ type dashboardItem struct { | ||
| 35 | 35 | UpdatedAt string `json:"updated_at"` |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | // PinnedOut is one pinned repository on the dashboard. | |
| 39 | type 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. | |
| 48 | type 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. | |
| 62 | type ServerOut struct { | |
| 63 | Commit string `json:"commit"` | |
| 64 | } | |
| 65 | ||
| 66 | // DashboardOut is what dashboard emits: the whole account aggregate in | |
| 67 | // one read. | |
| 68 | type 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 | ||
| 38 | 85 | func runDashboard(c *Ctx, args []string) int { |
| 39 | 86 | if len(args) != 0 { |
| 40 | 87 | return c.fail(protocol.ExitUsage, "usage: dashboard") |
| 41 | 88 | } |
| 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{}, | |
| 83 | 92 | } |
| 84 | 93 | |
| 85 | 94 | pinned, err := c.Store.PinnedRepos(c.User.ID) |
| @@ -95,7 +104,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 95 | 104 | continue |
| 96 | 105 | } |
| 97 | 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 | 110 | mrs, err := c.Store.DashboardMRs(c.User.ID) |
| @@ -103,7 +112,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 103 | 112 | return c.fail(protocol.ExitFailure, "%v", err) |
| 104 | 113 | } |
| 105 | 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 | 118 | reviews, err := c.Store.ReviewQueue(c.User.ID) |
| @@ -111,7 +120,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 111 | 120 | return c.fail(protocol.ExitFailure, "%v", err) |
| 112 | 121 | } |
| 113 | 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 | 126 | assigned, err := c.Store.AssignedIssues(c.User.ID) |
| @@ -119,7 +128,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 119 | 128 | return c.fail(protocol.ExitFailure, "%v", err) |
| 120 | 129 | } |
| 121 | 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 | 134 | issues, err := c.Store.DashboardIssues(c.User.ID) |
| @@ -127,7 +136,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 127 | 136 | return c.fail(protocol.ExitFailure, "%v", err) |
| 128 | 137 | } |
| 129 | 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 | 142 | events, err := c.Store.RecentEvents(c.User.ID, 20, 0) |
| @@ -141,11 +150,11 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 141 | 150 | return c.fail(protocol.ExitFailure, "%v", err) |
| 142 | 151 | } |
| 143 | 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 | 155 | d.Unread = c.Store.UnreadNotices(c.User.ID) |
| 147 | 156 | if c.User.IsAdmin { |
| 148 | d.Server = &serverOut{Commit: buildinfo.String()} | |
| 157 | d.Server = &ServerOut{Commit: buildinfo.String()} | |
| 149 | 158 | q, err := c.Store.QueueStatus() |
| 150 | 159 | if err != nil { |
| 151 | 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 | |
| 213 | func printDashboardItems(w io.Writer, items []dashboardItem, marker string) { | |
| 222 | func printDashboardItems(w io.Writer, items []DashboardItem, marker string) { | |
| 214 | 223 | for _, item := range items { |
| 215 | 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 | 229 | // back. |
| 221 | 230 | const feedDefaultLimit = 50 |
| 222 | 231 | |
| 223 | type feedOut struct { | |
| 232 | type FeedOut struct { | |
| 224 | 233 | ID int64 `json:"id"` |
| 225 | 234 | Repo string `json:"repo"` |
| 226 | 235 | Actor string `json:"actor,omitempty"` |
| @@ -229,10 +238,10 @@ type feedOut struct { | ||
| 229 | 238 | CreatedAt string `json:"created_at"` |
| 230 | 239 | } |
| 231 | 240 | |
| 232 | func feedOutputs(events []store.FeedEvent) []feedOut { | |
| 233 | ds := make([]feedOut, 0, len(events)) | |
| 241 | func feedOutputs(events []store.FeedEvent) []FeedOut { | |
| 242 | ds := make([]FeedOut, 0, len(events)) | |
| 234 | 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 | 245 | if json.Valid([]byte(e.Data)) { |
| 237 | 246 | d.Data = json.RawMessage(e.Data) |
| 238 | 247 | } |
internal/control/output_test.go added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 14 | func 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. | |
| 38 | func 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 | 162 | return p, nil |
| 163 | 163 | } |
| 164 | 164 | |
| 165 | type profileOut struct { | |
| 165 | type ProfileOut struct { | |
| 166 | 166 | Name string `json:"name"` |
| 167 | 167 | Kind string `json:"kind"` |
| 168 | 168 | Description string `json:"description,omitempty"` |
| @@ -176,24 +176,24 @@ type profileOut struct { | ||
| 176 | 176 | // they own that you can see, and how active they have been. The web |
| 177 | 177 | // read these straight out of the store, which kept them off every |
| 178 | 178 | // 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"` | |
| 183 | 183 | // ActivityTotal counts the same window the days cover. |
| 184 | 184 | ActivityTotal int `json:"activity_total"` |
| 185 | 185 | } |
| 186 | 186 | |
| 187 | type profileMember struct { | |
| 187 | type ProfileMember struct { | |
| 188 | 188 | Name string `json:"name"` |
| 189 | 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 | 193 | // metadata — topics, license, last commit — is here because a profile is |
| 194 | 194 | // a listing: a client that renders repositories without it is showing |
| 195 | 195 | // less than the web does, which is why the web kept its own copy. |
| 196 | type profileRepo struct { | |
| 196 | type ProfileRepo struct { | |
| 197 | 197 | Path string `json:"path"` |
| 198 | 198 | Visibility string `json:"visibility"` |
| 199 | 199 | Description string `json:"description,omitempty"` |
| @@ -204,9 +204,9 @@ type profileRepo struct { | ||
| 204 | 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 | 208 | // omitted; a client fills the calendar it wants to draw. |
| 209 | type activityDay struct { | |
| 209 | type ActivityDay struct { | |
| 210 | 210 | Date string `json:"date"` |
| 211 | 211 | Count int `json:"count"` |
| 212 | 212 | } |
| @@ -219,7 +219,7 @@ func ActivityWindow() string { | ||
| 219 | 219 | return end.AddDate(0, 0, -53*7+1).Format("2006-01-02") |
| 220 | 220 | } |
| 221 | 221 | |
| 222 | func emitProfile(c *Ctx, d profileOut) int { | |
| 222 | func emitProfile(c *Ctx, d ProfileOut) int { | |
| 223 | 223 | return c.emit(d, func(w io.Writer) { |
| 224 | 224 | fmt.Fprintf(w, "%s (%s)\n", d.Name, d.Kind) |
| 225 | 225 | if d.Description != "" { |
| @@ -268,8 +268,8 @@ func runProfileShow(c *Ctx, args []string) int { | ||
| 268 | 268 | if err != nil { |
| 269 | 269 | return c.fail(protocol.ExitFailure, "%v", err) |
| 270 | 270 | } |
| 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{}} | |
| 273 | 273 | |
| 274 | 274 | // Who they work with. Both lists are public on a profile — the web |
| 275 | 275 | // has always shown them — and neither exposes anything a member |
| @@ -280,7 +280,7 @@ func runProfileShow(c *Ctx, args []string) int { | ||
| 280 | 280 | return c.fail(protocol.ExitFailure, "%v", err) |
| 281 | 281 | } |
| 282 | 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 | 285 | } else { |
| 286 | 286 | members, err := c.Store.OrgMembers(id) |
| @@ -288,7 +288,7 @@ func runProfileShow(c *Ctx, args []string) int { | ||
| 288 | 288 | return c.fail(protocol.ExitFailure, "%v", err) |
| 289 | 289 | } |
| 290 | 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 | 311 | if err != nil { |
| 312 | 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 | 315 | Path: repo.Path(), |
| 316 | 316 | Visibility: repo.Visibility, |
| 317 | 317 | Description: gitutil.ReadDescription(dir), |
| @@ -338,7 +338,7 @@ func runProfileShow(c *Ctx, args []string) int { | ||
| 338 | 338 | } |
| 339 | 339 | sort.Strings(days) |
| 340 | 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 | 342 | d.ActivityTotal += counts[day] |
| 343 | 343 | } |
| 344 | 344 | return emitProfile(c, d) |
| @@ -367,9 +367,9 @@ func runProfileSet(c *Ctx, args []string) int { | ||
| 367 | 367 | if err := c.Store.SetOwnerProfile("user", c.User.ID, p); err != nil { |
| 368 | 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 | 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 | |
| 375 | 375 | func runOrgProfile(c *Ctx, args []string) int { |
| @@ -400,7 +400,7 @@ func runOrgProfile(c *Ctx, args []string) int { | ||
| 400 | 400 | if err := c.Store.SetOwnerProfile("org", org.ID, p); err != nil { |
| 401 | 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 | 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 | 289 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 290 | 290 | n := r.PathValue("n") |
| 291 | 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 | 293 | if code != protocol.ExitOK { |
| 294 | 294 | http.Error(w, msg, statusForExit(code)) |
| 295 | 295 | return |
| @@ -322,7 +322,7 @@ func (s *Server) mrEditSubmit(w http.ResponseWriter, r *http.Request, u store.Us | ||
| 322 | 322 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 323 | 323 | n := r.PathValue("n") |
| 324 | 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 | 326 | if code != protocol.ExitOK { |
| 327 | 327 | http.Error(w, msg, statusForExit(code)) |
| 328 | 328 | return |
| @@ -341,7 +341,7 @@ func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store | ||
| 341 | 341 | func (s *Server) commentSubmit(w http.ResponseWriter, r *http.Request, u store.User, noun, segment string) { |
| 342 | 342 | repoPath := r.PathValue("owner") + "/" + r.PathValue("repo") |
| 343 | 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 | 345 | if code != protocol.ExitOK { |
| 346 | 346 | http.Error(w, msg, statusForExit(code)) |
| 347 | 347 | return |
internal/httpd/admin.go +13 −7
| @@ -3,6 +3,7 @@ package httpd | ||
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | 5 | |
| 6 | "gitbay.org/gitbay/internal/control" | |
| 6 | 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 | 16 | s.notFound(w, r) |
| 16 | 17 | return |
| 17 | 18 | } |
| 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 | |
| 24 | 20 | if msg, ok := s.runControlInto(viewer, []string{"dashboard"}, &d); !ok { |
| 25 | 21 | http.Error(w, msg, http.StatusInternalServerError) |
| 26 | 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 | 34 | s.render(w, "admin.html", struct { |
| 29 | 35 | basePage |
| 30 | 36 | Tab string |
| 31 | 37 | Commit string |
| 32 | 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 @@ | ||
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "gitbay.org/gitbay/internal/control" | |
| 4 | 5 | "strings" |
| 5 | 6 | "testing" |
| 6 | 7 | |
| @@ -22,18 +23,18 @@ func TestBuildsPageRendersCommandOutput(t *testing.T) { | ||
| 22 | 23 | var sb strings.Builder |
| 23 | 24 | err := web.Render(&sb, "builds.html", struct { |
| 24 | 25 | repoPage |
| 25 | Builds []buildView | |
| 26 | Jobs []jobView | |
| 26 | Builds []control.BuildOut | |
| 27 | Jobs []control.JobOut | |
| 27 | 28 | CanWrite bool |
| 28 | 29 | Notice string |
| 29 | 30 | }{ |
| 30 | 31 | testRepoPage(), |
| 31 | []buildView{{ | |
| 32 | []control.BuildOut{{ | |
| 32 | 33 | Number: 60, Job: "build", Status: "success", |
| 33 | 34 | SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", |
| 34 | 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 | 38 | true, "", |
| 38 | 39 | }) |
| 39 | 40 | if err != nil { |
| @@ -54,11 +55,11 @@ func TestBuildPageRendersCommandOutput(t *testing.T) { | ||
| 54 | 55 | var sb strings.Builder |
| 55 | 56 | err := web.Render(&sb, "build.html", struct { |
| 56 | 57 | repoPage |
| 57 | Build buildView | |
| 58 | Build control.BuildOut | |
| 58 | 59 | Log string |
| 59 | 60 | }{ |
| 60 | 61 | testRepoPage(), |
| 61 | buildView{ | |
| 62 | control.BuildOut{ | |
| 62 | 63 | Number: 60, Job: "build", Status: "success", |
| 63 | 64 | SHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b", Ref: "cli-coverage", |
| 64 | 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 | ||
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | 5 | "strconv" |
| 6 | ) | |
| 7 | ||
| 8 | // buildView mirrors the build commands' JSON. The templates read these | |
| 9 | // names; nothing here touches the store. | |
| 10 | type 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 | |
| 20 | type 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 | ) | |
| 25 | 9 | |
| 26 | 10 | func (s *Server) builds(w http.ResponseWriter, r *http.Request) { |
| 27 | 11 | p, ok := s.repoFor(w, r, "") |
| @@ -31,18 +15,18 @@ func (s *Server) builds(w http.ResponseWriter, r *http.Request) { | ||
| 31 | 15 | p.Tab = "builds" |
| 32 | 16 | viewer := s.webViewer(r) |
| 33 | 17 | |
| 34 | var builds []buildView | |
| 18 | var builds []control.BuildOut | |
| 35 | 19 | s.runControlInto(viewer, []string{"build", "list", p.Repo.Path()}, &builds) |
| 36 | 20 | |
| 37 | 21 | // The jobs a trigger can name. A repo without a CI config has none; |
| 38 | 22 | // that is not an error for this page. |
| 39 | var jobs []jobView | |
| 23 | var jobs []control.JobOut | |
| 40 | 24 | s.runControlInto(viewer, []string{"build", "jobs", p.Repo.Path()}, &jobs) |
| 41 | 25 | |
| 42 | 26 | s.render(w, "builds.html", struct { |
| 43 | 27 | repoPage |
| 44 | Builds []buildView | |
| 45 | Jobs []jobView | |
| 28 | Builds []control.BuildOut | |
| 29 | Jobs []control.JobOut | |
| 46 | 30 | CanWrite bool |
| 47 | 31 | Notice string |
| 48 | 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 | 45 | n := r.PathValue("n") |
| 62 | 46 | viewer := s.webViewer(r) |
| 63 | 47 | |
| 64 | var b buildView | |
| 48 | var b control.BuildOut | |
| 65 | 49 | if _, ok := s.runControlInto(viewer, []string{"build", "show", p.Repo.Path(), n}, &b); !ok { |
| 66 | 50 | s.notFound(w, r) |
| 67 | 51 | return |
| @@ -70,7 +54,7 @@ func (s *Server) build(w http.ResponseWriter, r *http.Request) { | ||
| 70 | 54 | |
| 71 | 55 | s.render(w, "build.html", struct { |
| 72 | 56 | repoPage |
| 73 | Build buildView | |
| 57 | Build control.BuildOut | |
| 74 | 58 | Log string |
| 75 | 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 | 153 | return protocol.ExitOK, "" |
| 154 | 154 | } |
| 155 | 155 | |
| 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. | |
| 159 | func (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. | |
| 167 | func (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. | |
| 162 | func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code int, msg string) { | |
| 168 | 163 | var stdout, stderr bytes.Buffer |
| 169 | 164 | ctx := &control.Ctx{ |
| 170 | 165 | User: u, |
| @@ -180,8 +175,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i | ||
| 180 | 175 | } |
| 181 | 176 | code = control.Dispatch(ctx, argv) |
| 182 | 177 | var env struct { |
| 183 | Data map[string]any `json:"data"` | |
| 184 | Error string `json:"error"` | |
| 178 | Error string `json:"error"` | |
| 185 | 179 | } |
| 186 | 180 | json.Unmarshal(stdout.Bytes(), &env) |
| 187 | 181 | if code != protocol.ExitOK { |
| @@ -192,9 +186,9 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i | ||
| 192 | 186 | if m == "" { |
| 193 | 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 | 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 | 375 | // profileView is profile show's payload, shaped for the templates. The |
| 376 | 376 | // repo rows carry the same names the reporow partial reads, so a profile |
| 377 | 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. | |
| 378 | 381 | type 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 | ||
| 395 | type 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. | |
| 402 | 388 | type 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 | |
| 411 | 390 | } |
| 412 | 391 | |
| 413 | 392 | func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner } |
| 414 | 393 | func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name } |
| 394 | func (p profileRepoRow) Desc() string { return p.Description } | |
| 415 | 395 | |
| 416 | 396 | // ownerPage renders /{owner} for users and orgs: the repositories the |
| 417 | 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 | 434 | Profile store.Profile |
| 455 | 435 | AboutHTML template.HTML |
| 456 | 436 | Repos []profileRepoRow |
| 457 | Members []profileMember | |
| 458 | Orgs []profileMember | |
| 437 | Members []control.ProfileMember | |
| 438 | Orgs []control.ProfileMember | |
| 459 | 439 | Activity []activityWeek |
| 460 | 440 | ActivityTotal int |
| 461 | 441 | Teams []teamView |