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