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