internal/store/issues.go

075af6c002f84e2edcbd793668a23239fbe18636
gitbay/internal/store/issues.go history · blame · raw

296 lines · 8159 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"errors"
  6	"fmt"
  7)
  8
  9type Issue struct {
 10	ID        int64
 11	RepoID    int64
 12	Number    int64
 13	Author    string
 14	Title     string
 15	Body      string
 16	State     string // open | closed
 17	CreatedAt string
 18	UpdatedAt string
 19	Labels    []string
 20	Assignees []string
 21}
 22
 23type IssueComment struct {
 24	Author    string
 25	Body      string
 26	CreatedAt string
 27}
 28
 29// CreateIssue allocates the per-repo number from the repo counter inside the
 30// same transaction as the insert — MAX(number)+1 races.
 31func (s *Store) CreateIssue(repoID, authorID int64, title, body string) (int64, error) {
 32	tx, err := s.DB.Begin()
 33	if err != nil {
 34		return 0, err
 35	}
 36	defer tx.Rollback()
 37	if _, err := tx.Exec("UPDATE repos SET issue_counter = issue_counter + 1 WHERE id = ?", repoID); err != nil {
 38		return 0, err
 39	}
 40	var n int64
 41	if err := tx.QueryRow("SELECT issue_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
 42		return 0, err
 43	}
 44	if _, err := tx.Exec(
 45		"INSERT INTO issues (repo_id, number, author_id, title, body) VALUES (?, ?, ?, ?, ?)",
 46		repoID, n, authorID, title, body); err != nil {
 47		return 0, err
 48	}
 49	return n, tx.Commit()
 50}
 51
 52func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) {
 53	var i Issue
 54	err := s.DB.QueryRow(`
 55		SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, i.created_at, i.updated_at
 56		FROM issues i JOIN users u ON u.id = i.author_id
 57		WHERE i.repo_id = ? AND i.number = ?`, repoID, number).
 58		Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.CreatedAt, &i.UpdatedAt)
 59	if errors.Is(err, sql.ErrNoRows) {
 60		return i, ErrNotFound
 61	}
 62	if err != nil {
 63		return i, err
 64	}
 65	if i.Labels, err = s.issueStrings(i.ID, `
 66		SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id
 67		WHERE il.issue_id = ? ORDER BY l.name`); err != nil {
 68		return i, err
 69	}
 70	i.Assignees, err = s.issueStrings(i.ID, `
 71		SELECT u.username FROM issue_assignees ia JOIN users u ON u.id = ia.user_id
 72		WHERE ia.issue_id = ? ORDER BY u.username`)
 73	return i, err
 74}
 75
 76func (s *Store) issueStrings(issueID int64, query string) ([]string, error) {
 77	rows, err := s.DB.Query(query, issueID)
 78	if err != nil {
 79		return nil, err
 80	}
 81	defer rows.Close()
 82	var out []string
 83	for rows.Next() {
 84		var v string
 85		if err := rows.Scan(&v); err != nil {
 86			return nil, err
 87		}
 88		out = append(out, v)
 89	}
 90	return out, rows.Err()
 91}
 92
 93// ListIssues returns issues for a repo; state is "open", "closed", or "all".
 94func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) {
 95	q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state, i.created_at, i.updated_at
 96	      FROM issues i JOIN users u ON u.id = i.author_id WHERE i.repo_id = ?`
 97	args := []any{repoID}
 98	if state != "all" {
 99		q += " AND i.state = ?"
100		args = append(args, state)
101	}
102	q += " ORDER BY i.number DESC"
103	rows, err := s.DB.Query(q, args...)
104	if err != nil {
105		return nil, err
106	}
107	defer rows.Close()
108	var out []Issue
109	for rows.Next() {
110		var i Issue
111		if err := rows.Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.State, &i.CreatedAt, &i.UpdatedAt); err != nil {
112			return nil, err
113		}
114		out = append(out, i)
115	}
116	return out, rows.Err()
117}
118
119func (s *Store) SetIssueState(issueID int64, state string) error {
120	res, err := s.DB.Exec(
121		"UPDATE issues SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
122		state, issueID)
123	if err != nil {
124		return err
125	}
126	if n, _ := res.RowsAffected(); n == 0 {
127		return ErrNotFound
128	}
129	return nil
130}
131
132func (s *Store) AddIssueComment(issueID, authorID int64, body string) error {
133	tx, err := s.DB.Begin()
134	if err != nil {
135		return err
136	}
137	defer tx.Rollback()
138	if _, err := tx.Exec(
139		"INSERT INTO issue_comments (issue_id, author_id, body) VALUES (?, ?, ?)",
140		issueID, authorID, body); err != nil {
141		return err
142	}
143	if _, err := tx.Exec(
144		"UPDATE issues SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", issueID); err != nil {
145		return err
146	}
147	return tx.Commit()
148}
149
150func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
151	rows, err := s.DB.Query(`
152		SELECT u.username, c.body, c.created_at
153		FROM issue_comments c JOIN users u ON u.id = c.author_id
154		WHERE c.issue_id = ? ORDER BY c.id`, issueID)
155	if err != nil {
156		return nil, err
157	}
158	defer rows.Close()
159	var out []IssueComment
160	for rows.Next() {
161		var c IssueComment
162		if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt); err != nil {
163			return nil, err
164		}
165		out = append(out, c)
166	}
167	return out, rows.Err()
168}
169
170// ListIssueLabels returns the label names attached to each issue of a
171// repo, keyed by issue id. Used by the web issue listing; ListIssues
172// itself stays label-free for the CLI's lean list output.
173func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) {
174	rows, err := s.DB.Query(`
175		SELECT il.issue_id, l.name FROM issue_labels il
176		JOIN labels l ON l.id = il.label_id
177		WHERE l.repo_id = ? ORDER BY l.name`, repoID)
178	if err != nil {
179		return nil, err
180	}
181	defer rows.Close()
182	out := map[int64][]string{}
183	for rows.Next() {
184		var id int64
185		var name string
186		if err := rows.Scan(&id, &name); err != nil {
187			return nil, err
188		}
189		out[id] = append(out[id], name)
190	}
191	return out, rows.Err()
192}
193
194// LabelColors returns the repo's label colors keyed by label name. Labels
195// with no stored color map to "".
196func (s *Store) LabelColors(repoID int64) (map[string]string, error) {
197	rows, err := s.DB.Query("SELECT name, color FROM labels WHERE repo_id = ?", repoID)
198	if err != nil {
199		return nil, err
200	}
201	defer rows.Close()
202	out := map[string]string{}
203	for rows.Next() {
204		var name, color string
205		if err := rows.Scan(&name, &color); err != nil {
206			return nil, err
207		}
208		out[name] = color
209	}
210	return out, rows.Err()
211}
212
213// SetIssueLabel attaches (add) or detaches a label, creating the repo label
214// on first use.
215func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error {
216	tx, err := s.DB.Begin()
217	if err != nil {
218		return err
219	}
220	defer tx.Rollback()
221	if add {
222		if _, err := tx.Exec(
223			"INSERT INTO labels (repo_id, name) VALUES (?, ?) ON CONFLICT (repo_id, name) DO NOTHING",
224			repoID, name); err != nil {
225			return err
226		}
227		if _, err := tx.Exec(`
228			INSERT INTO issue_labels (issue_id, label_id)
229			SELECT ?, id FROM labels WHERE repo_id = ? AND name = ?
230			ON CONFLICT DO NOTHING`, issueID, repoID, name); err != nil {
231			return err
232		}
233	} else {
234		res, err := tx.Exec(`
235			DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
236			(SELECT id FROM labels WHERE repo_id = ? AND name = ?)`, issueID, repoID, name)
237		if err != nil {
238			return err
239		}
240		if n, _ := res.RowsAffected(); n == 0 {
241			return fmt.Errorf("label %q: %w", name, ErrNotFound)
242		}
243	}
244	return tx.Commit()
245}
246
247// SetIssueAssignee adds or removes an assignee by user id.
248func (s *Store) SetIssueAssignee(issueID, userID int64, add bool) error {
249	if add {
250		_, err := s.DB.Exec(
251			"INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
252			issueID, userID)
253		return err
254	}
255	res, err := s.DB.Exec(
256		"DELETE FROM issue_assignees WHERE issue_id = ? AND user_id = ?", issueID, userID)
257	if err != nil {
258		return err
259	}
260	if n, _ := res.RowsAffected(); n == 0 {
261		return ErrNotFound
262	}
263	return nil
264}
265
266// RecordEvent appends to the event log and enqueues a delivery for every
267// active webhook on the repo whose event filter matches.
268func (s *Store) RecordEvent(repoID, actorID int64, kind, dataJSON string) error {
269	if dataJSON == "" {
270		dataJSON = "{}"
271	}
272	tx, err := s.DB.Begin()
273	if err != nil {
274		return err
275	}
276	defer tx.Rollback()
277	res, err := tx.Exec(
278		"INSERT INTO events (repo_id, actor_id, kind, data_json) VALUES (?, ?, ?, ?)",
279		repoID, actorID, kind, dataJSON)
280	if err != nil {
281		return err
282	}
283	eventID, err := res.LastInsertId()
284	if err != nil {
285		return err
286	}
287	if _, err := tx.Exec(`
288		INSERT INTO webhook_deliveries (webhook_id, event_id)
289		SELECT id, ? FROM webhooks
290		WHERE repo_id = ? AND active = 1
291		  AND (events = '*' OR ',' || events || ',' LIKE '%,' || ? || ',%')`,
292		eventID, repoID, kind); err != nil {
293		return err
294	}
295	return tx.Commit()
296}