internal/store/labels.go
186 lines · 5873 bytes
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8// Label is an issue label with its colour, "" when none was set (the web
9// then derives one from the name), and how many issues carry it. Org is
10// true for a label the repository sees through its org.
11type Label struct {
12 Name string `json:"name"`
13 Color string `json:"color,omitempty"`
14 Org bool `json:"org,omitempty"`
15 Issues int64 `json:"issues"`
16}
17
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...)...)
28 if err != nil {
29 return nil, err
30 }
31 defer rows.Close()
32 var out []Label
33 for rows.Next() {
34 var l Label
35 if err := rows.Scan(&l.Name, &l.Color, &l.Org, &l.Issues); err != nil {
36 return nil, err
37 }
38 out = append(out, l)
39 }
40 return out, rows.Err()
41}
42
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 tx, err := s.DB.Begin()
86 if err != nil {
87 return err
88 }
89 defer tx.Rollback()
90 if held, err := orgHoldsLabel(tx, repo, name); err != nil || held {
91 if err != nil {
92 return err
93 }
94 return ErrOrgScoped
95 }
96 _, err = tx.Exec(`INSERT INTO labels (repo_id, name, color) VALUES (?, ?, ?)
97 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO UPDATE SET color = excluded.color`,
98 repo.ID, name, color)
99 if err != nil {
100 return err
101 }
102 return tx.Commit()
103}
104
105// DeleteLabel removes the repository's label and takes it off every issue.
106// An org's label is ErrOrgScoped; no label at all is ErrNotFound.
107func (s *Store) DeleteLabel(repo Repo, name string) error {
108 tx, err := s.DB.Begin()
109 if err != nil {
110 return err
111 }
112 defer tx.Rollback()
113 res, err := tx.Exec("DELETE FROM labels WHERE repo_id = ? AND name = ?", repo.ID, name)
114 if err != nil {
115 return err
116 }
117 if n, _ := res.RowsAffected(); n > 0 {
118 return tx.Commit()
119 }
120 if held, err := orgHoldsLabel(tx, repo, name); err != nil || held {
121 if err != nil {
122 return err
123 }
124 return ErrOrgScoped
125 }
126 return ErrNotFound
127}
128
129// SetOrgLabel creates the org's label or sets its colour. Repositories
130// under the org that hold the name are folded in: their issues move to
131// the org's row and their rows go. folded is how many were.
132func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
133 tx, err := s.DB.Begin()
134 if err != nil {
135 return 0, err
136 }
137 defer tx.Rollback()
138 if _, err := tx.Exec(`INSERT INTO labels (org_id, name, color) VALUES (?, ?, ?)
139 ON CONFLICT (org_id, name) WHERE org_id IS NOT NULL DO UPDATE SET color = excluded.color`,
140 orgID, name, color); err != nil {
141 return 0, err
142 }
143 var orgRow int64
144 if err := tx.QueryRow("SELECT id FROM labels WHERE org_id = ? AND name = ?", orgID, name).Scan(&orgRow); err != nil {
145 return 0, err
146 }
147 rows, err := tx.Query(`SELECT l.id FROM labels l JOIN repos r ON r.id = l.repo_id
148 WHERE r.owner_kind = 'org' AND r.owner_id = ? AND l.name = ?`, orgID, name)
149 if err != nil {
150 return 0, err
151 }
152 var repoRows []int64
153 for rows.Next() {
154 var id int64
155 if err := rows.Scan(&id); err != nil {
156 rows.Close()
157 return 0, err
158 }
159 repoRows = append(repoRows, id)
160 }
161 rows.Close()
162 for _, id := range repoRows {
163 // OR IGNORE: an issue cannot carry both today, but the primary key
164 // makes the move safe if it ever did.
165 if _, err := tx.Exec("UPDATE OR IGNORE issue_labels SET label_id = ? WHERE label_id = ?", orgRow, id); err != nil {
166 return 0, err
167 }
168 if _, err := tx.Exec("DELETE FROM labels WHERE id = ?", id); err != nil {
169 return 0, err
170 }
171 }
172 return len(repoRows), tx.Commit()
173}
174
175// DeleteOrgLabel removes an org's label from the org and from every issue
176// under it.
177func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
178 res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name)
179 if err != nil {
180 return err
181 }
182 if n, _ := res.RowsAffected(); n == 0 {
183 return ErrNotFound
184 }
185 return nil
186}