internal/store/mrs.go
382 lines · 12034 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 Milestone string
23 HeadSHA string
24 MergedBase string // target tip at merge time; base for historical diffs
25 MergedAt string // "" unless merged
26 MergedBy string // "" when unknown (imports) or the account is gone
27 ClosedAt string // "" unless closed without merging
28 ClosedBy string
29 CreatedAt string
30 UpdatedAt string
31}
32
33type MRReview struct {
34 Reviewer string
35 Verdict string
36 HeadSHA string
37 Stale bool
38 CreatedAt string
39}
40
41func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA, format string) (int64, error) {
42 tx, err := s.DB.Begin()
43 if err != nil {
44 return 0, err
45 }
46 defer tx.Rollback()
47 if _, err := tx.Exec("UPDATE repos SET mr_counter = mr_counter + 1 WHERE id = ?", repoID); err != nil {
48 return 0, err
49 }
50 var n int64
51 if err := tx.QueryRow("SELECT mr_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
52 return 0, err
53 }
54 if _, err := tx.Exec(`
55 INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha, body_format)
56 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
57 repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format); err != nil {
58 return 0, err
59 }
60 return n, tx.Commit()
61}
62
63const mrSelect = `
64 SELECT m.id, m.repo_id, m.number, u.username,
65 COALESCE(m.source_repo_id, 0),
66 COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
67 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state,
68 COALESCE(ms.title, ''), m.head_sha,
69 m.merged_base, m.merged_at, COALESCE(mu.username, ''),
70 m.closed_at, COALESCE(cu.username, ''), m.created_at, m.updated_at
71 FROM merge_requests m
72 JOIN users u ON u.id = m.author_id
73 LEFT JOIN users mu ON mu.id = m.merged_by
74 LEFT JOIN users cu ON cu.id = m.closed_by
75 LEFT JOIN repos sr ON sr.id = m.source_repo_id
76 LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id
77 LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id
78 LEFT JOIN milestones ms ON ms.id = m.milestone_id`
79
80func scanMR(row interface{ Scan(...any) error }) (MR, error) {
81 var m MR
82 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
83 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Milestone, &m.HeadSHA, &m.MergedBase,
84 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.CreatedAt, &m.UpdatedAt)
85 return m, err
86}
87
88func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
89 m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.repo_id = ? AND m.number = ?", repoID, number))
90 if errors.Is(err, sql.ErrNoRows) {
91 return m, ErrNotFound
92 }
93 return m, err
94}
95
96// ListMRs returns merge requests for a repo. limit 0 means everything;
97// before (an MR number) starts the page strictly below it, matching the
98// number-descending order.
99// MRFilter narrows a listing. Empty strings match anything; State "all"
100// too. Milestone "none" selects merge requests with no milestone.
101type MRFilter struct {
102 State string
103 Author string
104 Milestone string
105 Limit int
106 Before int64
107}
108
109func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) {
110 return s.QueryMRs(repoID, MRFilter{State: state, Limit: limit, Before: before})
111}
112
113// QueryMRs lists a repository's merge requests, newest first, narrowed
114// by f.
115func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) {
116 q := mrSelect + " WHERE m.repo_id = ?"
117 args := []any{repoID}
118 if f.State != "" && f.State != "all" {
119 q += " AND m.state = ?"
120 args = append(args, f.State)
121 }
122 if f.Author != "" {
123 q += " AND u.username = ?"
124 args = append(args, f.Author)
125 }
126 switch f.Milestone {
127 case "":
128 case "none":
129 q += " AND m.milestone_id IS NULL"
130 default:
131 q += " AND ms.title = ?"
132 args = append(args, f.Milestone)
133 }
134 if f.Before > 0 {
135 q += " AND m.number < ?"
136 args = append(args, f.Before)
137 }
138 q += " ORDER BY m.number DESC"
139 if f.Limit > 0 {
140 q += " LIMIT ?"
141 args = append(args, f.Limit)
142 }
143 rows, err := s.DB.Query(q, args...)
144 if err != nil {
145 return nil, err
146 }
147 defer rows.Close()
148 var out []MR
149 for rows.Next() {
150 m, err := scanMR(rows)
151 if err != nil {
152 return nil, err
153 }
154 out = append(out, m)
155 }
156 return out, rows.Err()
157}
158
159// OpenMRsBySource returns open (and source_gone) MRs fed by the given source
160// repo branch — the cross-repo hook effect consults this.
161func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, error) {
162 rows, err := s.DB.Query(
163 mrSelect+" WHERE m.source_repo_id = ? AND m.source_ref = ? AND m.state IN ('open','source_gone')",
164 sourceRepoID, sourceRef)
165 if err != nil {
166 return nil, err
167 }
168 defer rows.Close()
169 var out []MR
170 for rows.Next() {
171 m, err := scanMR(rows)
172 if err != nil {
173 return nil, err
174 }
175 out = append(out, m)
176 }
177 return out, rows.Err()
178}
179
180// MarkMerged records the merge along with the target tip it landed on, so
181// the MR's diff stays reconstructable after fast-forwards. actorID 0 and an
182// empty at leave the merger unknown and stamp the current time, which is
183// what an import that carries neither can say.
184func (s *Store) MarkMerged(mrID int64, baseSHA string, actorID int64, at string) error {
185 _, err := s.DB.Exec(
186 `UPDATE merge_requests SET state = 'merged', merged_base = ?,
187 merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
188 merged_by = NULLIF(?, 0),
189 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
190 baseSHA, at, actorID, mrID)
191 return err
192}
193
194// MarkClosed is MarkMerged's counterpart for a merge request closed without
195// merging.
196func (s *Store) MarkClosed(mrID, actorID int64, at string) error {
197 _, err := s.DB.Exec(
198 `UPDATE merge_requests SET state = 'closed',
199 closed_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
200 closed_by = NULLIF(?, 0),
201 updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
202 at, actorID, mrID)
203 return err
204}
205
206// SetMRState moves an MR between states that carry no resolution stamp.
207// Returning to open (a source branch that came back) clears one.
208func (s *Store) SetMRState(mrID int64, state string) error {
209 stamp := ""
210 if state == "open" || state == "source_gone" {
211 stamp = ", merged_at = '', merged_by = NULL, closed_at = '', closed_by = NULL"
212 }
213 res, err := s.DB.Exec(
214 "UPDATE merge_requests SET state = ?"+stamp+", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
215 state, mrID)
216 if err != nil {
217 return err
218 }
219 if n, _ := res.RowsAffected(); n == 0 {
220 return ErrNotFound
221 }
222 return nil
223}
224
225// UpdateMRHead records a new head and marks every review at another head
226// stale, in one transaction.
227func (s *Store) UpdateMRHead(mrID int64, headSHA string) error {
228 tx, err := s.DB.Begin()
229 if err != nil {
230 return err
231 }
232 defer tx.Rollback()
233 if _, err := tx.Exec(
234 "UPDATE merge_requests SET head_sha = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
235 headSHA, mrID); err != nil {
236 return err
237 }
238 if _, err := tx.Exec(
239 "UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil {
240 return err
241 }
242 return tx.Commit()
243}
244
245// SetMRTarget retargets a merge request and marks every existing review
246// stale, in one transaction. The base of the diff is derived from the
247// target on every read, so nothing else has to move; an approval,
248// though, was of the diff against the old branch.
249func (s *Store) SetMRTarget(mrID int64, targetRef string) error {
250 tx, err := s.DB.Begin()
251 if err != nil {
252 return err
253 }
254 defer tx.Rollback()
255 res, err := tx.Exec(
256 "UPDATE merge_requests SET target_ref = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
257 targetRef, mrID)
258 if err != nil {
259 return err
260 }
261 if n, _ := res.RowsAffected(); n == 0 {
262 return ErrNotFound
263 }
264 if _, err := tx.Exec("UPDATE mr_reviews SET stale = 1 WHERE mr_id = ?", mrID); err != nil {
265 return err
266 }
267 return tx.Commit()
268}
269
270// MarkSourceGoneForRepo flags every open MR sourced from the repo; called
271// when a fork is deleted. Head refs in the target repos are retained.
272func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
273 _, err := s.DB.Exec(
274 "UPDATE merge_requests SET state = 'source_gone', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE source_repo_id = ? AND state = 'open'",
275 sourceRepoID)
276 return err
277}
278
279func (s *Store) AddMRComment(mrID, authorID int64, body, format string) error {
280 _, err := s.DB.Exec(
281 "INSERT INTO mr_comments (mr_id, author_id, body, body_format) VALUES (?, ?, ?, ?)",
282 mrID, authorID, body, format)
283 return err
284}
285
286// UpdateMRText edits title, body, and/or markup format; nil leaves a field
287// unchanged.
288func (s *Store) UpdateMRText(mrID int64, title, body, format *string) error {
289 set, args := []string{}, []any{}
290 if title != nil {
291 set, args = append(set, "title = ?"), append(args, *title)
292 }
293 if body != nil {
294 set, args = append(set, "body = ?"), append(args, *body)
295 }
296 if format != nil {
297 set, args = append(set, "body_format = ?"), append(args, *format)
298 }
299 if len(set) == 0 {
300 return nil
301 }
302 set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
303 args = append(args, mrID)
304 res, err := s.DB.Exec("UPDATE merge_requests SET "+strings.Join(set, ", ")+" WHERE id = ?", args...)
305 if err != nil {
306 return err
307 }
308 if n, _ := res.RowsAffected(); n == 0 {
309 return ErrNotFound
310 }
311 return nil
312}
313
314// AddMRSystemComment is the informational counterpart of AddMRComment.
315func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
316 _, err := s.DB.Exec(
317 "INSERT INTO mr_comments (mr_id, author_id, body, kind) VALUES (?, ?, ?, 'system')",
318 mrID, actorID, body)
319 return err
320}
321
322func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
323 rows, err := s.DB.Query(`
324 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
325 c.body, c.body_format, c.created_at, c.kind
326 FROM mr_comments c JOIN users u ON u.id = c.author_id
327 WHERE c.mr_id = ? ORDER BY c.id`, mrID)
328 if err != nil {
329 return nil, err
330 }
331 defer rows.Close()
332 var out []IssueComment
333 for rows.Next() {
334 var c IssueComment
335 if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil {
336 return nil, err
337 }
338 out = append(out, c)
339 }
340 return out, rows.Err()
341}
342
343func (s *Store) AddMRReview(mrID, reviewerID int64, verdict, headSHA string) error {
344 _, err := s.DB.Exec(
345 "INSERT INTO mr_reviews (mr_id, reviewer_id, verdict, head_sha) VALUES (?, ?, ?, ?)",
346 mrID, reviewerID, verdict, headSHA)
347 return err
348}
349
350func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) {
351 rows, err := s.DB.Query(`
352 SELECT u.username, r.verdict, r.head_sha, r.stale, r.created_at
353 FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id
354 WHERE r.mr_id = ? ORDER BY r.id`, mrID)
355 if err != nil {
356 return nil, err
357 }
358 defer rows.Close()
359 var out []MRReview
360 for rows.Next() {
361 var r MRReview
362 var stale int
363 if err := rows.Scan(&r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil {
364 return nil, err
365 }
366 r.Stale = stale != 0
367 out = append(out, r)
368 }
369 return out, rows.Err()
370}
371
372// PrimaryVerifiedEmail returns the user's primary email if verified, else "".
373func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) {
374 var addr string
375 err := s.DB.QueryRow(
376 "SELECT address FROM emails WHERE user_id = ? AND is_primary = 1 AND verified_at IS NOT NULL",
377 userID).Scan(&addr)
378 if errors.Is(err, sql.ErrNoRows) {
379 return "", nil
380 }
381 return addr, err
382}