Commit 93177cdf69

93177cdf695869236ce3123f66f8f1f8668f6265

parent: f5b05a2161

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 03:33 UTC

store: merge queue table, MR queue fields, queued flag on dashboard rows

Ref #289

Layout: unified · split

internal/store/dashboard.go +10 −6
@@ -10,6 +10,7 @@ type DashboardItem struct {
10 Author string 10 Author string
11 State string 11 State string
12 UpdatedAt string 12 UpdatedAt string
13 Queued bool // a merge request with a queued merge
13} 14}
14 15
15// reachableCond filters to repositories the user owns, is granted on, or 16// reachableCond filters to repositories the user owns, is granted on, or
@@ -39,7 +40,7 @@ func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error)
39 var out []DashboardItem 40 var out []DashboardItem
40 for rows.Next() { 41 for rows.Next() {
41 var d DashboardItem 42 var d DashboardItem
42 if err := rows.Scan(&d.RepoPath, &d.Number, &d.Title, &d.Author, &d.State, &d.UpdatedAt); err != nil { 43 if err := rows.Scan(&d.RepoPath, &d.Number, &d.Title, &d.Author, &d.State, &d.UpdatedAt, &d.Queued); err != nil {
43 return nil, err 44 return nil, err
44 } 45 }
45 out = append(out, d) 46 out = append(out, d)
@@ -51,7 +52,8 @@ func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error)
51// each still walks the 0035 index that supplies its ORDER BY. 52// each still walks the 0035 index that supplies its ORDER BY.
52const dashboardMRsQuery = ` 53const dashboardMRsQuery = `
53 SELECT COALESCE(u.username, o.name) || '/' || r.name, 54 SELECT COALESCE(u.username, o.name) || '/' || r.name,
54 x.number, x.title, au.username, x.state, x.updated_at 55 x.number, x.title, au.username, x.state, x.updated_at,
56 EXISTS (SELECT 1 FROM mr_merge_queue q WHERE q.mr_id = x.id)
55 FROM merge_requests x 57 FROM merge_requests x
56 JOIN repos r ON r.id = x.repo_id 58 JOIN repos r ON r.id = x.repo_id
57 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id 59 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
@@ -69,7 +71,7 @@ func (s *Store) DashboardMRs(userID int64) ([]DashboardItem, error) {
69// DashboardIssues is the issue counterpart of DashboardMRs. 71// DashboardIssues is the issue counterpart of DashboardMRs.
70const dashboardIssuesQuery = ` 72const dashboardIssuesQuery = `
71 SELECT COALESCE(u.username, o.name) || '/' || r.name, 73 SELECT COALESCE(u.username, o.name) || '/' || r.name,
72 x.number, x.title, au.username, x.state, x.updated_at 74 x.number, x.title, au.username, x.state, x.updated_at, 0
73 FROM issues x 75 FROM issues x
74 JOIN repos r ON r.id = x.repo_id 76 JOIN repos r ON r.id = x.repo_id
75 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id 77 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
@@ -136,7 +138,8 @@ func (s *Store) PinnedRepos(userID int64) ([]Repo, error) {
136// current head. 138// current head.
137const reviewQueueQuery = ` 139const reviewQueueQuery = `
138 SELECT COALESCE(u.username, o.name) || '/' || r.name, 140 SELECT COALESCE(u.username, o.name) || '/' || r.name,
139 x.number, x.title, au.username, x.state, x.updated_at 141 x.number, x.title, au.username, x.state, x.updated_at,
142 EXISTS (SELECT 1 FROM mr_merge_queue q WHERE q.mr_id = x.id)
140 FROM merge_requests x 143 FROM merge_requests x
141 JOIN repos r ON r.id = x.repo_id 144 JOIN repos r ON r.id = x.repo_id
142 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id 145 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
@@ -159,7 +162,8 @@ const reviewQueueQuery = `
159// merge_requests table is the whole instance. 162// merge_requests table is the whole instance.
160const requestedReviewsQuery = ` 163const requestedReviewsQuery = `
161 SELECT COALESCE(u.username, o.name) || '/' || r.name, 164 SELECT COALESCE(u.username, o.name) || '/' || r.name,
162 x.number, x.title, au.username, x.state, x.updated_at 165 x.number, x.title, au.username, x.state, x.updated_at,
166 EXISTS (SELECT 1 FROM mr_merge_queue q WHERE q.mr_id = x.id)
163 FROM mr_review_requests rr 167 FROM mr_review_requests rr
164 JOIN merge_requests x ON x.id = rr.mr_id 168 JOIN merge_requests x ON x.id = rr.mr_id
165 JOIN repos r ON r.id = x.repo_id 169 JOIN repos r ON r.id = x.repo_id
@@ -233,7 +237,7 @@ func (s *Store) OpenCounts(repoID int64) (issues, mrs int) {
233// is the whole instance. 237// is the whole instance.
234const assignedIssuesQuery = ` 238const assignedIssuesQuery = `
235 SELECT COALESCE(u.username, o.name) || '/' || r.name, 239 SELECT COALESCE(u.username, o.name) || '/' || r.name,
236 x.number, x.title, au.username, x.state, x.updated_at 240 x.number, x.title, au.username, x.state, x.updated_at, 0
237 FROM issue_assignees ia 241 FROM issue_assignees ia
238 JOIN issues x ON x.id = ia.issue_id 242 JOIN issues x ON x.id = ia.issue_id
239 JOIN repos r ON r.id = x.repo_id 243 JOIN repos r ON r.id = x.repo_id
internal/store/mergequeue.go added +69
@@ -0,0 +1,69 @@
1package store
2
3import (
4 "database/sql"
5 "errors"
6)
7
8// MRByID loads a merge request by its row id, without labels or review
9// requests.
10func (s *Store) MRByID(id int64) (MR, error) {
11 m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.id = ?", id))
12 if errors.Is(err, sql.ErrNoRows) {
13 return m, ErrNotFound
14 }
15 return m, err
16}
17
18// QueueMerge queues a merge request to merge as userID with strategy
19// ("" for the default) once its gates pass. Queueing again replaces the
20// queuer and strategy and clears the recorded reason.
21func (s *Store) QueueMerge(mrID, userID int64, strategy string) error {
22 _, err := s.DB.Exec(`
23 INSERT INTO mr_merge_queue (mr_id, user_id, strategy) VALUES (?, ?, ?)
24 ON CONFLICT (mr_id) DO UPDATE SET user_id = excluded.user_id,
25 strategy = excluded.strategy, reason = '',
26 queued_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
27 mrID, userID, strategy)
28 return err
29}
30
31// DequeueMerge takes a merge request off the queue, reporting whether it
32// was on it.
33func (s *Store) DequeueMerge(mrID int64) (bool, error) {
34 res, err := s.DB.Exec("DELETE FROM mr_merge_queue WHERE mr_id = ?", mrID)
35 if err != nil {
36 return false, err
37 }
38 n, err := res.RowsAffected()
39 return n > 0, err
40}
41
42// SetMergeQueueReason records why the last attempt at a queued merge did
43// not merge.
44func (s *Store) SetMergeQueueReason(mrID int64, reason string) error {
45 _, err := s.DB.Exec("UPDATE mr_merge_queue SET reason = ? WHERE mr_id = ?", reason, mrID)
46 return err
47}
48
49// QueuedMRsAtHead is the queued, open merge requests of a repository
50// whose head is sha: the ones a status reported on sha can move.
51func (s *Store) QueuedMRsAtHead(repoID int64, sha string) ([]int64, error) {
52 rows, err := s.DB.Query(`
53 SELECT m.id FROM mr_merge_queue q JOIN merge_requests m ON m.id = q.mr_id
54 WHERE m.repo_id = ? AND m.head_sha = ? AND m.state IN ('open', 'source_gone')
55 ORDER BY m.number`, repoID, sha)
56 if err != nil {
57 return nil, err
58 }
59 defer rows.Close()
60 var ids []int64
61 for rows.Next() {
62 var id int64
63 if err := rows.Scan(&id); err != nil {
64 return nil, err
65 }
66 ids = append(ids, id)
67 }
68 return ids, rows.Err()
69}
internal/store/mergequeue_test.go added +91
@@ -0,0 +1,91 @@
1package store
2
3import (
4 "slices"
5 "testing"
6)
7
8// A queued merge rides on the merge request row, re-queueing replaces
9// the queuer and strategy and clears the reason, and dequeueing says
10// whether there was anything to take off.
11func TestMergeQueueRoundTrip(t *testing.T) {
12 s, repoID, uid := mrFixture(t)
13 mr, err := s.MRByNumber(repoID, 1)
14 if err != nil {
15 t.Fatal(err)
16 }
17 if mr.QueuedAt != "" {
18 t.Fatalf("fresh MR is queued: %+v", mr)
19 }
20 if err := s.QueueMerge(mr.ID, uid, "ff"); err != nil {
21 t.Fatal(err)
22 }
23 if err := s.SetMergeQueueReason(mr.ID, "checks pending"); err != nil {
24 t.Fatal(err)
25 }
26 mr, _ = s.MRByNumber(repoID, 1)
27 if mr.QueuedAt == "" || mr.QueuedBy != "cmc" || mr.QueuedByID != uid || mr.QueueStrategy != "ff" || mr.QueueReason != "checks pending" {
28 t.Fatalf("queued MR: %+v", mr)
29 }
30 byID, err := s.MRByID(mr.ID)
31 if err != nil || byID.Number != 1 || byID.QueueStrategy != "ff" {
32 t.Fatalf("MRByID = %+v, %v", byID, err)
33 }
34
35 if err := s.QueueMerge(mr.ID, uid, "merge"); err != nil {
36 t.Fatal(err)
37 }
38 mr, _ = s.MRByNumber(repoID, 1)
39 if mr.QueueStrategy != "merge" || mr.QueueReason != "" {
40 t.Fatalf("re-queued MR: %+v", mr)
41 }
42
43 ids, err := s.QueuedMRsAtHead(repoID, "abc123")
44 if err != nil || !slices.Equal(ids, []int64{mr.ID}) {
45 t.Fatalf("QueuedMRsAtHead = %v, %v", ids, err)
46 }
47 if ids, _ := s.QueuedMRsAtHead(repoID, "other"); len(ids) != 0 {
48 t.Fatalf("QueuedMRsAtHead(other) = %v", ids)
49 }
50
51 if ok, err := s.DequeueMerge(mr.ID); err != nil || !ok {
52 t.Fatalf("DequeueMerge = %v, %v", ok, err)
53 }
54 if ok, _ := s.DequeueMerge(mr.ID); ok {
55 t.Fatal("second DequeueMerge found a row")
56 }
57}
58
59// A merge request that leaves the open states leaves the queue with it,
60// whichever path merged or closed it.
61func TestMergeQueueLeftOnMergeOrClose(t *testing.T) {
62 s, repoID, uid := mrFixture(t)
63 mr, _ := s.MRByNumber(repoID, 1)
64 for _, mark := range []func() error{
65 func() error { return s.MarkMerged(mr.ID, "base", uid, "") },
66 func() error { return s.MarkClosed(mr.ID, uid, "") },
67 } {
68 if err := s.SetMRState(mr.ID, "open"); err != nil {
69 t.Fatal(err)
70 }
71 if err := s.QueueMerge(mr.ID, uid, ""); err != nil {
72 t.Fatal(err)
73 }
74 if err := mark(); err != nil {
75 t.Fatal(err)
76 }
77 got, _ := s.MRByNumber(repoID, 1)
78 if got.QueuedAt != "" {
79 t.Fatalf("%s MR still queued: %+v", got.State, got)
80 }
81 }
82 // source_gone keeps it: the branch can come back.
83 if err := s.SetMRState(mr.ID, "open"); err != nil {
84 t.Fatal(err)
85 }
86 s.QueueMerge(mr.ID, uid, "")
87 s.SetMRState(mr.ID, "source_gone")
88 if got, _ := s.MRByNumber(repoID, 1); got.QueuedAt == "" {
89 t.Fatal("source_gone dropped the queued merge")
90 }
91}
internal/store/migrations/0067_merge_queue.down.sql added +2
@@ -0,0 +1,2 @@
1DROP TRIGGER mr_merge_queue_leave;
2DROP TABLE mr_merge_queue;
internal/store/migrations/0067_merge_queue.up.sql added +17
@@ -0,0 +1,17 @@
1-- A merge request queued with `mr merge --when-ready`: merged as user_id
2-- with strategy once its gates pass. reason is why the last attempt did
3-- not merge. A merge request that is merged or closed leaves the queue,
4-- whichever path did it.
5CREATE TABLE mr_merge_queue (
6 mr_id INTEGER PRIMARY KEY REFERENCES merge_requests(id) ON DELETE CASCADE,
7 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8 strategy TEXT NOT NULL DEFAULT '',
9 reason TEXT NOT NULL DEFAULT '',
10 queued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
11);
12CREATE INDEX mr_merge_queue_user ON mr_merge_queue(user_id);
13CREATE TRIGGER mr_merge_queue_leave AFTER UPDATE OF state ON merge_requests
14WHEN NEW.state IN ('merged', 'closed')
15BEGIN
16 DELETE FROM mr_merge_queue WHERE mr_id = NEW.id;
17END;
internal/store/mrs.go +15 −3
@@ -38,6 +38,13 @@ type MR struct {
38 // ReviewRequests is who has been asked, directly, for a review — the 38 // ReviewRequests is who has been asked, directly, for a review — the
39 // mr review request counterpart of Issue.Assignees. 39 // mr review request counterpart of Issue.Assignees.
40 ReviewRequests []string 40 ReviewRequests []string
41 // The queued merge (mr merge --when-ready); QueuedAt is "" when there
42 // is none. QueueReason is why the last attempt did not merge.
43 QueuedByID int64
44 QueuedBy string
45 QueueStrategy string
46 QueueReason string
47 QueuedAt string
41} 48}
42 49
43type MRReview struct { 50type MRReview struct {
@@ -96,7 +103,9 @@ const mrSelect = `
96 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, 103 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
97 COALESCE(ms.title, ''), m.head_sha, 104 COALESCE(ms.title, ''), m.head_sha,
98 m.merged_base, m.merged_at, COALESCE(mu.username, ''), 105 m.merged_base, m.merged_at, COALESCE(mu.username, ''),
99 m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at 106 m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at,
107 COALESCE(q.user_id, 0), COALESCE(qu.username, ''), COALESCE(q.strategy, ''),
108 COALESCE(q.reason, ''), COALESCE(q.queued_at, '')
100 FROM merge_requests m 109 FROM merge_requests m
101 JOIN users u ON u.id = m.author_id 110 JOIN users u ON u.id = m.author_id
102 LEFT JOIN users mu ON mu.id = m.merged_by 111 LEFT JOIN users mu ON mu.id = m.merged_by
@@ -104,13 +113,16 @@ const mrSelect = `
104 LEFT JOIN repos sr ON sr.id = m.source_repo_id 113 LEFT JOIN repos sr ON sr.id = m.source_repo_id
105 LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id 114 LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id
106 LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id 115 LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id
107 LEFT JOIN milestones ms ON ms.id = m.milestone_id` 116 LEFT JOIN milestones ms ON ms.id = m.milestone_id
117 LEFT JOIN mr_merge_queue q ON q.mr_id = m.id
118 LEFT JOIN users qu ON qu.id = q.user_id`
108 119
109func scanMR(row interface{ Scan(...any) error }) (MR, error) { 120func scanMR(row interface{ Scan(...any) error }) (MR, error) {
110 var m MR 121 var m MR
111 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, 122 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
112 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase, 123 &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase,
113 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt) 124 &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt,
125 &m.QueuedByID, &m.QueuedBy, &m.QueueStrategy, &m.QueueReason, &m.QueuedAt)
114 return m, err 126 return m, err
115} 127}
116 128