A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 12c92d4c91

12c92d4c912abc236f20b4fabc97dfaa2ec06d08

parent: 9725e1b1ca

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-27T06:01:15Z

control: dashboard command for the account aggregate

One read returning pinned repos (visibility-checked), open merge
requests, assigned issues, and recent builds for the calling account —
the same queries the web dashboard runs. Replaces the per-repo fan-out
that drained the API rate bucket at ~66 repos.

Adds store.RecentBuilds and factors the repo reachability condition out
of the event feed query for it to share.

Closes #41
cmd/gitbay/main.go +2
@@ -36,6 +36,8 @@ func main() {
3636 pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}),
3737 pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}),
3838 ),
39 pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds",
40 passOpts{server: []string{"dashboard"}}),
3941 repoCmd(),
4042 issueCmd(),
4143 milestoneCmd(),
e2e/dashboard_test.go +125
@@ -119,6 +119,131 @@ func TestDashboard(t *testing.T) {
119119 }
120120 }
121121
122// The dashboard control command returns the same aggregate as the web
123// dashboard — pinned repos, open MRs, assigned issues, recent builds — in
124// one read.
125func TestDashboardCommand(t *testing.T) {
126 inst := startInstance(t)
127 aliceKey := inst.newKey(t, "alice")
128 bobKey := inst.newKey(t, "bob")
129 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub")
130 inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
131
132 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
133 t.Fatalf("repo create: %s", errOut)
134 }
135 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 {
136 t.Fatal("grant failed")
137 }
138 work := t.TempDir()
139 env := inst.gitEnv(aliceKey)
140 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
141 dir := filepath.Join(work, "w")
142 os.MkdirAll(filepath.Join(dir, ".gitbay"), 0o755)
143 os.WriteFile(filepath.Join(dir, ".gitbay", "ci.yml"), []byte(
144 "jobs:\n test:\n steps:\n - echo ok\n"), 0o644)
145 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
146 mustGit(t, dir, env, "add", ".")
147 mustGit(t, dir, env, "commit", "-q", "-m", "base")
148 mustGit(t, dir, env, "push", "-q", "origin", "main")
149 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
150 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
151 mustGit(t, dir, env, "add", ".")
152 mustGit(t, dir, env, "commit", "-q", "-m", "feat")
153 mustGit(t, dir, env, "push", "-q", "origin", "feat")
154
155 if _, _, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app",
156 "--source", "feat", "--target", "main", "--title", "'from bob'"); code != 0 {
157 t.Fatal("mr create failed")
158 }
159 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'todo one'"); code != 0 {
160 t.Fatal("issue create failed")
161 }
162 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "assign", "alice/app", "1", "--add", "alice"); code != 0 {
163 t.Fatal("assign failed")
164 }
165 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "pin", "alice/app"); code != 0 {
166 t.Fatal("pin failed")
167 }
168 if _, _, code := inst.ssh(t, bobKey, "", "repo", "pin", "alice/app"); code != 0 {
169 t.Fatal("bob pin failed")
170 }
171
172 out, errOut, code := inst.ssh(t, aliceKey, "", "dashboard", "--json")
173 if code != 0 {
174 t.Fatalf("dashboard: %s", errOut)
175 }
176 var env2 struct {
177 Data struct {
178 Pinned []struct {
179 Path string `json:"path"`
180 } `json:"pinned"`
181 MRs []struct {
182 Repo string `json:"repo"`
183 Number int64 `json:"number"`
184 Title string `json:"title"`
185 Author string `json:"author"`
186 State string `json:"state"`
187 } `json:"open_mrs"`
188 Assigned []struct {
189 Repo string `json:"repo"`
190 Number int64 `json:"number"`
191 Title string `json:"title"`
192 } `json:"assigned_issues"`
193 Builds []struct {
194 Repo string `json:"repo"`
195 Job string `json:"job"`
196 Status string `json:"status"`
197 Ref string `json:"ref"`
198 } `json:"builds"`
199 } `json:"data"`
200 }
201 if err := json.Unmarshal([]byte(out), &env2); err != nil {
202 t.Fatalf("bad json: %v\n%s", err, out)
203 }
204 d := env2.Data
205 if len(d.Pinned) != 1 || d.Pinned[0].Path != "alice/app" {
206 t.Fatalf("pinned = %+v", d.Pinned)
207 }
208 if len(d.MRs) != 1 || d.MRs[0].Repo != "alice/app" || d.MRs[0].Number != 1 ||
209 d.MRs[0].Title != "from bob" || d.MRs[0].Author != "bob" || d.MRs[0].State != "open" {
210 t.Fatalf("open_mrs = %+v", d.MRs)
211 }
212 if len(d.Assigned) != 1 || d.Assigned[0].Repo != "alice/app" || d.Assigned[0].Number != 1 ||
213 d.Assigned[0].Title != "todo one" {
214 t.Fatalf("assigned_issues = %+v", d.Assigned)
215 }
216 // Both pushes hit main and feat; each queues the ci.yml job.
217 if len(d.Builds) == 0 || d.Builds[0].Repo != "alice/app" || d.Builds[0].Job != "test" ||
218 d.Builds[0].Status != "pending" {
219 t.Fatalf("builds = %+v", d.Builds)
220 }
221
222 // Bob is not assigned and pinned repos he can no longer read disappear.
223 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "visibility", "alice/app", "private"); code != 0 {
224 t.Fatal("visibility failed")
225 }
226 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "revoke", "alice/app", "bob"); code != 0 {
227 t.Fatal("revoke failed")
228 }
229 out, _, code = inst.ssh(t, bobKey, "", "dashboard", "--json")
230 if code != 0 {
231 t.Fatal("bob dashboard failed")
232 }
233 if err := json.Unmarshal([]byte(out), &env2); err != nil {
234 t.Fatalf("bad json: %v\n%s", err, out)
235 }
236 if len(env2.Data.Pinned) != 0 {
237 t.Fatalf("bob still sees pinned = %+v", env2.Data.Pinned)
238 }
239 if len(env2.Data.Assigned) != 0 {
240 t.Fatalf("bob assigned = %+v", env2.Data.Assigned)
241 }
242 if len(env2.Data.Builds) != 0 {
243 t.Fatalf("bob builds = %+v", env2.Data.Builds)
244 }
245}
246
122247 // TestDashboardQueues covers the parts of the dashboard that answer "what
123248 // needs me": the review queue, assigned issues, and the activity feed.
124249 func TestDashboardQueues(t *testing.T) {
internal/control/dashboard.go added +119
@@ -0,0 +1,119 @@
1package control
2
3import (
4 "fmt"
5 "io"
6
7 "gitbay.org/gitbay/internal/gitutil"
8 "gitbay.org/gitbay/internal/policy"
9 "gitbay.org/gitbay/internal/protocol"
10)
11
12func init() {
13 register(Command{Path: []string{"dashboard"},
14 Summary: "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds",
15 ReadOnly: true, Run: runDashboard})
16}
17
18// dashboardItem is one open issue or MR row, with its repo resolved so a
19// client renders the aggregate without further reads.
20type dashboardItem struct {
21 Repo string `json:"repo"`
22 Number int64 `json:"number"`
23 Title string `json:"title"`
24 Author string `json:"author"`
25 State string `json:"state"`
26 UpdatedAt string `json:"updated_at"`
27}
28
29func runDashboard(c *Ctx, args []string) int {
30 if len(args) != 0 {
31 return c.fail(protocol.ExitUsage, "usage: dashboard")
32 }
33 type pinnedOut struct {
34 Path string `json:"path"`
35 Visibility string `json:"visibility"`
36 Description string `json:"description,omitempty"`
37 Archived bool `json:"archived,omitempty"`
38 }
39 type buildOut struct {
40 Repo string `json:"repo"`
41 Number int64 `json:"number"`
42 Job string `json:"job"`
43 Status string `json:"status"`
44 SHA string `json:"sha"`
45 Ref string `json:"ref"`
46 CreatedAt string `json:"created_at"`
47 FinishedAt string `json:"finished_at,omitempty"`
48 }
49 type out struct {
50 Pinned []pinnedOut `json:"pinned"`
51 MRs []dashboardItem `json:"open_mrs"`
52 Assigned []dashboardItem `json:"assigned_issues"`
53 Builds []buildOut `json:"builds"`
54 }
55 d := out{Pinned: []pinnedOut{}, MRs: []dashboardItem{}, Assigned: []dashboardItem{}, Builds: []buildOut{}}
56
57 pinned, err := c.Store.PinnedRepos(c.User.ID)
58 if err != nil {
59 return c.fail(protocol.ExitFailure, "%v", err)
60 }
61 for _, r := range pinned {
62 grant, err := c.Store.AccessRole(r.ID, c.User.ID)
63 if err != nil {
64 return c.fail(protocol.ExitFailure, "%v", err)
65 }
66 if !policy.CanRead(c.User, r, grant) {
67 continue
68 }
69 desc := gitutil.ReadDescription(RepoDir(c.Cfg.Server.Root, r.OwnerName, r.Name))
70 d.Pinned = append(d.Pinned, pinnedOut{r.Path(), r.Visibility, desc, r.Settings.Archived})
71 }
72
73 mrs, err := c.Store.DashboardMRs(c.User.ID)
74 if err != nil {
75 return c.fail(protocol.ExitFailure, "%v", err)
76 }
77 for _, m := range mrs {
78 d.MRs = append(d.MRs, dashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
79 }
80
81 assigned, err := c.Store.AssignedIssues(c.User.ID)
82 if err != nil {
83 return c.fail(protocol.ExitFailure, "%v", err)
84 }
85 for _, i := range assigned {
86 d.Assigned = append(d.Assigned, dashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
87 }
88
89 builds, err := c.Store.RecentBuilds(c.User.ID, 20)
90 if err != nil {
91 return c.fail(protocol.ExitFailure, "%v", err)
92 }
93 for _, b := range builds {
94 d.Builds = append(d.Builds, buildOut{b.RepoPath, b.Number, b.Job, b.Status, b.SHA, b.Ref, b.CreatedAt, b.FinishedAt})
95 }
96
97 return c.emit(d, func(w io.Writer) {
98 fmt.Fprintln(w, "pinned:")
99 for _, p := range d.Pinned {
100 mark := ""
101 if p.Archived {
102 mark = "\t[archived]"
103 }
104 fmt.Fprintf(w, " %s\t%s\t%s%s\n", p.Path, p.Visibility, p.Description, mark)
105 }
106 fmt.Fprintln(w, "open merge requests:")
107 for _, m := range d.MRs {
108 fmt.Fprintf(w, " %s!%d\t%s\t%s\n", m.Repo, m.Number, m.Title, m.Author)
109 }
110 fmt.Fprintln(w, "assigned issues:")
111 for _, i := range d.Assigned {
112 fmt.Fprintf(w, " %s#%d\t%s\t%s\n", i.Repo, i.Number, i.Title, i.Author)
113 }
114 fmt.Fprintln(w, "builds:")
115 for _, b := range d.Builds {
116 fmt.Fprintf(w, " %s\t%d\t%s\t%s\t%.10s\t%s\n", b.Repo, b.Number, b.Job, b.Status, b.SHA, b.Ref)
117 }
118 })
119}
internal/store/dashboard.go +47 −17
@@ -10,11 +10,10 @@ type DashboardItem struct {
1010 UpdatedAt string
1111 }
1212
13// involvedRepos filters to repositories the user owns, is granted on, or
14// reaches through org membership — or rows the user authored anywhere.
15const involvedCond = `(
16 x.author_id = ?1
17 OR (r.owner_kind = 'user' AND r.owner_id = ?1)
13// reachableCond filters to repositories the user owns, is granted on, or
14// reaches through org or team membership.
15const reachableCond = `(
16 (r.owner_kind = 'user' AND r.owner_id = ?1)
1817 OR EXISTS (SELECT 1 FROM repo_access a
1918 WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1)
2019 OR EXISTS (SELECT 1 FROM org_members mm
@@ -26,6 +25,9 @@ const involvedCond = `(
2625 WHERE tr.repo_id = r.id)
2726 )`
2827
28// involvedCond widens reachableCond to rows the user authored anywhere.
29const involvedCond = `(x.author_id = ?1 OR ` + reachableCond + `)`
30
2931 func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) {
3032 rows, err := s.DB.Query(q, userID)
3133 if err != nil {
@@ -168,6 +170,45 @@ func (s *Store) AssignedIssues(userID int64) ([]DashboardItem, error) {
168170 ORDER BY x.updated_at DESC LIMIT 20`, userID)
169171 }
170172
173// DashboardBuild is one build row on the dashboard, with its repo resolved.
174type DashboardBuild struct {
175 RepoPath string
176 Number int64
177 Job string
178 Status string
179 SHA string
180 Ref string
181 CreatedAt string
182 FinishedAt string
183}
184
185// RecentBuilds returns the newest builds on repositories the user can
186// reach, most recent first.
187func (s *Store) RecentBuilds(userID int64, limit int) ([]DashboardBuild, error) {
188 rows, err := s.DB.Query(`
189 SELECT COALESCE(u.username, o.name) || '/' || r.name,
190 b.number, b.job, b.status, b.sha, b.ref, b.created_at, b.finished_at
191 FROM builds b
192 JOIN repos r ON r.id = b.repo_id
193 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
194 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
195 WHERE `+reachableCond+`
196 ORDER BY b.id DESC LIMIT ?2`, userID, limit)
197 if err != nil {
198 return nil, err
199 }
200 defer rows.Close()
201 var out []DashboardBuild
202 for rows.Next() {
203 var b DashboardBuild
204 if err := rows.Scan(&b.RepoPath, &b.Number, &b.Job, &b.Status, &b.SHA, &b.Ref, &b.CreatedAt, &b.FinishedAt); err != nil {
205 return nil, err
206 }
207 out = append(out, b)
208 }
209 return out, rows.Err()
210}
211
171212 // FeedEvent is one line of the dashboard's activity feed.
172213 type FeedEvent struct {
173214 RepoPath string
@@ -188,18 +229,7 @@ func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) {
188229 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
189230 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
190231 LEFT JOIN users ac ON ac.id = e.actor_id
191 WHERE e.kind <> 'push' AND (
192 (r.owner_kind = 'user' AND r.owner_id = ?1)
193 OR EXISTS (SELECT 1 FROM repo_access a
194 WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1)
195 OR EXISTS (SELECT 1 FROM org_members mm
196 JOIN orgs oo ON oo.id = mm.org_id
197 WHERE r.owner_kind = 'org' AND mm.org_id = r.owner_id AND mm.user_id = ?1
198 AND (mm.role = 'admin' OR oo.members_role <> 'none'))
199 OR EXISTS (SELECT 1 FROM team_repos tr
200 JOIN team_members tm ON tm.team_id = tr.team_id AND tm.user_id = ?1
201 WHERE tr.repo_id = r.id)
202 )
232 WHERE e.kind <> 'push' AND `+reachableCond+`
203233 ORDER BY e.id DESC LIMIT ?2`, userID, limit)
204234 if err != nil {
205235 return nil, err