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