internal/store/labels.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/internal/store/labels.go history · blame · raw

275 lines · 8977 bytes

  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}