patches.go
| 1 | package db |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | ) |
| 8 | |
| 9 | type 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. |
| 30 | type PatchRef struct { |
| 31 | ID int64 |
| 32 | AuthorID *int64 |
| 33 | Status string |
| 34 | Version string |
| 35 | PatchContent string |
| 36 | } |
| 37 | |
| 38 | type 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 | |
| 49 | const 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 | |
| 54 | func 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 | |
| 68 | func (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 | |
| 90 | func (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 | |
| 116 | func (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 | |
| 123 | func (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. |
| 140 | func (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. |
| 175 | func (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. |
| 187 | func (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. |
| 195 | func (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. |
| 209 | func (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 | |
| 223 | func (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 | |
| 230 | func (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 | |
| 235 | func (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. |
| 261 | func (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 | |
| 279 | func (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. |
| 287 | func (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 | |
| 304 | func (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. |
| 316 | func (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 | |
| 322 | func (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. |
| 336 | func (d *DB) PatchLabelsByPatch(ctx context.Context, patchIDs []int64) (map[int64][]Label, error) { |
| 337 | return d.labelsByParent(ctx, "patch_labels", "patch_id", patchIDs) |
| 338 | } |
| 339 | |
| 340 | func (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 | |
| 347 | func (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 |