internal/store/issues.go

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

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