internal/store/mrs.go

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

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