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