package store import ( "database/sql" "errors" "strings" ) type MR struct { ID int64 RepoID int64 Number int64 Author string SourceRepoID int64 // 0 when the source repo is gone SourcePath string // owner/name of source repo, "" when gone SourceRef string TargetRef string Title string Body string BodyFormat string // md | org State string // open | merged | closed | source_gone // Draft marks an open merge request that is not asking to be merged // yet. Not a state: see migration 0037. Draft bool Milestone string HeadSHA string MergedBase string // target tip at merge time; base for historical diffs MergedAt string // "" unless merged MergedBy string // "" when unknown (imports) or the account is gone ClosedAt string // "" unless closed without merging ClosedBy string // SupersededBy is the number, within this repository, of the merge // request this one was closed in favour of. 0 means none. SupersededBy int64 CreatedAt string UpdatedAt string Labels []string // ReviewRequests is who has been asked, directly, for a review — the // mr review request counterpart of Issue.Assignees. ReviewRequests []string // The queued merge (mr merge --when-ready); QueuedAt is "" when there // is none. QueueReason is why the last attempt did not merge. QueuedByID int64 QueuedBy string QueueStrategy string QueueReason string QueuedAt string } type MRReview struct { Reviewer string Verdict string HeadSHA string Stale bool CreatedAt string } func (s *Store) CreateMR(repoID, authorID, sourceRepoID int64, sourceRef, targetRef, title, body, headSHA, format string, draft bool) (int64, error) { tx, err := s.DB.Begin() if err != nil { return 0, err } defer tx.Rollback() if _, err := tx.Exec("UPDATE repos SET mr_counter = mr_counter + 1 WHERE id = ?", repoID); err != nil { return 0, err } var n int64 if err := tx.QueryRow("SELECT mr_counter FROM repos WHERE id = ?", repoID).Scan(&n); err != nil { return 0, err } if _, err := tx.Exec(` INSERT INTO merge_requests (repo_id, number, author_id, source_repo_id, source_ref, target_ref, title, body, head_sha, body_format, draft) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, repoID, n, authorID, sourceRepoID, sourceRef, targetRef, title, body, headSHA, format, draft); err != nil { return 0, err } if headSHA != "" { var mrID int64 if err := tx.QueryRow("SELECT id FROM merge_requests WHERE repo_id = ? AND number = ?", repoID, n).Scan(&mrID); err != nil { return 0, err } if _, err := tx.Exec("INSERT INTO mr_heads (mr_id, sha) VALUES (?, ?)", mrID, headSHA); err != nil { return 0, err } } return n, tx.Commit() } // SetMRDraft marks an open merge request as a draft, or takes the mark // off. Merging is refused while it is set. func (s *Store) SetMRDraft(mrID int64, draft bool) error { _, err := s.DB.Exec( "UPDATE merge_requests SET draft = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", draft, mrID) return err } const mrSelect = ` SELECT m.id, m.repo_id, m.number, u.username, COALESCE(m.source_repo_id, 0), COALESCE(COALESCE(su.username, so.name) || '/' || sr.name, ''), m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, COALESCE(ms.title, ''), m.head_sha, m.merged_base, m.merged_at, COALESCE(mu.username, ''), m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at, COALESCE(q.user_id, 0), COALESCE(qu.username, ''), COALESCE(q.strategy, ''), COALESCE(q.reason, ''), COALESCE(q.queued_at, '') FROM merge_requests m JOIN users u ON u.id = m.author_id LEFT JOIN users mu ON mu.id = m.merged_by LEFT JOIN users cu ON cu.id = m.closed_by LEFT JOIN repos sr ON sr.id = m.source_repo_id LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id LEFT JOIN milestones ms ON ms.id = m.milestone_id LEFT JOIN mr_merge_queue q ON q.mr_id = m.id LEFT JOIN users qu ON qu.id = q.user_id` func scanMR(row interface{ Scan(...any) error }) (MR, error) { var m MR err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase, &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt, &m.QueuedByID, &m.QueuedBy, &m.QueueStrategy, &m.QueueReason, &m.QueuedAt) return m, err } func (s *Store) MRByNumber(repoID, number int64) (MR, error) { m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.repo_id = ? AND m.number = ?", repoID, number)) if errors.Is(err, sql.ErrNoRows) { return m, ErrNotFound } if err != nil { return m, err } if m.Labels, err = s.issueStrings(m.ID, ` SELECT l.name FROM mr_labels ml JOIN labels l ON l.id = ml.label_id WHERE ml.mr_id = ? ORDER BY l.name`); err != nil { return m, err } m.ReviewRequests, err = s.issueStrings(m.ID, ` SELECT u.username FROM mr_review_requests rr JOIN users u ON u.id = rr.user_id WHERE rr.mr_id = ? ORDER BY u.username`) return m, err } // ListMRLabels returns the label names attached to each merge request of // a repo, keyed by merge request id, its org's labels included. Used by // the web merge request listing; ListMRs itself stays label-free for the // CLI's lean list output. func (s *Store) ListMRLabels(repo Repo) (map[int64][]string, error) { return s.listItemLabels(mrLabelJoin, repo) } // SetMRLabel attaches (add) or detaches a label by name, the issue rules // exactly: the org's row when the org has the name, else the // repository's, created on first use. func (s *Store) SetMRLabel(repo Repo, mrID int64, name string, add bool) error { return s.setItemLabel(mrLabelJoin, repo, mrID, name, add) } // SetMRReviewRequest adds or removes a review request by user id — the // mr review request counterpart of SetIssueAssignee. func (s *Store) SetMRReviewRequest(mrID, userID int64, add bool) error { if add { _, err := s.DB.Exec( "INSERT INTO mr_review_requests (mr_id, user_id) VALUES (?, ?) ON CONFLICT DO NOTHING", mrID, userID) return err } res, err := s.DB.Exec( "DELETE FROM mr_review_requests WHERE mr_id = ? AND user_id = ?", mrID, userID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // MRReviewRequestIDs returns who has been asked for a review, by id — for // notifying them without a username round trip. func (s *Store) MRReviewRequestIDs(mrID int64) ([]int64, error) { return s.idQuery("SELECT user_id FROM mr_review_requests WHERE mr_id = ?", mrID) } // ListMRs returns merge requests for a repo. limit 0 means everything; // before (an MR number) starts the page strictly below it, matching the // number-descending order. // MRFilter narrows a listing. Empty strings match anything; State "all" // too. Milestone "none" selects merge requests with no milestone. type MRFilter struct { State string Label string Author string Milestone string Search string // full-text over title and body Limit int Before int64 } func (s *Store) ListMRs(repoID int64, state string, limit int, before int64) ([]MR, error) { return s.QueryMRs(repoID, MRFilter{State: state, Limit: limit, Before: before}) } // QueryMRs lists a repository's merge requests, newest first, narrowed // by f. func (s *Store) QueryMRs(repoID int64, f MRFilter) ([]MR, error) { q := mrSelect + " WHERE m.repo_id = ?" args := []any{repoID} if f.State != "" && f.State != "all" { q += " AND m.state = ?" args = append(args, f.State) } if f.Label != "" { q += ` AND EXISTS (SELECT 1 FROM mr_labels ml JOIN labels l ON l.id = ml.label_id WHERE ml.mr_id = m.id AND l.name = ?)` args = append(args, f.Label) } if f.Author != "" { q += " AND u.username = ?" args = append(args, f.Author) } switch f.Milestone { case "": case "none": q += " AND m.milestone_id IS NULL" default: q += " AND ms.title = ?" args = append(args, f.Milestone) } if f.Search != "" { q += " AND m.id IN (SELECT rowid FROM mr_fts WHERE mr_fts MATCH ?)" args = append(args, FTSQuery(f.Search)) } if f.Before > 0 { q += " AND m.number < ?" args = append(args, f.Before) } q += " ORDER BY m.number DESC" if f.Limit > 0 { q += " LIMIT ?" args = append(args, f.Limit) } rows, err := s.DB.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var out []MR for rows.Next() { m, err := scanMR(rows) if err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } // OpenMRsBySource returns open (and source_gone) MRs fed by the given source // repo branch — the cross-repo hook effect consults this. func (s *Store) OpenMRsBySource(sourceRepoID int64, sourceRef string) ([]MR, error) { rows, err := s.DB.Query( mrSelect+" WHERE m.source_repo_id = ? AND m.source_ref = ? AND m.state IN ('open','source_gone')", sourceRepoID, sourceRef) if err != nil { return nil, err } defer rows.Close() var out []MR for rows.Next() { m, err := scanMR(rows) if err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } // MarkMerged records the merge along with the target tip it landed on, so // the MR's diff stays reconstructable after fast-forwards. actorID 0 and an // empty at leave the merger unknown and stamp the current time, which is // what an import that carries neither can say. func (s *Store) MarkMerged(mrID int64, baseSHA string, actorID int64, at string) error { _, err := s.DB.Exec( `UPDATE merge_requests SET state = 'merged', merged_base = ?, merged_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), merged_by = NULLIF(?, 0), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, baseSHA, at, actorID, mrID) return err } // MarkClosed is MarkMerged's counterpart for a merge request closed without // merging. func (s *Store) MarkClosed(mrID, actorID int64, at string) error { _, err := s.DB.Exec( `UPDATE merge_requests SET state = 'closed', closed_at = COALESCE(NULLIF(?, ''), strftime('%Y-%m-%dT%H:%M:%fZ','now')), closed_by = NULLIF(?, 0), updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, at, actorID, mrID) return err } // SetSupersededBy records which merge request, by number within the same // repository, this one was closed in favour of. n of 0 clears it. func (s *Store) SetSupersededBy(mrID, n int64) error { var v any if n != 0 { v = n } _, err := s.DB.Exec("UPDATE merge_requests SET superseded_by = ? WHERE id = ?", v, mrID) return err } // MRsSuperseding returns the merge requests in a repository whose // superseded_by names number, oldest first — the reverse of // MR.SupersededBy. func (s *Store) MRsSuperseding(repoID, number int64) ([]MR, error) { rows, err := s.DB.Query(mrSelect+" WHERE m.repo_id = ? AND m.superseded_by = ? ORDER BY m.number ASC", repoID, number) if err != nil { return nil, err } defer rows.Close() var out []MR for rows.Next() { m, err := scanMR(rows) if err != nil { return nil, err } out = append(out, m) } return out, rows.Err() } // SetMRState moves an MR between states that carry no resolution stamp. // Returning to open (a source branch that came back) clears one. func (s *Store) SetMRState(mrID int64, state string) error { stamp := "" if state == "open" || state == "source_gone" { stamp = ", merged_at = '', merged_by = NULL, closed_at = '', closed_by = NULL" } res, err := s.DB.Exec( "UPDATE merge_requests SET state = ?"+stamp+", updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", state, mrID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // UpdateMRHead moves a merge request onto a new head, stales the reviews // of the old one, and records the head in the history a range-diff reads. // baseSHA is the merge base at this moment; "" when the caller could not // work it out, which only costs the range-diff its precision. // // sameDiff says the new head proposes the change the old one did (a // rebase onto a moved target, or the same commits pushed again). Then the // fresh reviews of the old head are reviews of this diff and move to the // new head rather than going stale (#198). Reviews already stale stay so. func (s *Store) UpdateMRHead(mrID int64, headSHA, baseSHA string, sameDiff bool) error { tx, err := s.DB.Begin() if err != nil { return err } defer tx.Rollback() if sameDiff { if _, err := tx.Exec(` UPDATE mr_reviews SET head_sha = ? WHERE mr_id = ? AND stale = 0 AND head_sha = (SELECT head_sha FROM merge_requests WHERE id = ?)`, headSHA, mrID, mrID); err != nil { return err } } if _, err := tx.Exec( "UPDATE merge_requests SET head_sha = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", headSHA, mrID); err != nil { return err } if _, err := tx.Exec( "UPDATE mr_reviews SET stale = 1 WHERE mr_id = ? AND head_sha <> ?", mrID, headSHA); err != nil { return err } // Same head twice is a push that changed nothing about this merge // request; it should not add a revision to compare against. var last string tx.QueryRow("SELECT sha FROM mr_heads WHERE mr_id = ? ORDER BY id DESC LIMIT 1", mrID).Scan(&last) if last != headSHA { if _, err := tx.Exec( "INSERT INTO mr_heads (mr_id, sha, base_sha) VALUES (?, ?, ?)", mrID, headSHA, baseSHA); err != nil { return err } } return tx.Commit() } // MRHead is one revision a merge request has had. type MRHead struct { SHA string BaseSHA string CreatedAt string } // MRHeads returns a merge request's revisions, oldest first. func (s *Store) MRHeads(mrID int64) ([]MRHead, error) { rows, err := s.DB.Query( "SELECT sha, base_sha, created_at FROM mr_heads WHERE mr_id = ? ORDER BY id", mrID) if err != nil { return nil, err } defer rows.Close() var out []MRHead for rows.Next() { var h MRHead if err := rows.Scan(&h.SHA, &h.BaseSHA, &h.CreatedAt); err != nil { return nil, err } out = append(out, h) } return out, rows.Err() } // SetMRTarget retargets a merge request and marks every existing review // stale, in one transaction. The base of the diff is derived from the // target on every read, so nothing else has to move; an approval, // though, was of the diff against the old branch. func (s *Store) SetMRTarget(mrID int64, targetRef string) error { tx, err := s.DB.Begin() if err != nil { return err } defer tx.Rollback() res, err := tx.Exec( "UPDATE merge_requests SET target_ref = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?", targetRef, mrID) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } if _, err := tx.Exec("UPDATE mr_reviews SET stale = 1 WHERE mr_id = ?", mrID); err != nil { return err } return tx.Commit() } // MarkSourceGoneForRepo flags every open MR sourced from the repo; called // when a fork is deleted. Head refs in the target repos are retained. func (s *Store) MarkSourceGoneForRepo(sourceRepoID int64) error { _, err := s.DB.Exec( "UPDATE merge_requests SET state = 'source_gone', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE source_repo_id = ? AND state = 'open'", sourceRepoID) return err } func (s *Store) AddMRComment(mrID, authorID int64, body, format string) error { _, err := s.DB.Exec( "INSERT INTO mr_comments (mr_id, author_id, body, body_format) VALUES (?, ?, ?, ?)", mrID, authorID, body, format) return err } // UpdateMRText edits title, body, and/or markup format; nil leaves a field // unchanged. func (s *Store) UpdateMRText(mrID int64, title, body, format *string) error { set, args := []string{}, []any{} if title != nil { set, args = append(set, "title = ?"), append(args, *title) } if body != nil { set, args = append(set, "body = ?"), append(args, *body) } if format != nil { set, args = append(set, "body_format = ?"), append(args, *format) } if len(set) == 0 { return nil } set = append(set, "updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')") args = append(args, mrID) res, err := s.DB.Exec("UPDATE merge_requests SET "+strings.Join(set, ", ")+" WHERE id = ?", args...) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // AddMRSystemComment is the informational counterpart of AddMRComment. func (s *Store) AddMRSystemComment(mrID, actorID int64, body string) error { _, err := s.DB.Exec( "INSERT INTO mr_comments (mr_id, author_id, body, kind) VALUES (?, ?, ?, 'system')", mrID, actorID, body) return err } func (s *Store) ListMRComments(mrID int64) ([]IssueComment, error) { rows, err := s.DB.Query(` SELECT c.id, CASE WHEN c.kind = 'system' THEN 'system' ELSE u.username END, c.body, c.body_format, c.created_at, c.kind FROM mr_comments c JOIN users u ON u.id = c.author_id WHERE c.mr_id = ? ORDER BY c.id`, mrID) if err != nil { return nil, err } defer rows.Close() var out []IssueComment for rows.Next() { var c IssueComment if err := rows.Scan(&c.ID, &c.Author, &c.Body, &c.BodyFormat, &c.CreatedAt, &c.Kind); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // MRCommentCounts totals, per MR, conversation comments plus diff-thread // roots — what the list page shows as one comment count. System comments, // diff-thread replies, and pending (unpublished) diff comments do not // count. The list handler asks for every row on a page in one call rather // than one query per MR. func (s *Store) MRCommentCounts(repoID int64, mrIDs []int64) (map[int64]int, error) { out := map[int64]int{} if len(mrIDs) == 0 { return out, nil } ph := "?" + strings.Repeat(",?", len(mrIDs)-1) args := make([]any, 0, len(mrIDs)+1) args = append(args, repoID) for _, id := range mrIDs { args = append(args, id) } add := func(query string) error { rows, err := s.DB.Query(query, args...) if err != nil { return err } defer rows.Close() for rows.Next() { var mrID int64 var n int if err := rows.Scan(&mrID, &n); err != nil { return err } out[mrID] += n } return rows.Err() } if err := add(` SELECT c.mr_id, COUNT(*) FROM mr_comments c JOIN merge_requests m ON m.id = c.mr_id WHERE m.repo_id = ? AND c.kind <> 'system' AND c.mr_id IN (` + ph + `) GROUP BY c.mr_id`); err != nil { return nil, err } if err := add(` SELECT c.mr_id, COUNT(*) FROM mr_diff_comments c JOIN merge_requests m ON m.id = c.mr_id WHERE m.repo_id = ? AND c.reply_to IS NULL AND c.pending = 0 AND c.mr_id IN (` + ph + `) GROUP BY c.mr_id`); err != nil { return nil, err } return out, nil } func (s *Store) AddMRReview(mrID, reviewerID int64, verdict, headSHA string) error { _, err := s.DB.Exec( "INSERT INTO mr_reviews (mr_id, reviewer_id, verdict, head_sha) VALUES (?, ?, ?, ?)", mrID, reviewerID, verdict, headSHA) return err } func (s *Store) ListMRReviews(mrID int64) ([]MRReview, error) { rows, err := s.DB.Query(` SELECT u.username, r.verdict, r.head_sha, r.stale, r.created_at FROM mr_reviews r JOIN users u ON u.id = r.reviewer_id WHERE r.mr_id = ? ORDER BY r.id`, mrID) if err != nil { return nil, err } defer rows.Close() var out []MRReview for rows.Next() { var r MRReview var stale int if err := rows.Scan(&r.Reviewer, &r.Verdict, &r.HeadSHA, &stale, &r.CreatedAt); err != nil { return nil, err } r.Stale = stale != 0 out = append(out, r) } return out, rows.Err() } // PrimaryVerifiedEmail returns the user's primary email if verified, else "". func (s *Store) PrimaryVerifiedEmail(userID int64) (string, error) { var addr string err := s.DB.QueryRow( "SELECT address FROM emails WHERE user_id = ? AND is_primary = 1 AND verified_at IS NOT NULL", userID).Scan(&addr) if errors.Is(err, sql.ErrNoRows) { return "", nil } return addr, err } // ActivityMailAddress returns where activity mail for an account goes: // its verified primary address, or "" when there is none or the account // turned activity mail off (#194). func (s *Store) ActivityMailAddress(userID int64) (string, error) { on, err := s.MailEnabled(userID) if err != nil || !on { return "", err } return s.PrimaryVerifiedEmail(userID) } // PreferredVerifiedEmail returns the primary address if it is verified, // otherwise the account's other verified address that sorts first by // address; "" if none is verified. Unlike PrimaryVerifiedEmail, a verified // secondary counts: an account that verified one address but not its // primary still has somewhere to send a login link. func (s *Store) PreferredVerifiedEmail(userID int64) (string, error) { var addr string err := s.DB.QueryRow( `SELECT address FROM emails WHERE user_id = ? AND verified_at IS NOT NULL ORDER BY is_primary DESC, address LIMIT 1`, userID).Scan(&addr) if errors.Is(err, sql.ErrNoRows) { return "", nil } return addr, err }