internal/store/issues.go

35cb93f6f17f984193c93df7ff9278f5bb662ffa
gitbay/internal/store/issues.go history · blame · raw

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