internal/store/labels.go
275 lines · 8977 bytes
16 symbols in this file
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "fmt"
7)
8
9// Label is a label with its colour, "" when none was set (the web then
10// derives one from the name), and how many issues and merge requests
11// carry it. Org is true for a label the repository sees through its org.
12type Label struct {
13 Name string `json:"name"`
14 Color string `json:"color,omitempty"`
15 Org bool `json:"org,omitempty"`
16 Issues int64 `json:"issues"`
17 MRs int64 `json:"mrs"`
18}
19
20// labelJoin is where a labelled thing carries its labels. Issues and
21// merge requests attach them identically, differing only in the join
22// table, its column naming the thing, and the thing's own table.
23type labelJoin struct {
24 table string
25 item string
26 items string
27}
28
29var (
30 issueLabelJoin = labelJoin{"issue_labels", "issue_id", "issues"}
31 mrLabelJoin = labelJoin{"mr_labels", "mr_id", "merge_requests"}
32)
33
34// labelRows lists labels under where, with use counted over the issues
35// and merge requests of the readable repositories only, so a private
36// repository's does not show in a count someone outside it can see.
37func (s *Store) labelRows(where string, args []any, readable []int64) ([]Label, error) {
38 in, inArgs := inClause(readable)
39 q := `SELECT l.name, l.color, l.org_id IS NOT NULL,
40 (SELECT COUNT(*) FROM issue_labels il JOIN issues i ON i.id = il.issue_id
41 WHERE il.label_id = l.id AND i.repo_id IN ` + in + `),
42 (SELECT COUNT(*) FROM mr_labels ml JOIN merge_requests m ON m.id = ml.mr_id
43 WHERE ml.label_id = l.id AND m.repo_id IN ` + in + `)
44 FROM labels l WHERE ` + where + ` ORDER BY l.org_id IS NULL, l.name`
45 rows, err := s.DB.Query(q, append(append(append([]any{}, inArgs...), inArgs...), args...)...)
46 if err != nil {
47 return nil, err
48 }
49 defer rows.Close()
50 var out []Label
51 for rows.Next() {
52 var l Label
53 if err := rows.Scan(&l.Name, &l.Color, &l.Org, &l.Issues, &l.MRs); err != nil {
54 return nil, err
55 }
56 out = append(out, l)
57 }
58 return out, rows.Err()
59}
60
61// listItemLabels returns the label names attached to each of a
62// repository's issues or merge requests, keyed by its row id, the org's
63// labels included.
64func (s *Store) listItemLabels(j labelJoin, repo Repo) (map[int64][]string, error) {
65 where, args := scopeClause("l", repo)
66 rows, err := s.DB.Query(`
67 SELECT j.`+j.item+`, l.name FROM `+j.table+` j
68 JOIN labels l ON l.id = j.label_id
69 JOIN `+j.items+` t ON t.id = j.`+j.item+`
70 WHERE t.repo_id = ? AND `+where+` ORDER BY l.name`, append([]any{repo.ID}, args...)...)
71 if err != nil {
72 return nil, err
73 }
74 defer rows.Close()
75 out := map[int64][]string{}
76 for rows.Next() {
77 var id int64
78 var name string
79 if err := rows.Scan(&id, &name); err != nil {
80 return nil, err
81 }
82 out[id] = append(out[id], name)
83 }
84 return out, rows.Err()
85}
86
87// setItemLabel attaches (add) or detaches a label by name. Adding
88// resolves the org's row when the org has the name, else the
89// repository's, creating that on first use.
90func (s *Store) setItemLabel(j labelJoin, repo Repo, itemID int64, name string, add bool) error {
91 tx, err := s.DB.Begin()
92 if err != nil {
93 return err
94 }
95 defer tx.Rollback()
96 where, args := scopeClause("l", repo)
97 if add {
98 if held, err := orgHoldsLabel(tx, repo, name); err != nil {
99 return err
100 } else if !held {
101 if _, err := tx.Exec(`INSERT INTO labels (repo_id, name) VALUES (?, ?)
102 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO NOTHING`, repo.ID, name); err != nil {
103 return err
104 }
105 }
106 if _, err := tx.Exec(`INSERT INTO `+j.table+` (`+j.item+`, label_id)
107 SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ?
108 ORDER BY l.org_id IS NULL LIMIT 1
109 ON CONFLICT DO NOTHING`, append(append([]any{itemID}, args...), name)...); err != nil {
110 return err
111 }
112 } else {
113 res, err := tx.Exec(`DELETE FROM `+j.table+` WHERE `+j.item+` = ? AND label_id IN
114 (SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`,
115 append(append([]any{itemID}, args...), name)...)
116 if err != nil {
117 return err
118 }
119 if n, _ := res.RowsAffected(); n == 0 {
120 return fmt.Errorf("label %q: %w", name, ErrNotFound)
121 }
122 }
123 return tx.Commit()
124}
125
126// ListLabels lists the labels a repository sees: its org's first, then its
127// own, each by name.
128func (s *Store) ListLabels(repo Repo, readable []int64) ([]Label, error) {
129 where, args := scopeClause("l", repo)
130 return s.labelRows(where, args, readable)
131}
132
133// ListOrgLabels lists an org's labels.
134func (s *Store) ListOrgLabels(orgID int64, readable []int64) ([]Label, error) {
135 return s.labelRows("l.org_id = ?", []any{orgID}, readable)
136}
137
138// LabelByName resolves a name the way attaching does: the org's row when
139// the org has it, else the repository's.
140func (s *Store) LabelByName(repo Repo, name string) (Label, error) {
141 where, args := scopeClause("l", repo)
142 var l Label
143 err := s.DB.QueryRow(`SELECT l.name, l.color, l.org_id IS NOT NULL FROM labels l
144 WHERE `+where+` AND l.name = ? ORDER BY l.org_id IS NULL LIMIT 1`,
145 append(args, name)...).Scan(&l.Name, &l.Color, &l.Org)
146 if errors.Is(err, sql.ErrNoRows) {
147 return l, ErrNotFound
148 }
149 return l, err
150}
151
152// orgHoldsLabel reports whether the repository's org has a label of that
153// name; always false for a user-owned repository.
154func orgHoldsLabel(q interface {
155 QueryRow(string, ...any) *sql.Row
156}, repo Repo, name string) (bool, error) {
157 if repo.OwnerKind != "org" {
158 return false, nil
159 }
160 var n int
161 err := q.QueryRow("SELECT COUNT(*) FROM labels WHERE org_id = ? AND name = ?", repo.OwnerID, name).Scan(&n)
162 return n > 0, err
163}
164
165// SetLabel creates the repository's label or sets its colour. A name the
166// org holds is refused with ErrOrgScoped.
167func (s *Store) SetLabel(repo Repo, name, color string) error {
168 tx, err := s.DB.Begin()
169 if err != nil {
170 return err
171 }
172 defer tx.Rollback()
173 if held, err := orgHoldsLabel(tx, repo, name); err != nil || held {
174 if err != nil {
175 return err
176 }
177 return ErrOrgScoped
178 }
179 _, err = tx.Exec(`INSERT INTO labels (repo_id, name, color) VALUES (?, ?, ?)
180 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO UPDATE SET color = excluded.color`,
181 repo.ID, name, color)
182 if err != nil {
183 return err
184 }
185 return tx.Commit()
186}
187
188// DeleteLabel removes the repository's label and takes it off every issue
189// and merge request. An org's label is ErrOrgScoped; no label at all is
190// ErrNotFound.
191func (s *Store) DeleteLabel(repo Repo, name string) error {
192 tx, err := s.DB.Begin()
193 if err != nil {
194 return err
195 }
196 defer tx.Rollback()
197 res, err := tx.Exec("DELETE FROM labels WHERE repo_id = ? AND name = ?", repo.ID, name)
198 if err != nil {
199 return err
200 }
201 if n, _ := res.RowsAffected(); n > 0 {
202 return tx.Commit()
203 }
204 if held, err := orgHoldsLabel(tx, repo, name); err != nil || held {
205 if err != nil {
206 return err
207 }
208 return ErrOrgScoped
209 }
210 return ErrNotFound
211}
212
213// SetOrgLabel creates the org's label or sets its colour. Repositories
214// under the org that hold the name are folded in: their issues and merge
215// requests move to the org's row and their rows go. folded is how many
216// were.
217func (s *Store) SetOrgLabel(orgID int64, name, color string) (int, error) {
218 tx, err := s.DB.Begin()
219 if err != nil {
220 return 0, err
221 }
222 defer tx.Rollback()
223 if _, err := tx.Exec(`INSERT INTO labels (org_id, name, color) VALUES (?, ?, ?)
224 ON CONFLICT (org_id, name) WHERE org_id IS NOT NULL DO UPDATE SET color = excluded.color`,
225 orgID, name, color); err != nil {
226 return 0, err
227 }
228 var orgRow int64
229 if err := tx.QueryRow("SELECT id FROM labels WHERE org_id = ? AND name = ?", orgID, name).Scan(&orgRow); err != nil {
230 return 0, err
231 }
232 rows, err := tx.Query(`SELECT l.id FROM labels l JOIN repos r ON r.id = l.repo_id
233 WHERE r.owner_kind = 'org' AND r.owner_id = ? AND l.name = ?`, orgID, name)
234 if err != nil {
235 return 0, err
236 }
237 repoRows, err := scanIDs(rows)
238 if err != nil {
239 return 0, err
240 }
241 for _, id := range repoRows {
242 if err := foldLabelRow(tx, orgRow, id); err != nil {
243 return 0, err
244 }
245 }
246 return len(repoRows), tx.Commit()
247}
248
249// foldLabelRow moves a repository's label onto the org's row: every issue
250// and merge request carrying it gets the org row, then the repository row
251// goes.
252func foldLabelRow(tx *sql.Tx, orgRow, repoRow int64) error {
253 // OR IGNORE: nothing can carry both today, but the primary key makes
254 // the move safe if it ever did.
255 for _, table := range []string{issueLabelJoin.table, mrLabelJoin.table} {
256 if _, err := tx.Exec("UPDATE OR IGNORE "+table+" SET label_id = ? WHERE label_id = ?", orgRow, repoRow); err != nil {
257 return err
258 }
259 }
260 _, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow)
261 return err
262}
263
264// DeleteOrgLabel removes an org's label from the org and from every issue
265// and merge request under it.
266func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
267 res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name)
268 if err != nil {
269 return err
270 }
271 if n, _ := res.RowsAffected(); n == 0 {
272 return ErrNotFound
273 }
274 return nil
275}