krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

main: internal/store/mrs.go · raw

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