internal/store/issues.go

617b05d09be747ad310179ce5432dec98ebe2c17
gitbay/internal/store/issues.go history · blame · raw

339 lines · 9641 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 "all".
 99func (s *Store) ListIssues(repoID int64, state string) ([]Issue, error) {
100	q := `SELECT i.id, i.repo_id, i.number, u.username, i.title, i.body, i.state,
101	             COALESCE(m.title, ''), i.created_at, i.updated_at
102	      FROM issues i JOIN users u ON u.id = i.author_id
103	      LEFT JOIN milestones m ON m.id = i.milestone_id
104	      WHERE i.repo_id = ?`
105	args := []any{repoID}
106	if state != "all" {
107		q += " AND i.state = ?"
108		args = append(args, state)
109	}
110	q += " ORDER BY i.number DESC"
111	rows, err := s.DB.Query(q, args...)
112	if err != nil {
113		return nil, err
114	}
115	defer rows.Close()
116	var out []Issue
117	for rows.Next() {
118		var i Issue
119		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 {
120			return nil, err
121		}
122		out = append(out, i)
123	}
124	return out, rows.Err()
125}
126
127// UpdateIssueText edits title and/or body; nil leaves a field unchanged.
128func (s *Store) UpdateIssueText(issueID int64, title, body *string) error {
129	set, args := []string{}, []any{}
130	if title != nil {
131		set, args = append(set, "title = ?"), append(args, *title)
132	}
133	if body != nil {
134		set, args = append(set, "body = ?"), append(args, *body)
135	}
136	if len(set) == 0 {
137		return nil
138	}
139	set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
140	args = append(args, issueID)
141	res, err := s.DB.Exec("UPDATE issues SET "+strings.Join(set, ", ")+" WHERE id = ?", args...)
142	if err != nil {
143		return err
144	}
145	if n, _ := res.RowsAffected(); n == 0 {
146		return ErrNotFound
147	}
148	return nil
149}
150
151func (s *Store) SetIssueState(issueID int64, state string) error {
152	res, err := s.DB.Exec(
153		"UPDATE issues SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
154		state, issueID)
155	if err != nil {
156		return err
157	}
158	if n, _ := res.RowsAffected(); n == 0 {
159		return ErrNotFound
160	}
161	return nil
162}
163
164func (s *Store) AddIssueComment(issueID, authorID int64, body string) error {
165	tx, err := s.DB.Begin()
166	if err != nil {
167		return err
168	}
169	defer tx.Rollback()
170	if _, err := tx.Exec(
171		"INSERT INTO issue_comments (issue_id, author_id, body) VALUES (?, ?, ?)",
172		issueID, authorID, body); err != nil {
173		return err
174	}
175	if _, err := tx.Exec(
176		"UPDATE issues SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", issueID); err != nil {
177		return err
178	}
179	return tx.Commit()
180}
181
182func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) {
183	rows, err := s.DB.Query(`
184		SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
185		       c.body, c.created_at, c.kind
186		FROM issue_comments c JOIN users u ON u.id = c.author_id
187		WHERE c.issue_id = ? ORDER BY c.id`, issueID)
188	if err != nil {
189		return nil, err
190	}
191	defer rows.Close()
192	var out []IssueComment
193	for rows.Next() {
194		var c IssueComment
195		if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt, &c.Kind); err != nil {
196			return nil, err
197		}
198		out = append(out, c)
199	}
200	return out, rows.Err()
201}
202
203// AddIssueSystemComment records an informational entry (commit references,
204// automated closes). The actor is kept for provenance but the entry
205// displays as coming from the system, not the user.
206func (s *Store) AddIssueSystemComment(issueID, actorID int64, body string) error {
207	_, err := s.DB.Exec(
208		"INSERT INTO issue_comments (issue_id, author_id, body, kind) VALUES (?, ?, ?, 'system')",
209		issueID, actorID, body)
210	return err
211}
212
213// ListIssueLabels returns the label names attached to each issue of a
214// repo, keyed by issue id. Used by the web issue listing; ListIssues
215// itself stays label-free for the CLI's lean list output.
216func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) {
217	rows, err := s.DB.Query(`
218		SELECT il.issue_id, l.name FROM issue_labels il
219		JOIN labels l ON l.id = il.label_id
220		WHERE l.repo_id = ? ORDER BY l.name`, repoID)
221	if err != nil {
222		return nil, err
223	}
224	defer rows.Close()
225	out := map[int64][]string{}
226	for rows.Next() {
227		var id int64
228		var name string
229		if err := rows.Scan(&id, &name); err != nil {
230			return nil, err
231		}
232		out[id] = append(out[id], name)
233	}
234	return out, rows.Err()
235}
236
237// LabelColors returns the repo's label colors keyed by label name. Labels
238// with no stored color map to "".
239func (s *Store) LabelColors(repoID int64) (map[string]string, error) {
240	rows, err := s.DB.Query("SELECT name, color FROM labels WHERE repo_id = ?", repoID)
241	if err != nil {
242		return nil, err
243	}
244	defer rows.Close()
245	out := map[string]string{}
246	for rows.Next() {
247		var name, color string
248		if err := rows.Scan(&name, &color); err != nil {
249			return nil, err
250		}
251		out[name] = color
252	}
253	return out, rows.Err()
254}
255
256// SetIssueLabel attaches (add) or detaches a label, creating the repo label
257// on first use.
258func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error {
259	tx, err := s.DB.Begin()
260	if err != nil {
261		return err
262	}
263	defer tx.Rollback()
264	if add {
265		if _, err := tx.Exec(
266			"INSERT INTO labels (repo_id, name) VALUES (?, ?) ON CONFLICT (repo_id, name) DO NOTHING",
267			repoID, name); err != nil {
268			return err
269		}
270		if _, err := tx.Exec(`
271			INSERT INTO issue_labels (issue_id, label_id)
272			SELECT ?, id FROM labels WHERE repo_id = ? AND name = ?
273			ON CONFLICT DO NOTHING`, issueID, repoID, name); err != nil {
274			return err
275		}
276	} else {
277		res, err := tx.Exec(`
278			DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
279			(SELECT id FROM labels WHERE repo_id = ? AND name = ?)`, issueID, repoID, name)
280		if err != nil {
281			return err
282		}
283		if n, _ := res.RowsAffected(); n == 0 {
284			return fmt.Errorf("label %q: %w", name, ErrNotFound)
285		}
286	}
287	return tx.Commit()
288}
289
290// SetIssueAssignee adds or removes an assignee by user id.
291func (s *Store) SetIssueAssignee(issueID, userID int64, add bool) error {
292	if add {
293		_, err := s.DB.Exec(
294			"INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
295			issueID, userID)
296		return err
297	}
298	res, err := s.DB.Exec(
299		"DELETE FROM issue_assignees WHERE issue_id = ? AND user_id = ?", issueID, userID)
300	if err != nil {
301		return err
302	}
303	if n, _ := res.RowsAffected(); n == 0 {
304		return ErrNotFound
305	}
306	return nil
307}
308
309// RecordEvent appends to the event log and enqueues a delivery for every
310// active webhook on the repo whose event filter matches.
311func (s *Store) RecordEvent(repoID, actorID int64, kind, dataJSON string) error {
312	if dataJSON == "" {
313		dataJSON = "{}"
314	}
315	tx, err := s.DB.Begin()
316	if err != nil {
317		return err
318	}
319	defer tx.Rollback()
320	res, err := tx.Exec(
321		"INSERT INTO events (repo_id, actor_id, kind, data_json) VALUES (?, ?, ?, ?)",
322		repoID, actorID, kind, dataJSON)
323	if err != nil {
324		return err
325	}
326	eventID, err := res.LastInsertId()
327	if err != nil {
328		return err
329	}
330	if _, err := tx.Exec(`
331		INSERT INTO webhook_deliveries (webhook_id, event_id)
332		SELECT id, ? FROM webhooks
333		WHERE repo_id = ? AND active = 1
334		  AND (events = '*' OR ',' || events || ',' LIKE '%,' || ? || ',%')`,
335		eventID, repoID, kind); err != nil {
336		return err
337	}
338	return tx.Commit()
339}