control: dashboard command for the account aggregate !96
4 files changed, +293 −17
Layout: unified · split
cmd/gitbay/main.go +2
| @@ -36,6 +36,8 @@ func main() { | |||
| 36 | pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}), | 36 | pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}), |
| 37 | pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}), | 37 | pass("trigger", "queue a job now: <job>", passOpts{server: []string{"build", "trigger"}, needsRepo: true}), |
| 38 | ), | 38 | ), |
| 39 | pass("dashboard", "one read for the account dashboard: pinned repos, open MRs, assigned issues, recent builds", | ||
| 40 | passOpts{server: []string{"dashboard"}}), | ||
| 39 | repoCmd(), | 41 | repoCmd(), |
| 40 | issueCmd(), | 42 | issueCmd(), |
| 41 | milestoneCmd(), | 43 | milestoneCmd(), |
e2e/dashboard_test.go +125
| @@ -119,6 +119,131 @@ func TestDashboard(t *testing.T) { | |||
| 119 | } | 119 | } |
| 120 | } | 120 | } |
| 121 | 121 | ||
| 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. | ||
| 125 | func 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 | |||
| 122 | // TestDashboardQueues covers the parts of the dashboard that answer "what | 247 | // TestDashboardQueues covers the parts of the dashboard that answer "what |
| 123 | // needs me": the review queue, assigned issues, and the activity feed. | 248 | // needs me": the review queue, assigned issues, and the activity feed. |
| 124 | func TestDashboardQueues(t *testing.T) { | 249 | func TestDashboardQueues(t *testing.T) { |
internal/control/dashboard.go added +119
| @@ -0,0 +1,119 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 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 | |||
| 12 | func 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. | ||
| 20 | type 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 | |||
| 29 | func 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 { | |||
| 10 | UpdatedAt string | 10 | UpdatedAt string |
| 11 | } | 11 | } |
| 12 | 12 | ||
| 13 | // involvedRepos filters to repositories the user owns, is granted on, or | 13 | // reachableCond filters to repositories the user owns, is granted on, or |
| 14 | // reaches through org membership — or rows the user authored anywhere. | 14 | // reaches through org or team membership. |
| 15 | const involvedCond = `( | 15 | const reachableCond = `( |
| 16 | x.author_id = ?1 | 16 | (r.owner_kind = 'user' AND r.owner_id = ?1) |
| 17 | OR (r.owner_kind = 'user' AND r.owner_id = ?1) | ||
| 18 | OR EXISTS (SELECT 1 FROM repo_access a | 17 | OR EXISTS (SELECT 1 FROM repo_access a |
| 19 | WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1) | 18 | WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1) |
| 20 | OR EXISTS (SELECT 1 FROM org_members mm | 19 | OR EXISTS (SELECT 1 FROM org_members mm |
| @@ -26,6 +25,9 @@ const involvedCond = `( | |||
| 26 | WHERE tr.repo_id = r.id) | 25 | WHERE tr.repo_id = r.id) |
| 27 | )` | 26 | )` |
| 28 | 27 | ||
| 28 | // involvedCond widens reachableCond to rows the user authored anywhere. | ||
| 29 | const involvedCond = `(x.author_id = ?1 OR ` + reachableCond + `)` | ||
| 30 | |||
| 29 | func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) { | 31 | func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) { |
| 30 | rows, err := s.DB.Query(q, userID) | 32 | rows, err := s.DB.Query(q, userID) |
| 31 | if err != nil { | 33 | if err != nil { |
| @@ -168,6 +170,45 @@ func (s *Store) AssignedIssues(userID int64) ([]DashboardItem, error) { | |||
| 168 | ORDER BY x.updated_at DESC LIMIT 20`, userID) | 170 | ORDER BY x.updated_at DESC LIMIT 20`, userID) |
| 169 | } | 171 | } |
| 170 | 172 | ||
| 173 | // DashboardBuild is one build row on the dashboard, with its repo resolved. | ||
| 174 | type 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. | ||
| 187 | func (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 | |||
| 171 | // FeedEvent is one line of the dashboard's activity feed. | 212 | // FeedEvent is one line of the dashboard's activity feed. |
| 172 | type FeedEvent struct { | 213 | type FeedEvent struct { |
| 173 | RepoPath string | 214 | RepoPath string |
| @@ -188,18 +229,7 @@ func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) { | |||
| 188 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | 229 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| 189 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id | 230 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id |
| 190 | LEFT JOIN users ac ON ac.id = e.actor_id | 231 | LEFT JOIN users ac ON ac.id = e.actor_id |
| 191 | WHERE e.kind <> 'push' AND ( | 232 | WHERE e.kind <> 'push' AND `+reachableCond+` |
| 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 | ) | ||
| 203 | ORDER BY e.id DESC LIMIT ?2`, userID, limit) | 233 | ORDER BY e.id DESC LIMIT ?2`, userID, limit) |
| 204 | if err != nil { | 234 | if err != nil { |
| 205 | return nil, err | 235 | return nil, err |