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