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