internal/store/issues.go

160fc0ec2bbbb4367ce5db1814c1a7404b656048
gitbay/internal/store/issues.go history · blame · raw

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