Commit d722ba57f3

d722ba57f32e8cb9d26753c80b3a0add31906429

parent: cc98ed4532

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-11 15:02 UTC

store: labels resolve through the repository's org

Ref #203
internal/store/issues.go +23 −17
@@ -294,10 +294,11 @@ func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) {
294294 return out, rows.Err()
295295}
296296
297// LabelColors returns the repo's label colors keyed by label name. Labels
298// with no stored color map to "".
299func (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 "".
299func (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...)
301302 if err != nil {
302303 return nil, err
303304 }
@@ -313,30 +314,35 @@ func (s *Store) LabelColors(repoID int64) (map[string]string, error) {
313314 return out, rows.Err()
314315}
315316
316// SetIssueLabel attaches (add) or detaches a label, creating the repo label
317// on first use.
318func (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.
320func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error {
319321 tx, err := s.DB.Begin()
320322 if err != nil {
321323 return err
322324 }
323325 defer tx.Rollback()
326 where, args := scopeClause("l", repo)
324327 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 {
328329 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 }
329335 }
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 {
334340 return err
335341 }
336342 } 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)...)
340346 if err != nil {
341347 return err
342348 }
internal/store/labels.go +135 −12
@@ -1,5 +1,10 @@
11package store
22
3import (
4 "database/sql"
5 "errors"
6)
7
38// Label is an issue label with its colour, "" when none was set (the web
49// then derives one from the name), and how many issues carry it. Org is
510// true for a label the repository sees through its org.
@@ -10,11 +15,16 @@ type Label struct {
1015 Issues int64 `json:"issues"`
1116}
1217
13// ListLabels lists a repository's labels by name.
14func (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.
21func (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...)...)
1828 if err != nil {
1929 return nil, err
2030 }
@@ -22,7 +32,7 @@ func (s *Store) ListLabels(repoID int64) ([]Label, error) {
2232 var out []Label
2333 for rows.Next() {
2434 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 {
2636 return nil, err
2737 }
2838 out = append(out, l)
@@ -30,16 +40,129 @@ func (s *Store) ListLabels(repoID int64) ([]Label, error) {
3040 return out, rows.Err()
3141}
3242
33// SetLabel creates the label or sets its colour.
34func (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.
45func (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.
51func (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.
57func (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.
71func 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.
84func (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 }
3591 _, 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)
3794 return err
3895}
3996
40// DeleteLabel removes a label and takes it off every issue.
41func (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.
99func (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.
119func (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.
164func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
165 res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name)
43166 if err != nil {
44167 return err
45168 }
internal/store/labels_test.go added +206
@@ -0,0 +1,206 @@
1package store
2
3import (
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.
10type 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
20func 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
69func (f acmeFixture) orgRepos() []int64 { return []int64{f.core.ID, f.site.ID} }
70
71func 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
97func 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
140func 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
160func 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 @@
1package store
2
3import (
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.
10var 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.
15func 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.
24func 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}