Commit 3467efe33e
Verified · cmc ci/build: success
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. |
| 125 | func TestDashboardCommand(t *testing.T) { | 125 | func 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 | ||
| 15 | func init() { | 15 | func 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 | ||
| 159 | func 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. |
| 129 | const feedDefaultLimit = 50 | 167 | const feedDefaultLimit = 50 |
| 130 | 168 | ||
| 169 | type 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 | |||
| 178 | func 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 | |||
| 131 | func runFeed(c *Ctx, args []string) int { | 190 | func 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)) |