internal/store/issues.go

39535a8e16d8dfc0189bce59511cfb0d7d019fb5
gitbay/internal/store/issues.go history · blame · raw

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