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