Commit 12c92d4c91
Verified · cmc ci/build: success
cmd/gitbay/main.go +2
| @@ -36,6 +36,8 @@ func main() { | ||
| 36 | 36 | pass("log", "a build's log: <owner/name> <n>", passOpts{server: []string{"build", "log"}, needsRepo: true}), |
| 37 | 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 | 41 | repoCmd(), |
| 40 | 42 | issueCmd(), |
| 41 | 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 | 247 | // TestDashboardQueues covers the parts of the dashboard that answer "what |
| 123 | 248 | // needs me": the review queue, assigned issues, and the activity feed. |
| 124 | 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 | 10 | UpdatedAt string |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | // involvedRepos filters to repositories the user owns, is granted on, or | |
| 14 | // reaches through org membership — or rows the user authored anywhere. | |
| 15 | const 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. | |
| 15 | const reachableCond = `( | |
| 16 | (r.owner_kind = 'user' AND r.owner_id = ?1) | |
| 18 | 17 | OR EXISTS (SELECT 1 FROM repo_access a |
| 19 | 18 | WHERE a.repo_id = r.id AND a.subject_kind = 'user' AND a.subject_id = ?1) |
| 20 | 19 | OR EXISTS (SELECT 1 FROM org_members mm |
| @@ -26,6 +25,9 @@ const involvedCond = `( | ||
| 26 | 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 | 31 | func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) { |
| 30 | 32 | rows, err := s.DB.Query(q, userID) |
| 31 | 33 | if err != nil { |
| @@ -168,6 +170,45 @@ func (s *Store) AssignedIssues(userID int64) ([]DashboardItem, error) { | ||
| 168 | 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 | 212 | // FeedEvent is one line of the dashboard's activity feed. |
| 172 | 213 | type FeedEvent struct { |
| 173 | 214 | RepoPath string |
| @@ -188,18 +229,7 @@ func (s *Store) RecentEvents(userID int64, limit int) ([]FeedEvent, error) { | ||
| 188 | 229 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| 189 | 230 | LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id |
| 190 | 231 | 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+` | |
| 203 | 233 | ORDER BY e.id DESC LIMIT ?2`, userID, limit) |
| 204 | 234 | if err != nil { |
| 205 | 235 | return nil, err |