internal/store/labels.go

4e7f0573d0c35b2ec41de44f17f8fe45d3cd2408
gitbay/internal/store/labels.go history · blame · raw

187 lines · 6009 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	repoRows, err := scanIDs(rows)
153	if err != nil {
154		return 0, err
155	}
156	for _, id := range repoRows {
157		if err := foldLabelRow(tx, orgRow, id); err != nil {
158			return 0, err
159		}
160	}
161	return len(repoRows), tx.Commit()
162}
163
164// foldLabelRow moves a repository's label onto the org's row: every issue
165// carrying it gets the org row, then the repository row goes.
166func foldLabelRow(tx *sql.Tx, orgRow, repoRow int64) error {
167	// OR IGNORE: an issue cannot carry both today, but the primary key
168	// makes the move safe if it ever did.
169	if _, err := tx.Exec("UPDATE OR IGNORE issue_labels SET label_id = ? WHERE label_id = ?", orgRow, repoRow); err != nil {
170		return err
171	}
172	_, err := tx.Exec("DELETE FROM labels WHERE id = ?", repoRow)
173	return err
174}
175
176// DeleteOrgLabel removes an org's label from the org and from every issue
177// under it.
178func (s *Store) DeleteOrgLabel(orgID int64, name string) error {
179	res, err := s.DB.Exec("DELETE FROM labels WHERE org_id = ? AND name = ?", orgID, name)
180	if err != nil {
181		return err
182	}
183	if n, _ := res.RowsAffected(); n == 0 {
184		return ErrNotFound
185	}
186	return nil
187}