internal/store/issues.go

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

399 lines · 11664 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 repo's label colors keyed by label name. Labels
298// with no stored color map to "".
299func (s *Store) LabelColors(repoID int64) (map[string]string, error) {
300	rows, err := s.DB.Query("SELECT name, color FROM labels WHERE repo_id = ?", repoID)
301	if err != nil {
302		return nil, err
303	}
304	defer rows.Close()
305	out := map[string]string{}
306	for rows.Next() {
307		var name, color string
308		if err := rows.Scan(&name, &color); err != nil {
309			return nil, err
310		}
311		out[name] = color
312	}
313	return out, rows.Err()
314}
315
316// SetIssueLabel attaches (add) or detaches a label, creating the repo label
317// on first use.
318func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error {
319	tx, err := s.DB.Begin()
320	if err != nil {
321		return err
322	}
323	defer tx.Rollback()
324	if add {
325		if _, err := tx.Exec(
326			"INSERT INTO labels (repo_id, name) VALUES (?, ?) ON CONFLICT (repo_id, name) DO NOTHING",
327			repoID, name); err != nil {
328			return err
329		}
330		if _, err := tx.Exec(`
331			INSERT INTO issue_labels (issue_id, label_id)
332			SELECT ?, id FROM labels WHERE repo_id = ? AND name = ?
333			ON CONFLICT DO NOTHING`, issueID, repoID, name); err != nil {
334			return err
335		}
336	} else {
337		res, err := tx.Exec(`
338			DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
339			(SELECT id FROM labels WHERE repo_id = ? AND name = ?)`, issueID, repoID, name)
340		if err != nil {
341			return err
342		}
343		if n, _ := res.RowsAffected(); n == 0 {
344			return fmt.Errorf("label %q: %w", name, ErrNotFound)
345		}
346	}
347	return tx.Commit()
348}
349
350// SetIssueAssignee adds or removes an assignee by user id.
351func (s *Store) SetIssueAssignee(issueID, userID int64, add bool) error {
352	if add {
353		_, err := s.DB.Exec(
354			"INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
355			issueID, userID)
356		return err
357	}
358	res, err := s.DB.Exec(
359		"DELETE FROM issue_assignees WHERE issue_id = ? AND user_id = ?", issueID, userID)
360	if err != nil {
361		return err
362	}
363	if n, _ := res.RowsAffected(); n == 0 {
364		return ErrNotFound
365	}
366	return nil
367}
368
369// RecordEvent appends to the event log and enqueues a delivery for every
370// active webhook on the repo whose event filter matches.
371func (s *Store) RecordEvent(repoID, actorID int64, kind, dataJSON string) error {
372	if dataJSON == "" {
373		dataJSON = "{}"
374	}
375	tx, err := s.DB.Begin()
376	if err != nil {
377		return err
378	}
379	defer tx.Rollback()
380	res, err := tx.Exec(
381		"INSERT INTO events (repo_id, actor_id, kind, data_json) VALUES (?, ?, ?, ?)",
382		repoID, actorID, kind, dataJSON)
383	if err != nil {
384		return err
385	}
386	eventID, err := res.LastInsertId()
387	if err != nil {
388		return err
389	}
390	if _, err := tx.Exec(`
391		INSERT INTO webhook_deliveries (webhook_id, event_id)
392		SELECT id, ? FROM webhooks
393		WHERE repo_id = ? AND active = 1
394		  AND (events = '*' OR ',' || events || ',' LIKE '%,' || ? || ',%')`,
395		eventID, repoID, kind); err != nil {
396		return err
397	}
398	return tx.Commit()
399}