internal/store/issues.go

c2d81105344db93058ba50f63e5e81c49abd4b7f
gitbay/internal/store/issues.go history · blame · raw

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