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