patches.go
⎇
Raw
1package db
2
3import (
4 "context"
5 "database/sql"
6 "errors"
7)
8
9type Patch struct {
10 ID int64
11 RepoID int64
12 AuthorID *int64
13 Number int64
14 Title string
15 Description string
16 PatchContent string
17 Status string
18 AuthorName string
19 AuthorEmail string
20 CreatedAt string
21 UpdatedAt string
22 EditedAt *string
23 Version string
24 // Joined from users, null when the author was deleted.
25 AuthorUsername *string
26 AuthorAvatarVersion *int64
27}
28
29// PatchRef is the small lookup result the mutating routes work with.
30type PatchRef struct {
31 ID int64
32 AuthorID *int64
33 Status string
34 Version string
35 PatchContent string
36}
37
38type PatchComment struct {
39 ID int64
40 PatchID int64
41 AuthorID *int64
42 Body string
43 CreatedAt string
44 EditedAt *string
45 AuthorUsername *string
46 AuthorAvatarVersion *int64
47}
48
49const patchColumns = `patches.id, patches.repo_id, patches.author_id, patches.number, patches.title,
50 patches.description, patches.patch_content, patches.status, patches.author_name,
51 patches.author_email, patches.created_at, patches.updated_at, patches.edited_at,
52 patches.version, users.username, users.avatar_version`
53
54func scanPatch(s rowScanner) (*Patch, error) {
55 var p Patch
56 err := s.Scan(&p.ID, &p.RepoID, &p.AuthorID, &p.Number, &p.Title, &p.Description,
57 &p.PatchContent, &p.Status, &p.AuthorName, &p.AuthorEmail, &p.CreatedAt,
58 &p.UpdatedAt, &p.EditedAt, &p.Version, &p.AuthorUsername, &p.AuthorAvatarVersion)
59 if errors.Is(err, sql.ErrNoRows) {
60 return nil, nil
61 }
62 if err != nil {
63 return nil, err
64 }
65 return &p, nil
66}
67
68func (d *DB) PatchCounts(ctx context.Context, repoID int64, labelIDs []int64) (map[string]int, error) {
69 args := []any{repoID}
70 filter, args := labelFilter("patch_labels", "patch_id", "patches", labelIDs, args)
71 rows, err := d.QueryContext(ctx,
72 `SELECT patches.status, COUNT(*) FROM patches WHERE patches.repo_id = ?`+filter+
73 ` GROUP BY patches.status`, args...)
74 if err != nil {
75 return nil, err
76 }
77 defer rows.Close()
78 counts := map[string]int{}
79 for rows.Next() {
80 var status string
81 var n int
82 if err := rows.Scan(&status, &n); err != nil {
83 return nil, err
84 }
85 counts[status] = n
86 }
87 return counts, rows.Err()
88}
89
90func (d *DB) ListPatches(ctx context.Context, repoID int64, status string, labelIDs []int64,
91 limit, offset int,
92) ([]Patch, error) {
93 args := []any{repoID, status}
94 filter, args := labelFilter("patch_labels", "patch_id", "patches", labelIDs, args)
95 args = append(args, limit, offset)
96 rows, err := d.QueryContext(ctx,
97 `SELECT `+patchColumns+` FROM patches
98 LEFT JOIN users ON users.id = patches.author_id
99 WHERE patches.repo_id = ? AND patches.status = ?`+filter+
100 ` ORDER BY patches.number DESC LIMIT ? OFFSET ?`, args...)
101 if err != nil {
102 return nil, err
103 }
104 defer rows.Close()
105 var out []Patch
106 for rows.Next() {
107 p, err := scanPatch(rows)
108 if err != nil {
109 return nil, err
110 }
111 out = append(out, *p)
112 }
113 return out, rows.Err()
114}
115
116func (d *DB) PatchByNumber(ctx context.Context, repoID, number int64) (*Patch, error) {
117 return scanPatch(d.QueryRowContext(ctx,
118 `SELECT `+patchColumns+` FROM patches
119 LEFT JOIN users ON users.id = patches.author_id
120 WHERE patches.repo_id = ? AND patches.number = ?`, repoID, number))
121}
122
123func (d *DB) PatchRefByNumber(ctx context.Context, repoID, number int64) (*PatchRef, error) {
124 var r PatchRef
125 err := d.QueryRowContext(ctx,
126 `SELECT id, author_id, status, version, patch_content
127 FROM patches WHERE repo_id = ? AND number = ?`,
128 repoID, number).Scan(&r.ID, &r.AuthorID, &r.Status, &r.Version, &r.PatchContent)
129 if errors.Is(err, sql.ErrNoRows) {
130 return nil, nil
131 }
132 if err != nil {
133 return nil, err
134 }
135 return &r, nil
136}
137
138// CreatePatch allocates the next patch number and inserts the patch with its
139// labels in one transaction. It returns the patch number and row id.
140func (d *DB) CreatePatch(ctx context.Context, repoID int64, authorID *int64,
141 title, description, patchContent, authorName, authorEmail, version, now string,
142 labelIDs []int64,
143) (number, id int64, err error) {
144 tx, err := d.BeginTx(ctx, nil)
145 if err != nil {
146 return 0, 0, err
147 }
148 defer tx.Rollback()
149
150 if err = tx.QueryRowContext(ctx,
151 `UPDATE repositories SET patch_seq = patch_seq + 1 WHERE id = ? RETURNING patch_seq`,
152 repoID).Scan(&number); err != nil {
153 return 0, 0, err
154 }
155 res, err := tx.ExecContext(ctx,
156 `INSERT INTO patches (repo_id, author_id, number, title, description, patch_content,
157 status, author_name, author_email, created_at, updated_at, version)
158 VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?)`,
159 repoID, authorID, number, title, description, patchContent,
160 authorName, authorEmail, now, now, version)
161 if err != nil {
162 return 0, 0, err
163 }
164 if id, err = res.LastInsertId(); err != nil {
165 return 0, 0, err
166 }
167 if err = attachLabels(ctx, tx, "patch_labels", "patch_id", id, repoID, labelIDs); err != nil {
168 return 0, 0, err
169 }
170 return number, id, tx.Commit()
171}
172
173// ClaimPatchMerge marks an open patch merged, but only while its content is
174// still at the version the admin reviewed. It reports whether the claim won.
175func (d *DB) ClaimPatchMerge(ctx context.Context, id int64, version, now string) (bool, error) {
176 res, err := d.ExecContext(ctx,
177 `UPDATE patches SET status = 'merged', updated_at = ?
178 WHERE id = ? AND status = 'open' AND version = ?`, now, id, version)
179 if err != nil {
180 return false, err
181 }
182 n, err := res.RowsAffected()
183 return n > 0, err
184}
185
186// ReopenPatch rolls a failed merge back to open.
187func (d *DB) ReopenPatch(ctx context.Context, id int64, now string) error {
188 _, err := d.ExecContext(ctx,
189 `UPDATE patches SET status = 'open', updated_at = ? WHERE id = ?`, now, id)
190 return err
191}
192
193// TogglePatchClosed flips open and closed. Merged patches are excluded, so a
194// false result means the patch is merged or gone.
195func (d *DB) TogglePatchClosed(ctx context.Context, id int64, now string) (bool, error) {
196 res, err := d.ExecContext(ctx,
197 `UPDATE patches SET status = CASE WHEN status = 'open' THEN 'closed' ELSE 'open' END,
198 updated_at = ? WHERE id = ? AND status != 'merged'`, now, id)
199 if err != nil {
200 return false, err
201 }
202 n, err := res.RowsAffected()
203 return n > 0, err
204}
205
206// ReplacePatchContent stores a re-uploaded patch file under a new version.
207// It reports false when the patch is no longer open, so a merge landing
208// between the caller's check and this write cannot rewrite merged content.
209func (d *DB) ReplacePatchContent(ctx context.Context, id int64,
210 patchContent, authorName, authorEmail, version, now string,
211) (bool, error) {
212 res, err := d.ExecContext(ctx,
213 `UPDATE patches SET patch_content = ?, author_name = ?, author_email = ?,
214 version = ?, updated_at = ? WHERE id = ? AND status = 'open'`,
215 patchContent, authorName, authorEmail, version, now, id)
216 if err != nil {
217 return false, err
218 }
219 n, err := res.RowsAffected()
220 return n > 0, err
221}
222
223func (d *DB) UpdatePatch(ctx context.Context, id int64, title, description, now string) error {
224 _, err := d.ExecContext(ctx,
225 `UPDATE patches SET title = ?, description = ?, edited_at = ?, updated_at = ? WHERE id = ?`,
226 title, description, now, now, id)
227 return err
228}
229
230func (d *DB) DeletePatch(ctx context.Context, id int64) error {
231 _, err := d.ExecContext(ctx, `DELETE FROM patches WHERE id = ?`, id)
232 return err
233}
234
235func (d *DB) ListPatchComments(ctx context.Context, patchID int64) ([]PatchComment, error) {
236 rows, err := d.QueryContext(ctx,
237 `SELECT patch_comments.id, patch_comments.patch_id, patch_comments.author_id,
238 patch_comments.body, patch_comments.created_at, patch_comments.edited_at,
239 users.username, users.avatar_version
240 FROM patch_comments
241 LEFT JOIN users ON users.id = patch_comments.author_id
242 WHERE patch_comments.patch_id = ?
243 ORDER BY patch_comments.created_at ASC`, patchID)
244 if err != nil {
245 return nil, err
246 }
247 defer rows.Close()
248 var out []PatchComment
249 for rows.Next() {
250 var c PatchComment
251 if err := rows.Scan(&c.ID, &c.PatchID, &c.AuthorID, &c.Body, &c.CreatedAt,
252 &c.EditedAt, &c.AuthorUsername, &c.AuthorAvatarVersion); err != nil {
253 return nil, err
254 }
255 out = append(out, c)
256 }
257 return out, rows.Err()
258}
259
260// AddPatchComment inserts a comment and bumps the patch's updated_at together.
261func (d *DB) AddPatchComment(ctx context.Context, patchID int64, authorID *int64, body, now string) error {
262 tx, err := d.BeginTx(ctx, nil)
263 if err != nil {
264 return err
265 }
266 defer tx.Rollback()
267 if _, err := tx.ExecContext(ctx,
268 `INSERT INTO patch_comments (patch_id, author_id, body, created_at) VALUES (?, ?, ?, ?)`,
269 patchID, authorID, body, now); err != nil {
270 return err
271 }
272 if _, err := tx.ExecContext(ctx,
273 `UPDATE patches SET updated_at = ? WHERE id = ?`, now, patchID); err != nil {
274 return err
275 }
276 return tx.Commit()
277}
278
279func (d *DB) UpdatePatchComment(ctx context.Context, id int64, body, now string) error {
280 _, err := d.ExecContext(ctx,
281 `UPDATE patch_comments SET body = ?, edited_at = ? WHERE id = ?`, body, now, id)
282 return err
283}
284
285// PatchCommentAuth loads the comment author and parent status in one query,
286// scoped to the repo so a comment from another repo cannot be edited.
287func (d *DB) PatchCommentAuth(ctx context.Context, commentID, repoID int64) (*CommentAuth, error) {
288 var a CommentAuth
289 err := d.QueryRowContext(ctx,
290 `SELECT patch_comments.author_id, patches.status
291 FROM patch_comments
292 JOIN patches ON patches.id = patch_comments.patch_id
293 WHERE patch_comments.id = ? AND patches.repo_id = ?`,
294 commentID, repoID).Scan(&a.AuthorID, &a.Status)
295 if errors.Is(err, sql.ErrNoRows) {
296 return nil, nil
297 }
298 if err != nil {
299 return nil, err
300 }
301 return &a, nil
302}
303
304func (d *DB) ListPatchReactions(ctx context.Context, patchID int64) ([]Reaction, error) {
305 rows, err := d.QueryContext(ctx,
306 `SELECT id, comment_id, user_id, emoji FROM patch_reactions WHERE patch_id = ?`, patchID)
307 if err != nil {
308 return nil, err
309 }
310 defer rows.Close()
311 return scanReactions(rows)
312}
313
314// TogglePatchReaction keeps one reaction per user per target: the same emoji
315// removes it, a different emoji replaces it.
316func (d *DB) TogglePatchReaction(ctx context.Context, patchID int64, commentID *int64,
317 userID int64, emoji string,
318) error {
319 return d.toggleReaction(ctx, "patch_reactions", "patch_comments", "patch_id", patchID, commentID, userID, emoji)
320}
321
322func (d *DB) PatchLabels(ctx context.Context, patchID int64) ([]Label, error) {
323 rows, err := d.QueryContext(ctx,
324 `SELECT labels.id, labels.repo_id, labels.name, labels.color, labels.created_at
325 FROM patch_labels
326 JOIN labels ON labels.id = patch_labels.label_id
327 WHERE patch_labels.patch_id = ?`, patchID)
328 if err != nil {
329 return nil, err
330 }
331 defer rows.Close()
332 return scanLabels(rows)
333}
334
335// PatchLabelsByPatch batch-loads labels for a list view.
336func (d *DB) PatchLabelsByPatch(ctx context.Context, patchIDs []int64) (map[int64][]Label, error) {
337 return d.labelsByParent(ctx, "patch_labels", "patch_id", patchIDs)
338}
339
340func (d *DB) AddPatchLabel(ctx context.Context, patchID, labelID int64) error {
341 _, err := d.ExecContext(ctx,
342 `INSERT INTO patch_labels (patch_id, label_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
343 patchID, labelID)
344 return err
345}
346
347func (d *DB) RemovePatchLabel(ctx context.Context, patchID, labelID int64) error {
348 _, err := d.ExecContext(ctx,
349 `DELETE FROM patch_labels WHERE patch_id = ? AND label_id = ?`, patchID, labelID)
350 return err
351}
352