internal/store/mrs.go
501 lines · 16245 bytes
1package store
2
3import (
4 "database/sql"
5 "errors"
6 "strings"
7)
8
9type MR struct {
10 ID int64
11 RepoID int64
12 Number int64
13 Author string
14 SourceRepoID int64 // 0 when the source repo is gone
15 SourcePath string // owner/name of source repo, "" when gone
16 SourceRef string
17 TargetRef string
18 Title string
19 Body string
20 BodyFormat string // md | org
21 State string // open | merged | closed | source_gone
22 // Draft marks an open merge request that is not asking to be merged
23 // yet. Not a state: see migration 0037.
24 Draft bool
25 Milestone string
26 HeadSHA string
27 MergedBase string // target tip at merge time; base for historical diffs
28 MergedAt string // "" unless merged
29 MergedBy string // "" when unknown (imports) or the account is gone
30 ClosedAt string // "" unless closed without merging
31 ClosedBy string
32 CreatedAt string
33 UpdatedAt string
34 // ReviewRequests is who has been asked, directly, for a review — the
35 // mr review request counterpart of Issue.Assignees.
36 ReviewRequests []string
37}
38
39type MRReview struct {
40 Reviewer string
41 Verdict string
42 HeadSHA string
43 Stale bool
44 CreatedAt string
45}
46
47func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA, format string, draft bool) (int64, error) {
48 tx, err := s.DB.Begin()
49 if err != nil {
50 return 0, err
51 }
52 defer tx.Rollback()
53 if _, err := tx.Exec("UPDATE repos SET mr_counter = mr_counter + 1 WHERE id = ?", repoID); err != nil {
54 return 0, err
55 }
56 var n int64
57 if err := tx.QueryRow("SELECT mr_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
58 return 0, err
59 }
60 if _, err := tx.Exec(`
61 INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha, body_format, draft)
62 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
63 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format, draft); err != nil {
64 return 0, err
65 }
66 if headSHA != "" {
67 var mrID int64
68 if err := tx.QueryRow("SELECT id FROM merge_requests WHERE repo_id = ? AND number = ?",
69 repoID, n).Scan(&mrID); err != nil {
70 return 0, err
71 }
72 if _, err := tx.Exec("INSERT INTO mr_heads (mr_id, sha) VALUES (?, ?)", mrID, headSHA); err != nil {
73 return 0, err
74 }
75 }
76 return n, tx.Commit()
77}
78
79// SetMRDraft marks an open merge request as a draft, or takes the mark
80// off. Merging is refused while it is set.
81func (s *Store) SetMRDraft(mrID int64, draft bool) error {
82 _, err := s.DB.Exec(
83 "UPDATE merge_requests SET draft = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
84 draft, mrID)
85 return err
86}
87
88const mrSelect = `
89 SELECT m.id, m.repo_id, m.number, u.username,
90 COALESCE(m.source_repo_id, 0),
91 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
92 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
93 COALESCE(ms.title, ''), m.head_sha,
94 m.merged_base, m.merged_at, COALESCE(mu.username, ''),
95 m.closed_at, COALESCE(cu.username, ''), m.created_at, m.updated_at
96 FROM merge_requests m
97 JOIN users u ON u.id = m.author_id
98 LEFT JOIN users mu ON mu.id = m.merged_by
99 LEFT JOIN users cu ON cu.id = m.closed_by
100 LEFT JOIN repos sr ON sr.id = m.source_repo_id
101 LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id
102 LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id
103 LEFT JOIN milestones ms ON ms.id = m.milestone_id`
104
105func scanMR(row interface{ Scan(...any) error }) (MR, error) {
106 var m MR
107 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
108 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase,
109 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.CreatedAt, &m.UpdatedAt)
110 return m, err
111}
112
113func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
114 m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.repo_id = ? AND m.number = ?", repoID, number))
115 if errors.Is(err, sql.ErrNoRows) {
116 return m, ErrNotFound
117 }
118 if err != nil {
119 return m, err
120 }
121 m.ReviewRequests, err = s.issueStrings(m.ID, `
122 SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id
123 WHERE rr.mr_id = ? ORDER BY u.username`)
124 return m, err
125}
126
127// SetMRReviewRequest adds or removes a review request by user id — the
128// mr review request counterpart of SetIssueAssignee.
129func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error {
130 if add {
131 _, err := s.DB.Exec(
132 "INSERT INTO mr_review_requests (mr_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
133 mrID, userID)
134 return err
135 }
136 res, err := s.DB.Exec(
137 "DELETE FROM mr_review_requests WHERE mr_id = ? AND user_id = ?", mrID, userID)
138 if err != nil {
139 return err
140 }
141 if n, _ := res.RowsAffected(); n == 0 {
142 return ErrNotFound
143 }
144 return nil
145}
146
147// MRReviewRequestIDs returns who has been asked for a review, by id — for
148// notifying them without a username round trip.
149func (s *Store) MRReviewRequestIDs(mrID int64) ([]int64, error) {
150 return s.idQuery("SELECT user_id FROM mr_review_requests WHERE mr_id = ?", mrID)
151}
152
153// ListMRs returns merge requests for a repo. limit 0 means everything;
154// before (an MR number) starts the page strictly below it, matching the
155// number-descending order.
156// MRFilter narrows a listing. Empty strings match anything; State "all"
157// too. Milestone "none" selects merge requests with no milestone.
158type MRFilter struct {
159 State string
160 Author string
161 Milestone string
162 Search string // full-text over title and body
163 Limit int
164 Before int64
165}
166
167func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) {
168 return s.QueryMRs(repoID, MRFilter{State: state, Limit: limit, Before: before})
169}
170
171// QueryMRs lists a repository's merge requests, newest first, narrowed
172// by f.
173func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) {
174 q := mrSelect + " WHERE m.repo_id = ?"
175 args := []any{repoID}
176 if f.State != "" && f.State != "all" {
177 q += " AND m.state = ?"
178 args = append(args, f.State)
179 }
180 if f.Author != "" {
181 q += " AND u.username = ?"
182 args = append(args, f.Author)
183 }
184 switch f.Milestone {
185 case "":
186 case "none":
187 q += " AND m.milestone_id IS NULL"
188 default:
189 q += " AND ms.title = ?"
190 args = append(args, f.Milestone)
191 }
192 if f.Search != "" {
193 q += " AND m.id IN (SELECT rowid FROM mr_fts WHERE mr_fts MATCH ?)"
194 args = append(args, FTSQuery(f.Search))
195 }
196 if f.Before > 0 {
197 q += " AND m.number < ?"
198 args = append(args, f.Before)
199 }
200 q += " ORDER BY m.number DESC"
201 if f.Limit > 0 {
202 q += " LIMIT ?"
203 args = append(args, f.Limit)
204 }
205 rows, err := s.DB.Query(q, args...)
206 if err != nil {
207 return nil, err
208 }
209 defer rows.Close()
210 var out []MR
211 for rows.Next() {
212 m, err := scanMR(rows)
213 if err != nil {
214 return nil, err
215 }
216 out = append(out, m)
217 }
218 return out, rows.Err()
219}
220
221// OpenMRsBySource returns open (and source_gone) MRs fed by the given source
222// repo branch — the cross-repo hook effect consults this.
223func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, error) {
224 rows, err := s.DB.Query(
225 mrSelect+" WHERE m.source_repo_id = ? AND m.source_ref = ? AND m.state IN ('open','source_gone')",
226 sourceRepoID, sourceRef)
227 if err != nil {
228 return nil, err
229 }
230 defer rows.Close()
231 var out []MR
232 for rows.Next() {
233 m, err := scanMR(rows)
234 if err != nil {
235 return nil, err
236 }
237 out = append(out, m)
238 }
239 return out, rows.Err()
240}
241
242// MarkMerged records the merge along with the target tip it landed on, so
243// the MR's diff stays reconstructable after fast-forwards. actorID 0 and an
244// empty at leave the merger unknown and stamp the current time, which is
245// what an import that carries neither can say.
246func (s *Store) MarkMerged(mrID int64, baseSHA string, actorID int64, at string) error {
247 _, err := s.DB.Exec(
248 `UPDATE merge_requests SET state = 'merged', merged_base = ?,
249 merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
250 merged_by = NULLIF(?, 0),
251 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
252 baseSHA, at, actorID, mrID)
253 return err
254}
255
256// MarkClosed is MarkMerged's counterpart for a merge request closed without
257// merging.
258func (s *Store) MarkClosed(mrID, actorID int64, at string) error {
259 _, err := s.DB.Exec(
260 `UPDATE merge_requests SET state = 'closed',
261 closed_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
262 closed_by = NULLIF(?, 0),
263 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
264 at, actorID, mrID)
265 return err
266}
267
268// SetMRState moves an MR between states that carry no resolution stamp.
269// Returning to open (a source branch that came back) clears one.
270func (s *Store) SetMRState(mrID int64, state string) error {
271 stamp := ""
272 if state == "open" || state == "source_gone" {
273 stamp = ", merged_at = '', merged_by = NULL, closed_at = '', closed_by = NULL"
274 }
275 res, err := s.DB.Exec(
276 "UPDATE merge_requests SET state = ?"+stamp+", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
277 state, mrID)
278 if err != nil {
279 return err
280 }
281 if n, _ := res.RowsAffected(); n == 0 {
282 return ErrNotFound
283 }
284 return nil
285}
286
287// UpdateMRHead records a new head and marks every review at another head
288// stale, in one transaction.
289// UpdateMRHead moves a merge request onto a new head, stales the reviews
290// of the old one, and records the head in the history a range-diff reads.
291// baseSHA is the merge base at this moment; "" when the caller could not
292// work it out, which only costs the range-diff its precision.
293func (s *Store) UpdateMRHead(mrID int64, headSHA, baseSHA string) error {
294 tx, err := s.DB.Begin()
295 if err != nil {
296 return err
297 }
298 defer tx.Rollback()
299 if _, err := tx.Exec(
300 "UPDATE merge_requests SET head_sha = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
301 headSHA, mrID); err != nil {
302 return err
303 }
304 if _, err := tx.Exec(
305 "UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil {
306 return err
307 }
308 // Same head twice is a push that changed nothing about this merge
309 // request; it should not add a revision to compare against.
310 var last string
311 tx.QueryRow("SELECT sha FROM mr_heads WHERE mr_id = ? ORDER BY id DESC LIMIT 1", mrID).Scan(&last)
312 if last != headSHA {
313 if _, err := tx.Exec(
314 "INSERT INTO mr_heads (mr_id, sha, base_sha) VALUES (?, ?, ?)", mrID, headSHA, baseSHA); err != nil {
315 return err
316 }
317 }
318 return tx.Commit()
319}
320
321// MRHead is one revision a merge request has had.
322type MRHead struct {
323 SHA string
324 BaseSHA string
325 CreatedAt string
326}
327
328// MRHeads returns a merge request's revisions, oldest first.
329func (s *Store) MRHeads(mrID int64) ([]MRHead, error) {
330 rows, err := s.DB.Query(
331 "SELECT sha, base_sha, created_at FROM mr_heads WHERE mr_id = ? ORDER BY id", mrID)
332 if err != nil {
333 return nil, err
334 }
335 defer rows.Close()
336 var out []MRHead
337 for rows.Next() {
338 var h MRHead
339 if err := rows.Scan(&h.SHA, &h.BaseSHA, &h.CreatedAt); err != nil {
340 return nil, err
341 }
342 out = append(out, h)
343 }
344 return out, rows.Err()
345}
346
347// SetMRTarget retargets a merge request and marks every existing review
348// stale, in one transaction. The base of the diff is derived from the
349// target on every read, so nothing else has to move; an approval,
350// though, was of the diff against the old branch.
351func (s *Store) SetMRTarget(mrID int64, targetRef string) error {
352 tx, err := s.DB.Begin()
353 if err != nil {
354 return err
355 }
356 defer tx.Rollback()
357 res, err := tx.Exec(
358 "UPDATE merge_requests SET target_ref = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
359 targetRef, mrID)
360 if err != nil {
361 return err
362 }
363 if n, _ := res.RowsAffected(); n == 0 {
364 return ErrNotFound
365 }
366 if _, err := tx.Exec("UPDATE mr_reviews SET stale = 1 WHERE mr_id = ?", mrID); err != nil {
367 return err
368 }
369 return tx.Commit()
370}
371
372// MarkSourceGoneForRepo flags every open MR sourced from the repo; called
373// when a fork is deleted. Head refs in the target repos are retained.
374func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
375 _, err := s.DB.Exec(
376 "UPDATE merge_requests SET state = 'source_gone', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE source_repo_id = ? AND state = 'open'",
377 sourceRepoID)
378 return err
379}
380
381func (s *Store) AddMRComment(mrID, authorID int64, body, format string) error {
382 _, err := s.DB.Exec(
383 "INSERT INTO mr_comments (mr_id, author_id, body, body_format) VALUES (?, ?, ?, ?)",
384 mrID, authorID, body, format)
385 return err
386}
387
388// UpdateMRText edits title, body, and/or markup format; nil leaves a field
389// unchanged.
390func (s *Store) UpdateMRText(mrID int64, title, body, format *string) error {
391 set, args := []string{}, []any{}
392 if title != nil {
393 set, args = append(set, "title = ?"), append(args, *title)
394 }
395 if body != nil {
396 set, args = append(set, "body = ?"), append(args, *body)
397 }
398 if format != nil {
399 set, args = append(set, "body_format = ?"), append(args, *format)
400 }
401 if len(set) == 0 {
402 return nil
403 }
404 set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
405 args = append(args, mrID)
406 res, err := s.DB.Exec("UPDATE merge_requests SET "+strings.Join(set, ", ")+" WHERE id = ?", args...)
407 if err != nil {
408 return err
409 }
410 if n, _ := res.RowsAffected(); n == 0 {
411 return ErrNotFound
412 }
413 return nil
414}
415
416// AddMRSystemComment is the informational counterpart of AddMRComment.
417func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
418 _, err := s.DB.Exec(
419 "INSERT INTO mr_comments (mr_id, author_id, body, kind) VALUES (?, ?, ?, 'system')",
420 mrID, actorID, body)
421 return err
422}
423
424func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
425 rows, err := s.DB.Query(`
426 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
427 c.body, c.body_format, c.created_at, c.kind
428 FROM mr_comments c JOIN users u ON u.id = c.author_id
429 WHERE c.mr_id = ? ORDER BY c.id`, mrID)
430 if err != nil {
431 return nil, err
432 }
433 defer rows.Close()
434 var out []IssueComment
435 for rows.Next() {
436 var c IssueComment
437 if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil {
438 return nil, err
439 }
440 out = append(out, c)
441 }
442 return out, rows.Err()
443}
444
445func (s *Store) AddMRReview(mrID, reviewerID int64, verdict, headSHA string) error {
446 _, err := s.DB.Exec(
447 "INSERT INTO mr_reviews (mr_id, reviewer_id, verdict, head_sha) VALUES (?, ?, ?, ?)",
448 mrID, reviewerID, verdict, headSHA)
449 return err
450}
451
452func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) {
453 rows, err := s.DB.Query(`
454 SELECT u.username, r.verdict, r.head_sha, r.stale, r.created_at
455 FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id
456 WHERE r.mr_id = ? ORDER BY r.id`, mrID)
457 if err != nil {
458 return nil, err
459 }
460 defer rows.Close()
461 var out []MRReview
462 for rows.Next() {
463 var r MRReview
464 var stale int
465 if err := rows.Scan(&r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil {
466 return nil, err
467 }
468 r.Stale = stale != 0
469 out = append(out, r)
470 }
471 return out, rows.Err()
472}
473
474// PrimaryVerifiedEmail returns the user's primary email if verified, else "".
475func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) {
476 var addr string
477 err := s.DB.QueryRow(
478 "SELECT address FROM emails WHERE user_id = ? AND is_primary = 1 AND verified_at IS NOT NULL",
479 userID).Scan(&addr)
480 if errors.Is(err, sql.ErrNoRows) {
481 return "", nil
482 }
483 return addr, err
484}
485
486// PreferredVerifiedEmail returns the primary address if it is verified,
487// otherwise the account's other verified address that sorts first by
488// address; "" if none is verified. Unlike PrimaryVerifiedEmail, a verified
489// secondary counts: an account that verified one address but not its
490// primary still has somewhere to send a login link.
491func (s *Store) PreferredVerifiedEmail(userID int64) (string, error) {
492 var addr string
493 err := s.DB.QueryRow(
494 `SELECT address FROM emails WHERE user_id = ? AND verified_at IS NOT NULL
495 ORDER BY is_primary DESC, address LIMIT 1`,
496 userID).Scan(&addr)
497 if errors.Is(err, sql.ErrNoRows) {
498 return "", nil
499 }
500 return addr, err
501}