internal/store/mrs.go

dca7370cd168dc7cf1f262252ab9548f66cc7dcc
gitbay/internal/store/mrs.go history · blame · raw

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