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