internal/store/mrs.go

7cc95d1fc1115f22008751a05e9e07156d49d6b0
gitbay/internal/store/mrs.go history · blame · raw

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