internal/store/mrs.go

160fc0ec2bbbb4367ce5db1814c1a7404b656048
gitbay/internal/store/mrs.go history · blame · raw

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