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