Commit 3467efe33e

3467efe33e77598a1e70eba482953deac3dec989

parent: 160fc0ec2b

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-27 19:52 UTC

control: align dashboard aggregate with web

Layout: unified · split

e2e/dashboard_test.go +28 −2
@@ -120,8 +120,8 @@ func TestDashboard(t *testing.T) {
120} 120}
121 121
122// The dashboard control command returns the same aggregate as the web 122// The dashboard control command returns the same aggregate as the web
123// dashboard — pinned repos, open MRs, assigned issues, recent builds — in 123// dashboard — review queue, assigned/open work, pins, and activity — in
124// one read. 124// one read. Builds remain for clients that already consume them.
125func TestDashboardCommand(t *testing.T) { 125func TestDashboardCommand(t *testing.T) {
126 inst := startInstance(t) 126 inst := startInstance(t)
127 aliceKey := inst.newKey(t, "alice") 127 aliceKey := inst.newKey(t, "alice")
@@ -175,6 +175,11 @@ func TestDashboardCommand(t *testing.T) {
175 } 175 }
176 var env2 struct { 176 var env2 struct {
177 Data struct { 177 Data struct {
178 Reviews []struct {
179 Repo string `json:"repo"`
180 Number int64 `json:"number"`
181 Title string `json:"title"`
182 } `json:"review_queue"`
178 Pinned []struct { 183 Pinned []struct {
179 Path string `json:"path"` 184 Path string `json:"path"`
180 } `json:"pinned"` 185 } `json:"pinned"`
@@ -190,6 +195,16 @@ func TestDashboardCommand(t *testing.T) {
190 Number int64 `json:"number"` 195 Number int64 `json:"number"`
191 Title string `json:"title"` 196 Title string `json:"title"`
192 } `json:"assigned_issues"` 197 } `json:"assigned_issues"`
198 Issues []struct {
199 Repo string `json:"repo"`
200 Number int64 `json:"number"`
201 Title string `json:"title"`
202 } `json:"open_issues"`
203 Activity []struct {
204 Repo string `json:"repo"`
205 Actor string `json:"actor"`
206 Kind string `json:"kind"`
207 } `json:"recent_activity"`
193 Builds []struct { 208 Builds []struct {
194 Repo string `json:"repo"` 209 Repo string `json:"repo"`
195 Job string `json:"job"` 210 Job string `json:"job"`
@@ -209,10 +224,21 @@ func TestDashboardCommand(t *testing.T) {
209 d.MRs[0].Title != "from bob" || d.MRs[0].Author != "bob" || d.MRs[0].State != "open" { 224 d.MRs[0].Title != "from bob" || d.MRs[0].Author != "bob" || d.MRs[0].State != "open" {
210 t.Fatalf("open_mrs = %+v", d.MRs) 225 t.Fatalf("open_mrs = %+v", d.MRs)
211 } 226 }
227 if len(d.Reviews) != 1 || d.Reviews[0].Repo != "alice/app" || d.Reviews[0].Number != 1 ||
228 d.Reviews[0].Title != "from bob" {
229 t.Fatalf("review_queue = %+v", d.Reviews)
230 }
212 if len(d.Assigned) != 1 || d.Assigned[0].Repo != "alice/app" || d.Assigned[0].Number != 1 || 231 if len(d.Assigned) != 1 || d.Assigned[0].Repo != "alice/app" || d.Assigned[0].Number != 1 ||
213 d.Assigned[0].Title != "todo one" { 232 d.Assigned[0].Title != "todo one" {
214 t.Fatalf("assigned_issues = %+v", d.Assigned) 233 t.Fatalf("assigned_issues = %+v", d.Assigned)
215 } 234 }
235 if len(d.Issues) != 1 || d.Issues[0].Repo != "alice/app" || d.Issues[0].Number != 1 ||
236 d.Issues[0].Title != "todo one" {
237 t.Fatalf("open_issues = %+v", d.Issues)
238 }
239 if len(d.Activity) == 0 || d.Activity[0].Repo != "alice/app" {
240 t.Fatalf("recent_activity = %+v", d.Activity)
241 }
216 // Both pushes hit main and feat; each queues the ci.yml job. 242 // 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" || 243 if len(d.Builds) == 0 || d.Builds[0].Repo != "alice/app" || d.Builds[0].Job != "test" ||
218 d.Builds[0].Status != "pending" { 244 d.Builds[0].Status != "pending" {
internal/control/dashboard.go +71 −27
@@ -14,7 +14,7 @@ import (
14 14
15func init() { 15func init() {
16 register(Command{Path: []string{"dashboard"}, 16 register(Command{Path: []string{"dashboard"},
17 Summary: "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds", 17 Summary: "one read for the account dashboard: review queue, assigned and open work, pins, activity, builds",
18 ReadOnly: true, Run: runDashboard}) 18 ReadOnly: true, Run: runDashboard})
19 register(Command{Path: []string{"feed"}, 19 register(Command{Path: []string{"feed"},
20 Summary: "activity on repositories you can reach: feed [--limit <n>] [--cursor <c>]", 20 Summary: "activity on repositories you can reach: feed [--limit <n>] [--cursor <c>]",
@@ -53,12 +53,18 @@ func runDashboard(c *Ctx, args []string) int {
53 FinishedAt string `json:"finished_at,omitempty"` 53 FinishedAt string `json:"finished_at,omitempty"`
54 } 54 }
55 type out struct { 55 type out struct {
56 Pinned []pinnedOut `json:"pinned"` 56 Reviews []dashboardItem `json:"review_queue"`
57 MRs []dashboardItem `json:"open_mrs"`
58 Assigned []dashboardItem `json:"assigned_issues"` 57 Assigned []dashboardItem `json:"assigned_issues"`
58 MRs []dashboardItem `json:"open_mrs"`
59 Issues []dashboardItem `json:"open_issues"`
60 Pinned []pinnedOut `json:"pinned"`
61 Activity []feedOut `json:"recent_activity"`
59 Builds []buildOut `json:"builds"` 62 Builds []buildOut `json:"builds"`
60 } 63 }
61 d := out{Pinned: []pinnedOut{}, MRs: []dashboardItem{}, Assigned: []dashboardItem{}, Builds: []buildOut{}} 64 d := out{
65 Reviews: []dashboardItem{}, Assigned: []dashboardItem{}, MRs: []dashboardItem{},
66 Issues: []dashboardItem{}, Pinned: []pinnedOut{}, Activity: []feedOut{}, Builds: []buildOut{},
67 }
62 68
63 pinned, err := c.Store.PinnedRepos(c.User.ID) 69 pinned, err := c.Store.PinnedRepos(c.User.ID)
64 if err != nil { 70 if err != nil {
@@ -84,6 +90,14 @@ func runDashboard(c *Ctx, args []string) int {
84 d.MRs = append(d.MRs, dashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt}) 90 d.MRs = append(d.MRs, dashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
85 } 91 }
86 92
93 reviews, err := c.Store.ReviewQueue(c.User.ID)
94 if err != nil {
95 return c.fail(protocol.ExitFailure, "%v", err)
96 }
97 for _, m := range reviews {
98 d.Reviews = append(d.Reviews, dashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
99 }
100
87 assigned, err := c.Store.AssignedIssues(c.User.ID) 101 assigned, err := c.Store.AssignedIssues(c.User.ID)
88 if err != nil { 102 if err != nil {
89 return c.fail(protocol.ExitFailure, "%v", err) 103 return c.fail(protocol.ExitFailure, "%v", err)
@@ -92,6 +106,20 @@ func runDashboard(c *Ctx, args []string) int {
92 d.Assigned = append(d.Assigned, dashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt}) 106 d.Assigned = append(d.Assigned, dashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
93 } 107 }
94 108
109 issues, err := c.Store.DashboardIssues(c.User.ID)
110 if err != nil {
111 return c.fail(protocol.ExitFailure, "%v", err)
112 }
113 for _, i := range issues {
114 d.Issues = append(d.Issues, dashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
115 }
116
117 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
118 if err != nil {
119 return c.fail(protocol.ExitFailure, "%v", err)
120 }
121 d.Activity = feedOutputs(events)
122
95 builds, err := c.Store.RecentBuilds(c.User.ID, 20) 123 builds, err := c.Store.RecentBuilds(c.User.ID, 20)
96 if err != nil { 124 if err != nil {
97 return c.fail(protocol.ExitFailure, "%v", err) 125 return c.fail(protocol.ExitFailure, "%v", err)
@@ -101,6 +129,14 @@ func runDashboard(c *Ctx, args []string) int {
101 } 129 }
102 130
103 return c.emit(d, func(w io.Writer) { 131 return c.emit(d, func(w io.Writer) {
132 fmt.Fprintln(w, "waiting on your review:")
133 printDashboardItems(w, d.Reviews, "!")
134 fmt.Fprintln(w, "assigned to you:")
135 printDashboardItems(w, d.Assigned, "#")
136 fmt.Fprintln(w, "open merge requests:")
137 printDashboardItems(w, d.MRs, "!")
138 fmt.Fprintln(w, "open issues:")
139 printDashboardItems(w, d.Issues, "#")
104 fmt.Fprintln(w, "pinned:") 140 fmt.Fprintln(w, "pinned:")
105 for _, p := range d.Pinned { 141 for _, p := range d.Pinned {
106 mark := "" 142 mark := ""
@@ -109,13 +145,9 @@ func runDashboard(c *Ctx, args []string) int {
109 } 145 }
110 fmt.Fprintf(w, " %s\t%s\t%s%s\n", p.Path, p.Visibility, p.Description, mark) 146 fmt.Fprintf(w, " %s\t%s\t%s%s\n", p.Path, p.Visibility, p.Description, mark)
111 } 147 }
112 fmt.Fprintln(w, "open merge requests:") 148 fmt.Fprintln(w, "recent activity:")
113 for _, m := range d.MRs { 149 for _, e := range d.Activity {
114 fmt.Fprintf(w, " %s!%d\t%s\t%s\n", m.Repo, m.Number, m.Title, m.Author) 150 fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", e.CreatedAt, e.Actor, e.Kind, e.Repo, string(e.Data))
115 }
116 fmt.Fprintln(w, "assigned issues:")
117 for _, i := range d.Assigned {
118 fmt.Fprintf(w, " %s#%d\t%s\t%s\n", i.Repo, i.Number, i.Title, i.Author)
119 } 151 }
120 fmt.Fprintln(w, "builds:") 152 fmt.Fprintln(w, "builds:")
121 for _, b := range d.Builds { 153 for _, b := range d.Builds {
@@ -124,10 +156,37 @@ func runDashboard(c *Ctx, args []string) int {
124 }) 156 })
125} 157}
126 158
159func printDashboardItems(w io.Writer, items []dashboardItem, marker string) {
160 for _, item := range items {
161 fmt.Fprintf(w, " %s%s%d\t%s\t%s\n", item.Repo, marker, item.Number, item.Title, item.Author)
162 }
163}
164
127// feedDefaultLimit caps a bare `feed` call; pagination reaches further 165// feedDefaultLimit caps a bare `feed` call; pagination reaches further
128// back. 166// back.
129const feedDefaultLimit = 50 167const feedDefaultLimit = 50
130 168
169type feedOut struct {
170 ID int64 `json:"id"`
171 Repo string `json:"repo"`
172 Actor string `json:"actor,omitempty"`
173 Kind string `json:"kind"`
174 Data json.RawMessage `json:"data,omitempty"`
175 CreatedAt string `json:"created_at"`
176}
177
178func feedOutputs(events []store.FeedEvent) []feedOut {
179 ds := make([]feedOut, 0, len(events))
180 for _, e := range events {
181 d := feedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
182 if json.Valid([]byte(e.Data)) {
183 d.Data = json.RawMessage(e.Data)
184 }
185 ds = append(ds, d)
186 }
187 return ds
188}
189
131func runFeed(c *Ctx, args []string) int { 190func runFeed(c *Ctx, args []string) int {
132 rest, p, code := parsePageFlags(c, args, "feed", true) 191 rest, p, code := parsePageFlags(c, args, "feed", true)
133 if code >= 0 { 192 if code >= 0 {
@@ -146,22 +205,7 @@ func runFeed(c *Ctx, args []string) int {
146 events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string { 205 events, next := trimPage(p, events, "feed", func(e store.FeedEvent) string {
147 return strconv.FormatInt(e.ID, 10) 206 return strconv.FormatInt(e.ID, 10)
148 }) 207 })
149 type feedOut struct { 208 ds := feedOutputs(events)
150 ID int64 `json:"id"`
151 Repo string `json:"repo"`
152 Actor string `json:"actor,omitempty"`
153 Kind string `json:"kind"`
154 Data json.RawMessage `json:"data,omitempty"`
155 CreatedAt string `json:"created_at"`
156 }
157 var ds []feedOut
158 for _, e := range events {
159 d := feedOut{ID: e.ID, Repo: e.RepoPath, Actor: e.Actor, Kind: e.Kind, CreatedAt: e.CreatedAt}
160 if json.Valid([]byte(e.Data)) {
161 d.Data = json.RawMessage(e.Data)
162 }
163 ds = append(ds, d)
164 }
165 return c.emitPage(p, ds, next, func(w io.Writer) { 209 return c.emitPage(p, ds, next, func(w io.Writer) {
166 for _, d := range ds { 210 for _, d := range ds {
167 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.CreatedAt, d.Actor, d.Kind, d.Repo, string(d.Data)) 211 fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", d.CreatedAt, d.Actor, d.Kind, d.Repo, string(d.Data))