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