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