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