internal/store/mrs.go
292 lines · 8776 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
87// ListMRs returns merge requests for a repo. limit 0 means everything;
88// before (an MR number) starts the page strictly below it, matching the
89// number-descending order.
90func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) {
91 q := mrSelect + " WHERE m.repo_id = ?"
92 args := []any{repoID}
93 if state != "all" {
94 q += " AND m.state = ?"
95 args = append(args, state)
96 }
97 if before > 0 {
98 q += " AND m.number < ?"
99 args = append(args, before)
100 }
101 q += " ORDER BY m.number DESC"
102 if limit > 0 {
103 q += " LIMIT ?"
104 args = append(args, limit)
105 }
106 rows, err := s.DB.Query(q, args...)
107 if err != nil {
108 return nil, err
109 }
110 defer rows.Close()
111 var out []MR
112 for rows.Next() {
113 m, err := scanMR(rows)
114 if err != nil {
115 return nil, err
116 }
117 out = append(out, m)
118 }
119 return out, rows.Err()
120}
121
122// OpenMRsBySource returns open (and source_gone) MRs fed by the given source
123// repo branch — the cross-repo hook effect consults this.
124func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, error) {
125 rows, err := s.DB.Query(
126 mrSelect+" WHERE m.source_repo_id = ? AND m.source_ref = ? AND m.state IN ('open','source_gone')",
127 sourceRepoID, sourceRef)
128 if err != nil {
129 return nil, err
130 }
131 defer rows.Close()
132 var out []MR
133 for rows.Next() {
134 m, err := scanMR(rows)
135 if err != nil {
136 return nil, err
137 }
138 out = append(out, m)
139 }
140 return out, rows.Err()
141}
142
143// MarkMerged records the merge along with the target tip it landed on, so
144// the MR's diff stays reconstructable after fast-forwards.
145func (s *Store) MarkMerged(mrID int64, baseSHA string) error {
146 _, err := s.DB.Exec(
147 "UPDATE merge_requests SET state = 'merged', merged_base = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
148 baseSHA, mrID)
149 return err
150}
151
152func (s *Store) SetMRState(mrID int64, state string) error {
153 res, err := s.DB.Exec(
154 "UPDATE merge_requests SET state = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
155 state, mrID)
156 if err != nil {
157 return err
158 }
159 if n, _ := res.RowsAffected(); n == 0 {
160 return ErrNotFound
161 }
162 return nil
163}
164
165// UpdateMRHead records a new head and marks every review at another head
166// stale, in one transaction.
167func (s *Store) UpdateMRHead(mrID int64, headSHA string) error {
168 tx, err := s.DB.Begin()
169 if err != nil {
170 return err
171 }
172 defer tx.Rollback()
173 if _, err := tx.Exec(
174 "UPDATE merge_requests SET head_sha = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
175 headSHA, mrID); err != nil {
176 return err
177 }
178 if _, err := tx.Exec(
179 "UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil {
180 return err
181 }
182 return tx.Commit()
183}
184
185// MarkSourceGoneForRepo flags every open MR sourced from the repo; called
186// when a fork is deleted. Head refs in the target repos are retained.
187func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
188 _, err := s.DB.Exec(
189 "UPDATE merge_requests SET state = 'source_gone', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE source_repo_id = ? AND state = 'open'",
190 sourceRepoID)
191 return err
192}
193
194func (s *Store) AddMRComment(mrID, authorID int64, body string) error {
195 _, err := s.DB.Exec(
196 "INSERT INTO mr_comments (mr_id, author_id, body) VALUES (?, ?, ?)", mrID, authorID, body)
197 return err
198}
199
200// UpdateMRText edits title and/or body; nil leaves a field unchanged.
201func (s *Store) UpdateMRText(mrID int64, title, body *string) error {
202 set, args := []string{}, []any{}
203 if title != nil {
204 set, args = append(set, "title = ?"), append(args, *title)
205 }
206 if body != nil {
207 set, args = append(set, "body = ?"), append(args, *body)
208 }
209 if len(set) == 0 {
210 return nil
211 }
212 set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
213 args = append(args, mrID)
214 res, err := s.DB.Exec("UPDATE merge_requests SET "+strings.Join(set, ", ")+" WHERE id = ?", args...)
215 if err != nil {
216 return err
217 }
218 if n, _ := res.RowsAffected(); n == 0 {
219 return ErrNotFound
220 }
221 return nil
222}
223
224// AddMRSystemComment is the informational counterpart of AddMRComment.
225func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
226 _, err := s.DB.Exec(
227 "INSERT INTO mr_comments (mr_id, author_id, body, kind) VALUES (?, ?, ?, 'system')",
228 mrID, actorID, body)
229 return err
230}
231
232func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
233 rows, err := s.DB.Query(`
234 SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
235 c.body, c.created_at, c.kind
236 FROM mr_comments c JOIN users u ON u.id = c.author_id
237 WHERE c.mr_id = ? ORDER BY c.id`, mrID)
238 if err != nil {
239 return nil, err
240 }
241 defer rows.Close()
242 var out []IssueComment
243 for rows.Next() {
244 var c IssueComment
245 if err := rows.Scan(&c.Author, &c.Body, &c.CreatedAt, &c.Kind); err != nil {
246 return nil, err
247 }
248 out = append(out, c)
249 }
250 return out, rows.Err()
251}
252
253func (s *Store) AddMRReview(mrID, reviewerID int64, verdict, headSHA string) error {
254 _, err := s.DB.Exec(
255 "INSERT INTO mr_reviews (mr_id, reviewer_id, verdict, head_sha) VALUES (?, ?, ?, ?)",
256 mrID, reviewerID, verdict, headSHA)
257 return err
258}
259
260func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) {
261 rows, err := s.DB.Query(`
262 SELECT u.username, r.verdict, r.head_sha, r.stale, r.created_at
263 FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id
264 WHERE r.mr_id = ? ORDER BY r.id`, mrID)
265 if err != nil {
266 return nil, err
267 }
268 defer rows.Close()
269 var out []MRReview
270 for rows.Next() {
271 var r MRReview
272 var stale int
273 if err := rows.Scan(&r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil {
274 return nil, err
275 }
276 r.Stale = stale != 0
277 out = append(out, r)
278 }
279 return out, rows.Err()
280}
281
282// PrimaryVerifiedEmail returns the user's primary email if verified, else "".
283func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) {
284 var addr string
285 err := s.DB.QueryRow(
286 "SELECT address FROM emails WHERE user_id = ? AND is_primary = 1 AND verified_at IS NOT NULL",
287 userID).Scan(&addr)
288 if errors.Is(err, sql.ErrNoRows) {
289 return "", nil
290 }
291 return addr, err
292}