internal/store/mrs.go

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

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