internal/store/issues.go

784b5dfad3f6ed718ada2a43910225c220abb310
gitbay/internal/store/issues.go history · blame · raw

405 lines · 12062 bytes

  1package store
  2
  3import (
  4	"database/sql"
  5	"errors"
  6	"fmt"
  7	"strings"
  8)
  9
 10type Issue struct {
 11	ID         int64
 12	RepoID     int64
 13	Number     int64
 14	Author     string
 15	Title      string
 16	Body       string
 17	BodyFormat string // md | org
 18	State      string // open | closed
 19	Milestone  string
 20	CreatedAt  string
 21	UpdatedAt  string
 22	Labels     []string
 23	Assignees  []string
 24}
 25
 26type IssueComment struct {
 27	Author     string
 28	Body       string
 29	BodyFormat string // md | org
 30	CreatedAt  string
 31	Kind       string // comment | system
 32}
 33
 34// CreateIssue allocates the per-repo number from the repo counter inside the
 35// same transaction as the insert — MAX(number)+1 races.
 36func (s *Store) CreateIssue(repoID, authorID int64, title, body, format string) (int64, error) {
 37	tx, err := s.DB.Begin()
 38	if err != nil {
 39		return 0, err
 40	}
 41	defer tx.Rollback()
 42	if _, err := tx.Exec("UPDATE repos SET issue_counter = issue_counter + 1 WHERE id = ?", repoID); err != nil {
 43		return 0, err
 44	}
 45	var n int64
 46	if err := tx.QueryRow("SELECT issue_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
 47		return 0, err
 48	}
 49	if _, err := tx.Exec(
 50		"INSERT INTO issues (repo_id, number, author_id, title, body, body_format) VALUES (?, ?, ?, ?, ?, ?)",
 51		repoID, n, authorID, title, body, format); err != nil {
 52		return 0, err
 53	}
 54	return n, tx.Commit()
 55}
 56
 57func (s *Store) IssueByNumber(repoID, number int64) (Issue, error) {
 58	var i Issue
 59	err := s.DB.QueryRow(`
 60		SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.body_format, i.state,
 61		       COALESCE(m.title, ''), i.created_at, i.updated_at
 62		FROM issues i JOIN users u ON u.id = i.author_id
 63		LEFT JOIN milestones m ON m.id = i.milestone_id
 64		WHERE i.repo_id = ? AND i.number = ?`, repoID, number).
 65		Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.BodyFormat, &i.State, &i.Milestone, &i.CreatedAt, &i.UpdatedAt)
 66	if errors.Is(err, sql.ErrNoRows) {
 67		return i, ErrNotFound
 68	}
 69	if err != nil {
 70		return i, err
 71	}
 72	if i.Labels, err = s.issueStrings(i.ID, `
 73		SELECT l.name FROM issue_labels il JOIN labels l ON l.id = il.label_id
 74		WHERE il.issue_id = ? ORDER BY l.name`); err != nil {
 75		return i, err
 76	}
 77	i.Assignees, err = s.issueStrings(i.ID, `
 78		SELECT u.username FROM issue_assignees ia JOIN users u ON u.id = ia.user_id
 79		WHERE ia.issue_id = ? ORDER BY u.username`)
 80	return i, err
 81}
 82
 83func (s *Store) issueStrings(issueID int64, query string) ([]string, error) {
 84	rows, err := s.DB.Query(query, issueID)
 85	if err != nil {
 86		return nil, err
 87	}
 88	defer rows.Close()
 89	var out []string
 90	for rows.Next() {
 91		var v string
 92		if err := rows.Scan(&v); err != nil {
 93			return nil, err
 94		}
 95		out = append(out, v)
 96	}
 97	return out, rows.Err()
 98}
 99
100// ListIssues returns issues for a repo; state is "open", "closed", or
101// "all". limit 0 means everything; before (an issue number) starts the
102// page strictly below it, matching the number-descending order.
103// IssueFilter narrows a listing. Empty strings match anything; State
104// "all" too. Milestone "none" selects issues with no milestone.
105type IssueFilter struct {
106	State     string
107	Label     string
108	Assignee  string
109	Author    string
110	Milestone string
111	Search    string // full-text over title and body
112	Limit     int
113	Before    int64
114}
115
116func (s *Store) ListIssues(repoID int64, state string, limit int, before int64) ([]Issue, error) {
117	return s.QueryIssues(repoID, IssueFilter{State: state, Limit: limit, Before: before})
118}
119
120// QueryIssues lists a repository's issues, newest first, narrowed by f.
121func (s *Store) QueryIssues(repoID int64, f IssueFilter) ([]Issue, error) {
122	q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.body_format, i.state,
123	             COALESCE(m.title, ''), i.created_at, i.updated_at
124	      FROM issues i JOIN users u ON u.id = i.author_id
125	      LEFT JOIN milestones m ON m.id = i.milestone_id
126	      WHERE i.repo_id = ?`
127	args := []any{repoID}
128	if f.State != "" && f.State != "all" {
129		q += " AND i.state = ?"
130		args = append(args, f.State)
131	}
132	if f.Label != "" {
133		q += ` AND EXISTS (SELECT 1 FROM issue_labels il JOIN labels l ON l.id = il.label_id
134			WHERE il.issue_id = i.id AND l.name = ?)`
135		args = append(args, f.Label)
136	}
137	if f.Assignee != "" {
138		q += ` AND EXISTS (SELECT 1 FROM issue_assignees ia JOIN users au ON au.id = ia.user_id
139			WHERE ia.issue_id = i.id AND au.username = ?)`
140		args = append(args, f.Assignee)
141	}
142	if f.Author != "" {
143		q += " AND u.username = ?"
144		args = append(args, f.Author)
145	}
146	switch f.Milestone {
147	case "":
148	case "none":
149		q += " AND i.milestone_id IS NULL"
150	default:
151		q += " AND m.title = ?"
152		args = append(args, f.Milestone)
153	}
154	if f.Search != "" {
155		q += " AND i.id IN (SELECT rowid FROM issue_fts WHERE issue_fts MATCH ?)"
156		args = append(args, FTSQuery(f.Search))
157	}
158	if f.Before > 0 {
159		q += " AND i.number < ?"
160		args = append(args, f.Before)
161	}
162	q += " ORDER BY i.number DESC"
163	if f.Limit > 0 {
164		q += " LIMIT ?"
165		args = append(args, f.Limit)
166	}
167	rows, err := s.DB.Query(q, args...)
168	if err != nil {
169		return nil, err
170	}
171	defer rows.Close()
172	var out []Issue
173	for rows.Next() {
174		var i Issue
175		if err := rows.Scan(&i.ID, &i.RepoID, &i.Number, &i.Author, &i.Title, &i.Body, &i.BodyFormat, &i.State, &i.Milestone, &i.CreatedAt, &i.UpdatedAt); err != nil {
176			return nil, err
177		}
178		out = append(out, i)
179	}
180	return out, rows.Err()
181}
182
183// UpdateIssueText edits title, body, and/or markup format; nil leaves a field
184// unchanged.
185func (s *Store) UpdateIssueText(issueID int64, title, body, format *string) error {
186	set, args := []string{}, []any{}
187	if title != nil {
188		set, args = append(set, "title = ?"), append(args, *title)
189	}
190	if body != nil {
191		set, args = append(set, "body = ?"), append(args, *body)
192	}
193	if format != nil {
194		set, args = append(set, "body_format = ?"), append(args, *format)
195	}
196	if len(set) == 0 {
197		return nil
198	}
199	set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
200	args = append(args, issueID)
201	res, err := s.DB.Exec("UPDATE issues SET "+strings.Join(set, ", ")+" WHERE id = ?", args...)
202	if err != nil {
203		return err
204	}
205	if n, _ := res.RowsAffected(); n == 0 {
206		return ErrNotFound
207	}
208	return nil
209}
210
211func (s *Store) SetIssueState(issueID int64, state string) error {
212	res, err := s.DB.Exec(
213		"UPDATE issues SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
214		state, issueID)
215	if err != nil {
216		return err
217	}
218	if n, _ := res.RowsAffected(); n == 0 {
219		return ErrNotFound
220	}
221	return nil
222}
223
224func (s *Store) AddIssueComment(issueID, authorID int64, body, format string) error {
225	tx, err := s.DB.Begin()
226	if err != nil {
227		return err
228	}
229	defer tx.Rollback()
230	if _, err := tx.Exec(
231		"INSERT INTO issue_comments (issue_id, author_id, body, body_format) VALUES (?, ?, ?, ?)",
232		issueID, authorID, body, format); err != nil {
233		return err
234	}
235	if _, err := tx.Exec(
236		"UPDATE issues SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", issueID); err != nil {
237		return err
238	}
239	return tx.Commit()
240}
241
242func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
243	rows, err := s.DB.Query(`
244		SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
245		       c.body, c.body_format, c.created_at, c.kind
246		FROM issue_comments c JOIN users u ON u.id = c.author_id
247		WHERE c.issue_id = ? ORDER BY c.id`, issueID)
248	if err != nil {
249		return nil, err
250	}
251	defer rows.Close()
252	var out []IssueComment
253	for rows.Next() {
254		var c IssueComment
255		if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil {
256			return nil, err
257		}
258		out = append(out, c)
259	}
260	return out, rows.Err()
261}
262
263// AddIssueSystemComment records an informational entry (commit references,
264// automated closes). The actor is kept for provenance but the entry
265// displays as coming from the system, not the user.
266func (s *Store) AddIssueSystemComment(issueID, actorID int64, body string) error {
267	_, err := s.DB.Exec(
268		"INSERT INTO issue_comments (issue_id, author_id, body, kind) VALUES (?, ?, ?, 'system')",
269		issueID, actorID, body)
270	return err
271}
272
273// ListIssueLabels returns the label names attached to each issue of a
274// repo, keyed by issue id. Used by the web issue listing; ListIssues
275// itself stays label-free for the CLI's lean list output.
276func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) {
277	rows, err := s.DB.Query(`
278		SELECT il.issue_id, l.name FROM issue_labels il
279		JOIN labels l ON l.id = il.label_id
280		WHERE l.repo_id = ? ORDER BY l.name`, repoID)
281	if err != nil {
282		return nil, err
283	}
284	defer rows.Close()
285	out := map[int64][]string{}
286	for rows.Next() {
287		var id int64
288		var name string
289		if err := rows.Scan(&id, &name); err != nil {
290			return nil, err
291		}
292		out[id] = append(out[id], name)
293	}
294	return out, rows.Err()
295}
296
297// LabelColors returns the colours of the labels a repository sees, keyed
298// by name. Labels with no stored colour map to "".
299func (s *Store) LabelColors(repo Repo) (map[string]string, error) {
300	where, args := scopeClause("l", repo)
301	rows, err := s.DB.Query("SELECT l.name, l.color FROM labels l WHERE "+where, args...)
302	if err != nil {
303		return nil, err
304	}
305	defer rows.Close()
306	out := map[string]string{}
307	for rows.Next() {
308		var name, color string
309		if err := rows.Scan(&name, &color); err != nil {
310			return nil, err
311		}
312		out[name] = color
313	}
314	return out, rows.Err()
315}
316
317// SetIssueLabel attaches (add) or detaches a label by name. Adding
318// resolves the org's row when the org has the name, else the repository's,
319// creating that on first use.
320func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error {
321	tx, err := s.DB.Begin()
322	if err != nil {
323		return err
324	}
325	defer tx.Rollback()
326	where, args := scopeClause("l", repo)
327	if add {
328		if held, err := orgHoldsLabel(tx, repo, name); err != nil {
329			return err
330		} else if !held {
331			if _, err := tx.Exec(`INSERT INTO labels (repo_id, name) VALUES (?, ?)
332				ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO NOTHING`, repo.ID, name); err != nil {
333				return err
334			}
335		}
336		if _, err := tx.Exec(`INSERT INTO issue_labels (issue_id, label_id)
337			SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ?
338			ORDER BY l.org_id IS NULL LIMIT 1
339			ON CONFLICT DO NOTHING`, append(append([]any{issueID}, args...), name)...); err != nil {
340			return err
341		}
342	} else {
343		res, err := tx.Exec(`DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
344			(SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`,
345			append(append([]any{issueID}, args...), name)...)
346		if err != nil {
347			return err
348		}
349		if n, _ := res.RowsAffected(); n == 0 {
350			return fmt.Errorf("label %q: %w", name, ErrNotFound)
351		}
352	}
353	return tx.Commit()
354}
355
356// SetIssueAssignee adds or removes an assignee by user id.
357func (s *Store) SetIssueAssignee(issueID, userID int64, add bool) error {
358	if add {
359		_, err := s.DB.Exec(
360			"INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
361			issueID, userID)
362		return err
363	}
364	res, err := s.DB.Exec(
365		"DELETE FROM issue_assignees WHERE issue_id = ? AND user_id = ?", issueID, userID)
366	if err != nil {
367		return err
368	}
369	if n, _ := res.RowsAffected(); n == 0 {
370		return ErrNotFound
371	}
372	return nil
373}
374
375// RecordEvent appends to the event log and enqueues a delivery for every
376// active webhook on the repo whose event filter matches.
377func (s *Store) RecordEvent(repoID, actorID int64, kind, dataJSON string) error {
378	if dataJSON == "" {
379		dataJSON = "{}"
380	}
381	tx, err := s.DB.Begin()
382	if err != nil {
383		return err
384	}
385	defer tx.Rollback()
386	res, err := tx.Exec(
387		"INSERT INTO events (repo_id, actor_id, kind, data_json) VALUES (?, ?, ?, ?)",
388		repoID, actorID, kind, dataJSON)
389	if err != nil {
390		return err
391	}
392	eventID, err := res.LastInsertId()
393	if err != nil {
394		return err
395	}
396	if _, err := tx.Exec(`
397		INSERT INTO webhook_deliveries (webhook_id, event_id)
398		SELECT id, ? FROM webhooks
399		WHERE repo_id = ? AND active = 1
400		  AND (events = '*' OR ',' || events || ',' LIKE '%,' || ? || ',%')`,
401		eventID, repoID, kind); err != nil {
402		return err
403	}
404	return tx.Commit()
405}