internal/store/issues.go
405 lines · 12062 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. Used by the web issue listing; ListIssues
275// itself stays label-free for the CLI's lean list output.
276func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) {
277 rows, err := s.DB.Query(`
278 SELECT il.issue_id, l.name FROM issue_labels il
279 JOIN labels l ON l.id = il.label_id
280 WHERE l.repo_id = ? ORDER BY l.name`, repoID)
281 if err != nil {
282 return nil, err
283 }
284 defer rows.Close()
285 out := map[int64][]string{}
286 for rows.Next() {
287 var id int64
288 var name string
289 if err := rows.Scan(&id, &name); err != nil {
290 return nil, err
291 }
292 out[id] = append(out[id], name)
293 }
294 return out, rows.Err()
295}
296
297// LabelColors returns the colours of the labels a repository sees, keyed
298// by name. Labels with no stored colour map to "".
299func (s *Store) LabelColors(repo Repo) (map[string]string, error) {
300 where, args := scopeClause("l", repo)
301 rows, err := s.DB.Query("SELECT l.name, l.color FROM labels l WHERE "+where, args...)
302 if err != nil {
303 return nil, err
304 }
305 defer rows.Close()
306 out := map[string]string{}
307 for rows.Next() {
308 var name, color string
309 if err := rows.Scan(&name, &color); err != nil {
310 return nil, err
311 }
312 out[name] = color
313 }
314 return out, rows.Err()
315}
316
317// SetIssueLabel attaches (add) or detaches a label by name. Adding
318// resolves the org's row when the org has the name, else the repository's,
319// creating that on first use.
320func (s *Store) SetIssueLabel(repo Repo, issueID int64, name string, add bool) error {
321 tx, err := s.DB.Begin()
322 if err != nil {
323 return err
324 }
325 defer tx.Rollback()
326 where, args := scopeClause("l", repo)
327 if add {
328 if held, err := orgHoldsLabel(tx, repo, name); err != nil {
329 return err
330 } else if !held {
331 if _, err := tx.Exec(`INSERT INTO labels (repo_id, name) VALUES (?, ?)
332 ON CONFLICT (repo_id, name) WHERE repo_id IS NOT NULL DO NOTHING`, repo.ID, name); err != nil {
333 return err
334 }
335 }
336 if _, err := tx.Exec(`INSERT INTO issue_labels (issue_id, label_id)
337 SELECT ?, l.id FROM labels l WHERE `+where+` AND l.name = ?
338 ORDER BY l.org_id IS NULL LIMIT 1
339 ON CONFLICT DO NOTHING`, append(append([]any{issueID}, args...), name)...); err != nil {
340 return err
341 }
342 } else {
343 res, err := tx.Exec(`DELETE FROM issue_labels WHERE issue_id = ? AND label_id IN
344 (SELECT l.id FROM labels l WHERE `+where+` AND l.name = ?)`,
345 append(append([]any{issueID}, args...), name)...)
346 if err != nil {
347 return err
348 }
349 if n, _ := res.RowsAffected(); n == 0 {
350 return fmt.Errorf("label %q: %w", name, ErrNotFound)
351 }
352 }
353 return tx.Commit()
354}
355
356// SetIssueAssignee adds or removes an assignee by user id.
357func (s *Store) SetIssueAssignee(issueID, userID int64, add bool) error {
358 if add {
359 _, err := s.DB.Exec(
360 "INSERT INTO issue_assignees (issue_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
361 issueID, userID)
362 return err
363 }
364 res, err := s.DB.Exec(
365 "DELETE FROM issue_assignees WHERE issue_id = ? AND user_id = ?", issueID, userID)
366 if err != nil {
367 return err
368 }
369 if n, _ := res.RowsAffected(); n == 0 {
370 return ErrNotFound
371 }
372 return nil
373}
374
375// RecordEvent appends to the event log and enqueues a delivery for every
376// active webhook on the repo whose event filter matches.
377func (s *Store) RecordEvent(repoID, actorID int64, kind, dataJSON string) error {
378 if dataJSON == "" {
379 dataJSON = "{}"
380 }
381 tx, err := s.DB.Begin()
382 if err != nil {
383 return err
384 }
385 defer tx.Rollback()
386 res, err := tx.Exec(
387 "INSERT INTO events (repo_id, actor_id, kind, data_json) VALUES (?, ?, ?, ?)",
388 repoID, actorID, kind, dataJSON)
389 if err != nil {
390 return err
391 }
392 eventID, err := res.LastInsertId()
393 if err != nil {
394 return err
395 }
396 if _, err := tx.Exec(`
397 INSERT INTO webhook_deliveries (webhook_id, event_id)
398 SELECT id, ? FROM webhooks
399 WHERE repo_id = ? AND active = 1
400 AND (events = '*' OR ',' || events || ',' LIKE '%,' || ? || ',%')`,
401 eventID, repoID, kind); err != nil {
402 return err
403 }
404 return tx.Commit()
405}