Commit d722ba57f3
Verified · cmc
internal/store/issues.go +23 −17
| @@ -294,10 +294,11 @@ func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) { | ||
| 294 | 294 | return out, rows.Err() |
| 295 | 295 | } |
| 296 | 296 | |
| 297 | // LabelColors returns the repo's label colors keyed by label name. Labels | |
| 298 | // with no stored color map to "". | |
| 299 | func (s *Store) LabelColors(repoID int64) (map[string]string, error) { | |
| 300 | rows, err := s.DB.Query("SELECT name, color FROM labels WHERE repo_id = ?", repoID) | |
| 297 | // LabelColors returns the colours of the labels a repository sees, keyed | |
| 298 | // by name. Labels with no stored colour map to "". | |
| 299 | func (s *Store) LabelColors(repo Repo) (map[string]string, error) { | |
| 300 | where, args := scopeClause("l", repo) | |
| 301 | rows, err := s.DB.Query("SELECT l.name, l.color FROM labels l WHERE "+where, args...) | |
| 301 | 302 | if err != nil { |
| 302 | 303 | return nil, err |
| 303 | 304 | } |
| @@ -313,30 +314,35 @@ func (s *Store) LabelColors(repoID int64) (map[string]string, error) { | ||
| 313 | 314 | return out, rows.Err() |
| 314 | 315 | } |
| 315 | 316 | |
| 316 | // SetIssueLabel attaches (add) or detaches a label, creating the repo label | |
| 317 | // on first use. | |
| 318 | func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error { | |
| 317 | // SetIssueLabel attaches (add) or detaches a label by name. Adding | |
| 318 | // resolves the org's row when the org has the name, else the repository's, | |
| 319 | // creating that on first use. | |
| 320 | func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error { | |
| 319 | 321 | tx, err := s.DB.Begin() |
| 320 | 322 | if err != nil { |
| 321 | 323 | return err |
| 322 | 324 | } |
| 323 | 325 | defer tx.Rollback() |
| 326 | where, args := scopeClause("l", repo) | |
| 324 | 327 | if add { |
| 325 | if _, err := tx.Exec( | |
| 326 | "INSERT INTO labels (repo_id, name) VALUES (?, ?) ON CONFLICT (repo_id, name) DO NOTHING", | |
| 327 | repoID, name); err != nil { | |
| 328 | if held, err := orgHoldsLabel(tx, repo, name); err != nil { | |
| 328 | 329 | return err |
| 330 | } else if !held { | |
| 331 | if _, err := tx.Exec(`INSERT INTO labels (repo_id, name) VALUES (?, ?) | |
| 332 | ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO NOTHING`, repo.ID, name); err != nil { | |
| 333 | return err | |
| 334 | } | |
| 329 | 335 | } |
| 330 | if _, err := tx.Exec(` | |
| 331 | INSERT INTO issue_labels (issue_id, label_id) | |
| 332 | SELECT ?, id FROM labels WHERE repo_id = ? AND name = ? | |
| 333 | ON CONFLICT DO NOTHING`, issueID, repoID, name); err != nil { | |
| 336 | if _, err := tx.Exec(`INSERT INTO issue_labels (issue_id, label_id) | |
| 337 | SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ? | |
| 338 | ORDER BY l.org_id IS NULL LIMIT 1 | |
| 339 | ON CONFLICT DO NOTHING`, append(append([]any{issueID}, args...), name)...); err != nil { | |
| 334 | 340 | return err |
| 335 | 341 | } |
| 336 | 342 | } else { |
| 337 | res, err := tx.Exec(` | |
| 338 | DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN | |
| 339 | (SELECT id FROM labels WHERE repo_id = ? AND name = ?)`, issueID, repoID, name) | |
| 343 | res, err := tx.Exec(`DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN | |
| 344 | (SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`, | |
| 345 | append(append([]any{issueID}, args...), name)...) | |
| 340 | 346 | if err != nil { |
| 341 | 347 | return err |
| 342 | 348 | } |
internal/store/labels.go +135 −12
| @@ -1,5 +1,10 @@ | ||
| 1 | 1 | package store |
| 2 | 2 | |
| 3 | import ( | |
| 4 | "database/sql" | |
| 5 | "errors" | |
| 6 | ) | |
| 7 | ||
| 3 | 8 | // Label is an issue label with its colour, "" when none was set (the web |
| 4 | 9 | // then derives one from the name), and how many issues carry it. Org is |
| 5 | 10 | // true for a label the repository sees through its org. |
| @@ -10,11 +15,16 @@ type Label struct { | ||
| 10 | 15 | Issues int64 `json:"issues"` |
| 11 | 16 | } |
| 12 | 17 | |
| 13 | // ListLabels lists a repository's labels by name. | |
| 14 | func (s *Store) ListLabels(repoID int64) ([]Label, error) { | |
| 15 | rows, err := s.DB.Query(`SELECT l.name, l.color, COUNT(il.issue_id) | |
| 16 | FROM labels l LEFT JOIN issue_labels il ON il.label_id = l.id | |
| 17 | WHERE l.repo_id = ? GROUP BY l.id ORDER BY l.name`, repoID) | |
| 18 | // labelRows lists labels under where, with use counted over the issues of | |
| 19 | // the readable repositories only, so a private repository's issues do not | |
| 20 | // show in a count someone outside it can see. | |
| 21 | func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) { | |
| 22 | in, inArgs := inClause(readable) | |
| 23 | q := `SELECT l.name, l.color, l.org_id IS NOT NULL, | |
| 24 | (SELECT COUNT(*) FROM issue_labels il JOIN issues i ON i.id = il.issue_id | |
| 25 | WHERE il.label_id = l.id AND i.repo_id IN ` + in + `) | |
| 26 | FROM labels l WHERE ` + where + ` ORDER BY l.org_id IS NULL, l.name` | |
| 27 | rows, err := s.DB.Query(q, append(inArgs, args...)...) | |
| 18 | 28 | if err != nil { |
| 19 | 29 | return nil, err |
| 20 | 30 | } |
| @@ -22,7 +32,7 @@ func (s *Store) ListLabels(repoID int64) ([]Label, error) { | ||
| 22 | 32 | var out []Label |
| 23 | 33 | for rows.Next() { |
| 24 | 34 | var l Label |
| 25 | if err := rows.Scan(&l.Name, &l.Color, &l.Issues); err != nil { | |
| 35 | if err := rows.Scan(&l.Name, &l.Color, &l.Org, &l.Issues); err != nil { | |
| 26 | 36 | return nil, err |
| 27 | 37 | } |
| 28 | 38 | out = append(out, l) |
| @@ -30,16 +40,129 @@ func (s *Store) ListLabels(repoID int64) ([]Label, error) { | ||
| 30 | 40 | return out, rows.Err() |
| 31 | 41 | } |
| 32 | 42 | |
| 33 | // SetLabel creates the label or sets its colour. | |
| 34 | func (s *Store) SetLabel(repoID int64, name, color string) error { | |
| 43 | // ListLabels lists the labels a repository sees: its org's first, then its | |
| 44 | // own, each by name. | |
| 45 | func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) { | |
| 46 | where, args := scopeClause("l", repo) | |
| 47 | return s.labelRows(where, args, readable) | |
| 48 | } | |
| 49 | ||
| 50 | // ListOrgLabels lists an org's labels. | |
| 51 | func (s *Store) ListOrgLabels(orgID int64, readable []int64) ([]Label, error) { | |
| 52 | return s.labelRows("l.org_id = ?", []any{orgID}, readable) | |
| 53 | } | |
| 54 | ||
| 55 | // LabelByName resolves a name the way attaching does: the org's row when | |
| 56 | // the org has it, else the repository's. | |
| 57 | func (s *Store) LabelByName(repo Repo, name string) (Label, error) { | |
| 58 | where, args := scopeClause("l", repo) | |
| 59 | var l Label | |
| 60 | err := s.DB.QueryRow(`SELECT l.name, l.color, l.org_id IS NOT NULL FROM labels l | |
| 61 | WHERE `+where+` AND l.name = ? ORDER BY l.org_id IS NULL LIMIT 1`, | |
| 62 | append(args, name)...).Scan(&l.Name, &l.Color, &l.Org) | |
| 63 | if errors.Is(err, sql.ErrNoRows) { | |
| 64 | return l, ErrNotFound | |
| 65 | } | |
| 66 | return l, err | |
| 67 | } | |
| 68 | ||
| 69 | // orgHoldsLabel reports whether the repository's org has a label of that | |
| 70 | // name; always false for a user-owned repository. | |
| 71 | func orgHoldsLabel(q interface { | |
| 72 | QueryRow(string, ...any) *sql.Row | |
| 73 | }, repo Repo, name string) (bool, error) { | |
| 74 | if repo.OwnerKind != "org" { | |
| 75 | return false, nil | |
| 76 | } | |
| 77 | var n int | |
| 78 | err := q.QueryRow("SELECT COUNT(*) FROM labels WHERE org_id = ? AND name = ?", repo.OwnerID, name).Scan(&n) | |
| 79 | return n > 0, err | |
| 80 | } | |
| 81 | ||
| 82 | // SetLabel creates the repository's label or sets its colour. A name the | |
| 83 | // org holds is refused with ErrOrgScoped. | |
| 84 | func (s *Store) SetLabel(repo Repo, name, color string) error { | |
| 85 | if held, err := orgHoldsLabel(s.DB, repo, name); err != nil || held { | |
| 86 | if err != nil { | |
| 87 | return err | |
| 88 | } | |
| 89 | return ErrOrgScoped | |
| 90 | } | |
| 35 | 91 | _, err := s.DB.Exec(`INSERT INTO labels (repo_id, name, color) VALUES (?, ?, ?) |
| 36 | ON CONFLICT (repo_id, name) DO UPDATE SET color = excluded.color`, repoID, name, color) | |
| 92 | ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO UPDATE SET color = excluded.color`, | |
| 93 | repo.ID, name, color) | |
| 37 | 94 | return err |
| 38 | 95 | } |
| 39 | 96 | |
| 40 | // DeleteLabel removes a label and takes it off every issue. | |
| 41 | func (s *Store) DeleteLabel(repoID int64, name string) error { | |
| 42 | res, err := s.DB.Exec("DELETE FROM labels WHERE repo_id = ? AND name = ?", repoID, name) | |
| 97 | // DeleteLabel removes the repository's label and takes it off every issue. | |
| 98 | // An org's label is ErrOrgScoped; no label at all is ErrNotFound. | |
| 99 | func (s *Store) DeleteLabel(repo Repo, name string) error { | |
| 100 | res, err := s.DB.Exec("DELETE FROM labels WHERE repo_id = ? AND name = ?", repo.ID, name) | |
| 101 | if err != nil { | |
| 102 | return err | |
| 103 | } | |
| 104 | if n, _ := res.RowsAffected(); n > 0 { | |
| 105 | return nil | |
| 106 | } | |
| 107 | if held, err := orgHoldsLabel(s.DB, repo, name); err != nil || held { | |
| 108 | if err != nil { | |
| 109 | return err | |
| 110 | } | |
| 111 | return ErrOrgScoped | |
| 112 | } | |
| 113 | return ErrNotFound | |
| 114 | } | |
| 115 | ||
| 116 | // SetOrgLabel creates the org's label or sets its colour. Repositories | |
| 117 | // under the org that hold the name are folded in: their issues move to | |
| 118 | // the org's row and their rows go. folded is how many were. | |
| 119 | func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) { | |
| 120 | tx, err := s.DB.Begin() | |
| 121 | if err != nil { | |
| 122 | return 0, err | |
| 123 | } | |
| 124 | defer tx.Rollback() | |
| 125 | if _, err := tx.Exec(`INSERT INTO labels (org_id, name, color) VALUES (?, ?, ?) | |
| 126 | ON CONFLICT (org_id, name) WHERE org_id IS NOT NULL DO UPDATE SET color = excluded.color`, | |
| 127 | orgID, name, color); err != nil { | |
| 128 | return 0, err | |
| 129 | } | |
| 130 | var orgRow int64 | |
| 131 | if err := tx.QueryRow("SELECT id FROM labels WHERE org_id = ? AND name = ?", orgID, name).Scan(&orgRow); err != nil { | |
| 132 | return 0, err | |
| 133 | } | |
| 134 | rows, err := tx.Query(`SELECT l.id FROM labels l JOIN repos r ON r.id = l.repo_id | |
| 135 | WHERE r.owner_kind = 'org' AND r.owner_id = ? AND l.name = ?`, orgID, name) | |
| 136 | if err != nil { | |
| 137 | return 0, err | |
| 138 | } | |
| 139 | var repoRows []int64 | |
| 140 | for rows.Next() { | |
| 141 | var id int64 | |
| 142 | if err := rows.Scan(&id); err != nil { | |
| 143 | rows.Close() | |
| 144 | return 0, err | |
| 145 | } | |
| 146 | repoRows = append(repoRows, id) | |
| 147 | } | |
| 148 | rows.Close() | |
| 149 | for _, id := range repoRows { | |
| 150 | // OR IGNORE: an issue cannot carry both today, but the primary key | |
| 151 | // makes the move safe if it ever did. | |
| 152 | if _, err := tx.Exec("UPDATE OR IGNORE issue_labels SET label_id = ? WHERE label_id = ?", orgRow, id); err != nil { | |
| 153 | return 0, err | |
| 154 | } | |
| 155 | if _, err := tx.Exec("DELETE FROM labels WHERE id = ?", id); err != nil { | |
| 156 | return 0, err | |
| 157 | } | |
| 158 | } | |
| 159 | return len(repoRows), tx.Commit() | |
| 160 | } | |
| 161 | ||
| 162 | // DeleteOrgLabel removes an org's label from the org and from every issue | |
| 163 | // under it. | |
| 164 | func (s *Store) DeleteOrgLabel(orgID int64, name string) error { | |
| 165 | res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name) | |
| 43 | 166 | if err != nil { |
| 44 | 167 | return err |
| 45 | 168 | } |
internal/store/labels_test.go added +206
| @@ -0,0 +1,206 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "testing" | |
| 6 | ) | |
| 7 | ||
| 8 | // acmeFixture: org acme owned by alice with repos acme/core and | |
| 9 | // acme/site, an issue in each, and alice's own alice/app. | |
| 10 | type acmeFixture struct { | |
| 11 | s *Store | |
| 12 | alice int64 | |
| 13 | org int64 | |
| 14 | core, site Repo | |
| 15 | app Repo | |
| 16 | coreIssue int64 | |
| 17 | siteIssue int64 | |
| 18 | } | |
| 19 | ||
| 20 | func newAcme(t *testing.T) acmeFixture { | |
| 21 | t.Helper() | |
| 22 | s := open(t) | |
| 23 | if err := s.MigrateUp(); err != nil { | |
| 24 | t.Fatal(err) | |
| 25 | } | |
| 26 | var f acmeFixture | |
| 27 | f.s = s | |
| 28 | var err error | |
| 29 | if f.alice, err = s.CreateUser("alice", false); err != nil { | |
| 30 | t.Fatal(err) | |
| 31 | } | |
| 32 | if f.org, err = s.CreateOrg("acme", f.alice); err != nil { | |
| 33 | t.Fatal(err) | |
| 34 | } | |
| 35 | mk := func(kind string, owner int64, name string) Repo { | |
| 36 | id, err := s.CreateRepo(kind, owner, name, "public") | |
| 37 | if err != nil { | |
| 38 | t.Fatal(err) | |
| 39 | } | |
| 40 | r, err := s.RepoByID(id) | |
| 41 | if err != nil { | |
| 42 | t.Fatal(err) | |
| 43 | } | |
| 44 | return r | |
| 45 | } | |
| 46 | f.core = mk("org", f.org, "core") | |
| 47 | f.site = mk("org", f.org, "site") | |
| 48 | f.app = mk("user", f.alice, "app") | |
| 49 | // CreateIssue returns the per-repo issue number, not the issues.id row | |
| 50 | // that issue_labels.issue_id references (and that every production | |
| 51 | // caller of SetIssueLabel passes); resolve it the same way they do, or | |
| 52 | // core's and site's both-numbered-1 first issues collide. | |
| 53 | mkIssue := func(repo Repo, title string) int64 { | |
| 54 | n, err := s.CreateIssue(repo.ID, f.alice, title, "", "md") | |
| 55 | if err != nil { | |
| 56 | t.Fatal(err) | |
| 57 | } | |
| 58 | iss, err := s.IssueByNumber(repo.ID, n) | |
| 59 | if err != nil { | |
| 60 | t.Fatal(err) | |
| 61 | } | |
| 62 | return iss.ID | |
| 63 | } | |
| 64 | f.coreIssue = mkIssue(f.core, "c1") | |
| 65 | f.siteIssue = mkIssue(f.site, "s1") | |
| 66 | return f | |
| 67 | } | |
| 68 | ||
| 69 | func (f acmeFixture) orgRepos() []int64 { return []int64{f.core.ID, f.site.ID} } | |
| 70 | ||
| 71 | func TestOrgLabelSeenByEveryOrgRepo(t *testing.T) { | |
| 72 | f := newAcme(t) | |
| 73 | if _, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000"); err != nil { | |
| 74 | t.Fatal(err) | |
| 75 | } | |
| 76 | if err := f.s.SetLabel(f.site, "docs", ""); err != nil { | |
| 77 | t.Fatal(err) | |
| 78 | } | |
| 79 | // site sees the org's bug first, then its own docs; core sees only bug; | |
| 80 | // alice/app, user-owned, sees nothing. | |
| 81 | got, err := f.s.ListLabels(f.site, f.orgRepos()) | |
| 82 | if err != nil || len(got) != 2 || got[0].Name != "bug" || !got[0].Org || got[1].Name != "docs" || got[1].Org { | |
| 83 | t.Fatalf("site labels = %+v, %v", got, err) | |
| 84 | } | |
| 85 | if got, _ := f.s.ListLabels(f.core, f.orgRepos()); len(got) != 1 || got[0].Name != "bug" { | |
| 86 | t.Fatalf("core labels = %+v", got) | |
| 87 | } | |
| 88 | if got, _ := f.s.ListLabels(f.app, []int64{f.app.ID}); len(got) != 0 { | |
| 89 | t.Fatalf("app labels = %+v", got) | |
| 90 | } | |
| 91 | colors, _ := f.s.LabelColors(f.core) | |
| 92 | if colors["bug"] != "#ff0000" { | |
| 93 | t.Fatalf("core colours = %v", colors) | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | func TestIssueLabelResolvesOrgRowFirst(t *testing.T) { | |
| 98 | f := newAcme(t) | |
| 99 | if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil { | |
| 100 | t.Fatal(err) | |
| 101 | } | |
| 102 | if err := f.s.SetIssueLabel(f.core, f.coreIssue, "bug", true); err != nil { | |
| 103 | t.Fatal(err) | |
| 104 | } | |
| 105 | if err := f.s.SetIssueLabel(f.site, f.siteIssue, "bug", true); err != nil { | |
| 106 | t.Fatal(err) | |
| 107 | } | |
| 108 | // One org row, no repo rows were created on the fly. | |
| 109 | var n int | |
| 110 | f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug'").Scan(&n) | |
| 111 | if n != 1 { | |
| 112 | t.Fatalf("labels named bug: %d, want 1", n) | |
| 113 | } | |
| 114 | // The count spans the org's readable repos. | |
| 115 | got, _ := f.s.ListOrgLabels(f.org, f.orgRepos()) | |
| 116 | if len(got) != 1 || got[0].Issues != 2 { | |
| 117 | t.Fatalf("org labels = %+v", got) | |
| 118 | } | |
| 119 | got, _ = f.s.ListOrgLabels(f.org, []int64{f.core.ID}) | |
| 120 | if got[0].Issues != 1 { | |
| 121 | t.Fatalf("org labels over core only = %+v", got) | |
| 122 | } | |
| 123 | // A label neither scope has is still created on the fly in the repo. | |
| 124 | if err := f.s.SetIssueLabel(f.core, f.coreIssue, "adhoc", true); err != nil { | |
| 125 | t.Fatal(err) | |
| 126 | } | |
| 127 | if l, err := f.s.LabelByName(f.core, "adhoc"); err != nil || l.Org { | |
| 128 | t.Fatalf("adhoc = %+v, %v", l, err) | |
| 129 | } | |
| 130 | // Removing by name works for the org row too. | |
| 131 | if err := f.s.SetIssueLabel(f.core, f.coreIssue, "bug", false); err != nil { | |
| 132 | t.Fatal(err) | |
| 133 | } | |
| 134 | got, _ = f.s.ListOrgLabels(f.org, f.orgRepos()) | |
| 135 | if got[0].Issues != 1 { | |
| 136 | t.Fatalf("after detach: %+v", got) | |
| 137 | } | |
| 138 | } | |
| 139 | ||
| 140 | func TestRepoLabelRefusedWhenOrgHoldsName(t *testing.T) { | |
| 141 | f := newAcme(t) | |
| 142 | if _, err := f.s.SetOrgLabel(f.org, "bug", ""); err != nil { | |
| 143 | t.Fatal(err) | |
| 144 | } | |
| 145 | if err := f.s.SetLabel(f.core, "bug", "#00ff00"); !errors.Is(err, ErrOrgScoped) { | |
| 146 | t.Fatalf("SetLabel over org name: %v, want ErrOrgScoped", err) | |
| 147 | } | |
| 148 | if err := f.s.DeleteLabel(f.core, "bug"); !errors.Is(err, ErrOrgScoped) { | |
| 149 | t.Fatalf("DeleteLabel of org row: %v, want ErrOrgScoped", err) | |
| 150 | } | |
| 151 | if err := f.s.DeleteLabel(f.core, "nope"); !errors.Is(err, ErrNotFound) { | |
| 152 | t.Fatalf("DeleteLabel of nothing: %v, want ErrNotFound", err) | |
| 153 | } | |
| 154 | // A user-owned repo is unaffected by any org. | |
| 155 | if err := f.s.SetLabel(f.app, "bug", ""); err != nil { | |
| 156 | t.Fatal(err) | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | func TestSetOrgLabelPromotesRepoLabels(t *testing.T) { | |
| 161 | f := newAcme(t) | |
| 162 | if err := f.s.SetIssueLabel(f.core, f.coreIssue, "bug", true); err != nil { | |
| 163 | t.Fatal(err) | |
| 164 | } | |
| 165 | if err := f.s.SetIssueLabel(f.site, f.siteIssue, "bug", true); err != nil { | |
| 166 | t.Fatal(err) | |
| 167 | } | |
| 168 | if err := f.s.SetLabel(f.app, "bug", "#123456"); err != nil { | |
| 169 | t.Fatal(err) | |
| 170 | } | |
| 171 | folded, err := f.s.SetOrgLabel(f.org, "bug", "#ff0000") | |
| 172 | if err != nil || folded != 2 { | |
| 173 | t.Fatalf("SetOrgLabel folded %d, %v; want 2", folded, err) | |
| 174 | } | |
| 175 | var n int | |
| 176 | f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug' AND org_id = ?", f.org).Scan(&n) | |
| 177 | if n != 1 { | |
| 178 | t.Fatalf("org rows named bug: %d", n) | |
| 179 | } | |
| 180 | f.s.DB.QueryRow("SELECT COUNT(*) FROM labels WHERE name = 'bug' AND repo_id IN (?, ?)", f.core.ID, f.site.ID).Scan(&n) | |
| 181 | if n != 0 { | |
| 182 | t.Fatalf("repo rows named bug left under the org: %d", n) | |
| 183 | } | |
| 184 | got, _ := f.s.ListOrgLabels(f.org, f.orgRepos()) | |
| 185 | if len(got) != 1 || got[0].Issues != 2 || got[0].Color != "#ff0000" { | |
| 186 | t.Fatalf("after promote: %+v", got) | |
| 187 | } | |
| 188 | // alice/app's own bug is another owner's and stays. | |
| 189 | if l, err := f.s.LabelByName(f.app, "bug"); err != nil || l.Color != "#123456" { | |
| 190 | t.Fatalf("app bug = %+v, %v", l, err) | |
| 191 | } | |
| 192 | // A second set only recolours. | |
| 193 | if folded, err := f.s.SetOrgLabel(f.org, "bug", "#0000ff"); err != nil || folded != 0 { | |
| 194 | t.Fatalf("second set folded %d, %v", folded, err) | |
| 195 | } | |
| 196 | if err := f.s.DeleteOrgLabel(f.org, "bug"); err != nil { | |
| 197 | t.Fatal(err) | |
| 198 | } | |
| 199 | if err := f.s.DeleteOrgLabel(f.org, "bug"); !errors.Is(err, ErrNotFound) { | |
| 200 | t.Fatalf("second delete: %v", err) | |
| 201 | } | |
| 202 | f.s.DB.QueryRow("SELECT COUNT(*) FROM issue_labels").Scan(&n) | |
| 203 | if n != 0 { | |
| 204 | t.Fatalf("memberships after org delete: %d", n) | |
| 205 | } | |
| 206 | } | |
internal/store/scope.go added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import ( | |
| 4 | "errors" | |
| 5 | "strings" | |
| 6 | ) | |
| 7 | ||
| 8 | // ErrOrgScoped is returned when a repository-level write names a label or | |
| 9 | // milestone its org holds; the org commands manage those. | |
| 10 | var ErrOrgScoped = errors.New("held by the org") | |
| 11 | ||
| 12 | // scopeClause selects the label or milestone rows a repository sees: its | |
| 13 | // own, and its org's when an org owns it. alias is the table alias in the | |
| 14 | // query. | |
| 15 | func scopeClause(alias string, repo Repo) (string, []any) { | |
| 16 | if repo.OwnerKind == "org" { | |
| 17 | return "(" + alias + ".repo_id = ? OR " + alias + ".org_id = ?)", []any{repo.ID, repo.OwnerID} | |
| 18 | } | |
| 19 | return alias + ".repo_id = ?", []any{repo.ID} | |
| 20 | } | |
| 21 | ||
| 22 | // inClause renders ids as a parenthesised placeholder list. An empty set | |
| 23 | // yields (NULL), which matches nothing. | |
| 24 | func inClause(ids []int64) (string, []any) { | |
| 25 | if len(ids) == 0 { | |
| 26 | return "(NULL)", nil | |
| 27 | } | |
| 28 | args := make([]any, len(ids)) | |
| 29 | for i, id := range ids { | |
| 30 | args[i] = id | |
| 31 | } | |
| 32 | return "(" + strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + ")", args | |
| 33 | } | |