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