internal/store/mrs.go

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

523 lines · 17059 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	// Draft marks an open merge request that is not asking to be merged
 23	// yet. Not a state: see migration 0037.
 24	Draft      bool
 25	Milestone  string
 26	HeadSHA    string
 27	MergedBase string // target tip at merge time; base for historical diffs
 28	MergedAt   string // "" unless merged
 29	MergedBy   string // "" when unknown (imports) or the account is gone
 30	ClosedAt   string // "" unless closed without merging
 31	ClosedBy   string
 32	CreatedAt  string
 33	UpdatedAt  string
 34	// ReviewRequests is who has been asked, directly, for a review — the
 35	// mr review request counterpart of Issue.Assignees.
 36	ReviewRequests []string
 37}
 38
 39type MRReview struct {
 40	Reviewer  string
 41	Verdict   string
 42	HeadSHA   string
 43	Stale     bool
 44	CreatedAt string
 45}
 46
 47func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA, format string, draft bool) (int64, error) {
 48	tx, err := s.DB.Begin()
 49	if err != nil {
 50		return 0, err
 51	}
 52	defer tx.Rollback()
 53	if _, err := tx.Exec("UPDATE repos SET mr_counter = mr_counter + 1 WHERE id = ?", repoID); err != nil {
 54		return 0, err
 55	}
 56	var n int64
 57	if err := tx.QueryRow("SELECT mr_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil {
 58		return 0, err
 59	}
 60	if _, err := tx.Exec(`
 61		INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha, body_format, draft)
 62		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
 63		repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format, draft); err != nil {
 64		return 0, err
 65	}
 66	if headSHA != "" {
 67		var mrID int64
 68		if err := tx.QueryRow("SELECT id FROM merge_requests WHERE repo_id = ? AND number = ?",
 69			repoID, n).Scan(&mrID); err != nil {
 70			return 0, err
 71		}
 72		if _, err := tx.Exec("INSERT INTO mr_heads (mr_id, sha) VALUES (?, ?)", mrID, headSHA); err != nil {
 73			return 0, err
 74		}
 75	}
 76	return n, tx.Commit()
 77}
 78
 79// SetMRDraft marks an open merge request as a draft, or takes the mark
 80// off. Merging is refused while it is set.
 81func (s *Store) SetMRDraft(mrID int64, draft bool) error {
 82	_, err := s.DB.Exec(
 83		"UPDATE merge_requests SET draft = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
 84		draft, mrID)
 85	return err
 86}
 87
 88const mrSelect = `
 89	SELECT m.id, m.repo_id, m.number, u.username,
 90	       COALESCE(m.source_repo_id, 0),
 91	       COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''),
 92	       m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
 93	       COALESCE(ms.title, ''), m.head_sha,
 94	       m.merged_base, m.merged_at, COALESCE(mu.username, ''),
 95	       m.closed_at, COALESCE(cu.username, ''), m.created_at, m.updated_at
 96	FROM merge_requests m
 97	JOIN users u ON u.id = m.author_id
 98	LEFT JOIN users mu ON mu.id = m.merged_by
 99	LEFT JOIN users cu ON cu.id = m.closed_by
100	LEFT JOIN repos sr ON sr.id = m.source_repo_id
101	LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id
102	LEFT JOIN orgs so  ON sr.owner_kind = 'org'  AND so.id = sr.owner_id
103	LEFT JOIN milestones ms ON ms.id = m.milestone_id`
104
105func scanMR(row interface{ Scan(...any) error }) (MR, error) {
106	var m MR
107	err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
108		&m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase,
109		&m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.CreatedAt, &m.UpdatedAt)
110	return m, err
111}
112
113func (s *Store) MRByNumber(repoID, number int64) (MR, error) {
114	m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.repo_id = ? AND m.number = ?", repoID, number))
115	if errors.Is(err, sql.ErrNoRows) {
116		return m, ErrNotFound
117	}
118	if err != nil {
119		return m, err
120	}
121	m.ReviewRequests, err = s.issueStrings(m.ID, `
122		SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id
123		WHERE rr.mr_id = ? ORDER BY u.username`)
124	return m, err
125}
126
127// SetMRReviewRequest adds or removes a review request by user id — the
128// mr review request counterpart of SetIssueAssignee.
129func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error {
130	if add {
131		_, err := s.DB.Exec(
132			"INSERT INTO mr_review_requests (mr_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING",
133			mrID, userID)
134		return err
135	}
136	res, err := s.DB.Exec(
137		"DELETE FROM mr_review_requests WHERE mr_id = ? AND user_id = ?", mrID, userID)
138	if err != nil {
139		return err
140	}
141	if n, _ := res.RowsAffected(); n == 0 {
142		return ErrNotFound
143	}
144	return nil
145}
146
147// MRReviewRequestIDs returns who has been asked for a review, by id — for
148// notifying them without a username round trip.
149func (s *Store) MRReviewRequestIDs(mrID int64) ([]int64, error) {
150	return s.idQuery("SELECT user_id FROM mr_review_requests WHERE mr_id = ?", mrID)
151}
152
153// ListMRs returns merge requests for a repo. limit 0 means everything;
154// before (an MR number) starts the page strictly below it, matching the
155// number-descending order.
156// MRFilter narrows a listing. Empty strings match anything; State "all"
157// too. Milestone "none" selects merge requests with no milestone.
158type MRFilter struct {
159	State     string
160	Author    string
161	Milestone string
162	Search    string // full-text over title and body
163	Limit     int
164	Before    int64
165}
166
167func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) {
168	return s.QueryMRs(repoID, MRFilter{State: state, Limit: limit, Before: before})
169}
170
171// QueryMRs lists a repository's merge requests, newest first, narrowed
172// by f.
173func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) {
174	q := mrSelect + " WHERE m.repo_id = ?"
175	args := []any{repoID}
176	if f.State != "" && f.State != "all" {
177		q += " AND m.state = ?"
178		args = append(args, f.State)
179	}
180	if f.Author != "" {
181		q += " AND u.username = ?"
182		args = append(args, f.Author)
183	}
184	switch f.Milestone {
185	case "":
186	case "none":
187		q += " AND m.milestone_id IS NULL"
188	default:
189		q += " AND ms.title = ?"
190		args = append(args, f.Milestone)
191	}
192	if f.Search != "" {
193		q += " AND m.id IN (SELECT rowid FROM mr_fts WHERE mr_fts MATCH ?)"
194		args = append(args, FTSQuery(f.Search))
195	}
196	if f.Before > 0 {
197		q += " AND m.number < ?"
198		args = append(args, f.Before)
199	}
200	q += " ORDER BY m.number DESC"
201	if f.Limit > 0 {
202		q += " LIMIT ?"
203		args = append(args, f.Limit)
204	}
205	rows, err := s.DB.Query(q, args...)
206	if err != nil {
207		return nil, err
208	}
209	defer rows.Close()
210	var out []MR
211	for rows.Next() {
212		m, err := scanMR(rows)
213		if err != nil {
214			return nil, err
215		}
216		out = append(out, m)
217	}
218	return out, rows.Err()
219}
220
221// OpenMRsBySource returns open (and source_gone) MRs fed by the given source
222// repo branch — the cross-repo hook effect consults this.
223func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, error) {
224	rows, err := s.DB.Query(
225		mrSelect+" WHERE m.source_repo_id = ? AND m.source_ref = ? AND m.state IN ('open','source_gone')",
226		sourceRepoID, sourceRef)
227	if err != nil {
228		return nil, err
229	}
230	defer rows.Close()
231	var out []MR
232	for rows.Next() {
233		m, err := scanMR(rows)
234		if err != nil {
235			return nil, err
236		}
237		out = append(out, m)
238	}
239	return out, rows.Err()
240}
241
242// MarkMerged records the merge along with the target tip it landed on, so
243// the MR's diff stays reconstructable after fast-forwards. actorID 0 and an
244// empty at leave the merger unknown and stamp the current time, which is
245// what an import that carries neither can say.
246func (s *Store) MarkMerged(mrID int64, baseSHA string, actorID int64, at string) error {
247	_, err := s.DB.Exec(
248		`UPDATE merge_requests SET state = 'merged', merged_base = ?,
249			merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
250			merged_by = NULLIF(?, 0),
251			updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
252		baseSHA, at, actorID, mrID)
253	return err
254}
255
256// MarkClosed is MarkMerged's counterpart for a merge request closed without
257// merging.
258func (s *Store) MarkClosed(mrID, actorID int64, at string) error {
259	_, err := s.DB.Exec(
260		`UPDATE merge_requests SET state = 'closed',
261			closed_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')),
262			closed_by = NULLIF(?, 0),
263			updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`,
264		at, actorID, mrID)
265	return err
266}
267
268// SetMRState moves an MR between states that carry no resolution stamp.
269// Returning to open (a source branch that came back) clears one.
270func (s *Store) SetMRState(mrID int64, state string) error {
271	stamp := ""
272	if state == "open" || state == "source_gone" {
273		stamp = ", merged_at = '', merged_by = NULL, closed_at = '', closed_by = NULL"
274	}
275	res, err := s.DB.Exec(
276		"UPDATE merge_requests SET state = ?"+stamp+", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
277		state, mrID)
278	if err != nil {
279		return err
280	}
281	if n, _ := res.RowsAffected(); n == 0 {
282		return ErrNotFound
283	}
284	return nil
285}
286
287// UpdateMRHead moves a merge request onto a new head, stales the reviews
288// of the old one, and records the head in the history a range-diff reads.
289// baseSHA is the merge base at this moment; "" when the caller could not
290// work it out, which only costs the range-diff its precision.
291//
292// sameDiff says the new head proposes the change the old one did (a
293// rebase onto a moved target, or the same commits pushed again). Then the
294// fresh reviews of the old head are reviews of this diff and move to the
295// new head rather than going stale (#198). Reviews already stale stay so.
296func (s *Store) UpdateMRHead(mrID int64, headSHA, baseSHA string, sameDiff bool) error {
297	tx, err := s.DB.Begin()
298	if err != nil {
299		return err
300	}
301	defer tx.Rollback()
302	if sameDiff {
303		if _, err := tx.Exec(`
304			UPDATE mr_reviews SET head_sha = ? WHERE mr_id = ? AND stale = 0
305			AND head_sha = (SELECT head_sha FROM merge_requests WHERE id = ?)`,
306			headSHA, mrID, mrID); err != nil {
307			return err
308		}
309	}
310	if _, err := tx.Exec(
311		"UPDATE merge_requests SET head_sha = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
312		headSHA, mrID); err != nil {
313		return err
314	}
315	if _, err := tx.Exec(
316		"UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil {
317		return err
318	}
319	// Same head twice is a push that changed nothing about this merge
320	// request; it should not add a revision to compare against.
321	var last string
322	tx.QueryRow("SELECT sha FROM mr_heads WHERE mr_id = ? ORDER BY id DESC LIMIT 1", mrID).Scan(&last)
323	if last != headSHA {
324		if _, err := tx.Exec(
325			"INSERT INTO mr_heads (mr_id, sha, base_sha) VALUES (?, ?, ?)", mrID, headSHA, baseSHA); err != nil {
326			return err
327		}
328	}
329	return tx.Commit()
330}
331
332// MRHead is one revision a merge request has had.
333type MRHead struct {
334	SHA       string
335	BaseSHA   string
336	CreatedAt string
337}
338
339// MRHeads returns a merge request's revisions, oldest first.
340func (s *Store) MRHeads(mrID int64) ([]MRHead, error) {
341	rows, err := s.DB.Query(
342		"SELECT sha, base_sha, created_at FROM mr_heads WHERE mr_id = ? ORDER BY id", mrID)
343	if err != nil {
344		return nil, err
345	}
346	defer rows.Close()
347	var out []MRHead
348	for rows.Next() {
349		var h MRHead
350		if err := rows.Scan(&h.SHA, &h.BaseSHA, &h.CreatedAt); err != nil {
351			return nil, err
352		}
353		out = append(out, h)
354	}
355	return out, rows.Err()
356}
357
358// SetMRTarget retargets a merge request and marks every existing review
359// stale, in one transaction. The base of the diff is derived from the
360// target on every read, so nothing else has to move; an approval,
361// though, was of the diff against the old branch.
362func (s *Store) SetMRTarget(mrID int64, targetRef string) error {
363	tx, err := s.DB.Begin()
364	if err != nil {
365		return err
366	}
367	defer tx.Rollback()
368	res, err := tx.Exec(
369		"UPDATE merge_requests SET target_ref = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
370		targetRef, mrID)
371	if err != nil {
372		return err
373	}
374	if n, _ := res.RowsAffected(); n == 0 {
375		return ErrNotFound
376	}
377	if _, err := tx.Exec("UPDATE mr_reviews SET stale = 1 WHERE mr_id = ?", mrID); err != nil {
378		return err
379	}
380	return tx.Commit()
381}
382
383// MarkSourceGoneForRepo flags every open MR sourced from the repo; called
384// when a fork is deleted. Head refs in the target repos are retained.
385func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error {
386	_, err := s.DB.Exec(
387		"UPDATE merge_requests SET state = 'source_gone', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE source_repo_id = ? AND state = 'open'",
388		sourceRepoID)
389	return err
390}
391
392func (s *Store) AddMRComment(mrID, authorID int64, body, format string) error {
393	_, err := s.DB.Exec(
394		"INSERT INTO mr_comments (mr_id, author_id, body, body_format) VALUES (?, ?, ?, ?)",
395		mrID, authorID, body, format)
396	return err
397}
398
399// UpdateMRText edits title, body, and/or markup format; nil leaves a field
400// unchanged.
401func (s *Store) UpdateMRText(mrID int64, title, body, format *string) error {
402	set, args := []string{}, []any{}
403	if title != nil {
404		set, args = append(set, "title = ?"), append(args, *title)
405	}
406	if body != nil {
407		set, args = append(set, "body = ?"), append(args, *body)
408	}
409	if format != nil {
410		set, args = append(set, "body_format = ?"), append(args, *format)
411	}
412	if len(set) == 0 {
413		return nil
414	}
415	set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')")
416	args = append(args, mrID)
417	res, err := s.DB.Exec("UPDATE merge_requests SET "+strings.Join(set, ", ")+" WHERE id = ?", args...)
418	if err != nil {
419		return err
420	}
421	if n, _ := res.RowsAffected(); n == 0 {
422		return ErrNotFound
423	}
424	return nil
425}
426
427// AddMRSystemComment is the informational counterpart of AddMRComment.
428func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error {
429	_, err := s.DB.Exec(
430		"INSERT INTO mr_comments (mr_id, author_id, body, kind) VALUES (?, ?, ?, 'system')",
431		mrID, actorID, body)
432	return err
433}
434
435func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) {
436	rows, err := s.DB.Query(`
437		SELECT CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END,
438		       c.body, c.body_format, c.created_at, c.kind
439		FROM mr_comments c JOIN users u ON u.id = c.author_id
440		WHERE c.mr_id = ? ORDER BY c.id`, mrID)
441	if err != nil {
442		return nil, err
443	}
444	defer rows.Close()
445	var out []IssueComment
446	for rows.Next() {
447		var c IssueComment
448		if err := rows.Scan(&c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil {
449			return nil, err
450		}
451		out = append(out, c)
452	}
453	return out, rows.Err()
454}
455
456func (s *Store) AddMRReview(mrID, reviewerID int64, verdict, headSHA string) error {
457	_, err := s.DB.Exec(
458		"INSERT INTO mr_reviews (mr_id, reviewer_id, verdict, head_sha) VALUES (?, ?, ?, ?)",
459		mrID, reviewerID, verdict, headSHA)
460	return err
461}
462
463func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) {
464	rows, err := s.DB.Query(`
465		SELECT u.username, r.verdict, r.head_sha, r.stale, r.created_at
466		FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id
467		WHERE r.mr_id = ? ORDER BY r.id`, mrID)
468	if err != nil {
469		return nil, err
470	}
471	defer rows.Close()
472	var out []MRReview
473	for rows.Next() {
474		var r MRReview
475		var stale int
476		if err := rows.Scan(&r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil {
477			return nil, err
478		}
479		r.Stale = stale != 0
480		out = append(out, r)
481	}
482	return out, rows.Err()
483}
484
485// PrimaryVerifiedEmail returns the user's primary email if verified, else "".
486func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) {
487	var addr string
488	err := s.DB.QueryRow(
489		"SELECT address FROM emails WHERE user_id = ? AND is_primary = 1 AND verified_at IS NOT NULL",
490		userID).Scan(&addr)
491	if errors.Is(err, sql.ErrNoRows) {
492		return "", nil
493	}
494	return addr, err
495}
496
497// ActivityMailAddress returns where activity mail for an account goes:
498// its verified primary address, or "" when there is none or the account
499// turned activity mail off (#194).
500func (s *Store) ActivityMailAddress(userID int64) (string, error) {
501	on, err := s.MailEnabled(userID)
502	if err != nil || !on {
503		return "", err
504	}
505	return s.PrimaryVerifiedEmail(userID)
506}
507
508// PreferredVerifiedEmail returns the primary address if it is verified,
509// otherwise the account's other verified address that sorts first by
510// address; "" if none is verified. Unlike PrimaryVerifiedEmail, a verified
511// secondary counts: an account that verified one address but not its
512// primary still has somewhere to send a login link.
513func (s *Store) PreferredVerifiedEmail(userID int64) (string, error) {
514	var addr string
515	err := s.DB.QueryRow(
516		`SELECT address FROM emails WHERE user_id = ? AND verified_at IS NOT NULL
517		 ORDER BY is_primary DESC, address LIMIT 1`,
518		userID).Scan(&addr)
519	if errors.Is(err, sql.ErrNoRows) {
520		return "", nil
521	}
522	return addr, err
523}