internal/store/mrs.go

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