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 {
1010 Author string
1111 State string
1212 UpdatedAt string
13 Queued bool // a merge request with a queued merge
1314}
1415
1516// 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)
3940 var out []DashboardItem
4041 for rows.Next() {
4142 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 {
4344 return nil, err
4445 }
4546 out = append(out, d)
@@ -51,7 +52,8 @@ func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error)
5152// each still walks the 0035 index that supplies its ORDER BY.
5253const dashboardMRsQuery = `
5354 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)
5557 FROM merge_requests x
5658 JOIN repos r ON r.id = x.repo_id
5759 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) {
6971// DashboardIssues is the issue counterpart of DashboardMRs.
7072const dashboardIssuesQuery = `
7173 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
7375 FROM issues x
7476 JOIN repos r ON r.id = x.repo_id
7577 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) {
136138// current head.
137139const reviewQueueQuery = `
138140 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)
140143 FROM merge_requests x
141144 JOIN repos r ON r.id = x.repo_id
142145 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
@@ -159,7 +162,8 @@ const reviewQueueQuery = `
159162// merge_requests table is the whole instance.
160163const requestedReviewsQuery = `
161164 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)
163167 FROM mr_review_requests rr
164168 JOIN merge_requests x ON x.id = rr.mr_id
165169 JOIN repos r ON r.id = x.repo_id
@@ -233,7 +237,7 @@ func (s *Store) OpenCounts(repoID int64) (issues, mrs int) {
233237// is the whole instance.
234238const assignedIssuesQuery = `
235239 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
237241 FROM issue_assignees ia
238242 JOIN issues x ON x.id = ia.issue_id
239243 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 {
3838 // ReviewRequests is who has been asked, directly, for a review — the
3939 // mr review request counterpart of Issue.Assignees.
4040 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
4148}
4249
4350type MRReview struct {
@@ -96,7 +103,9 @@ const mrSelect = `
96103 m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft,
97104 COALESCE(ms.title, ''), m.head_sha,
98105 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, '')
100109 FROM merge_requests m
101110 JOIN users u ON u.id = m.author_id
102111 LEFT JOIN users mu ON mu.id = m.merged_by
@@ -104,13 +113,16 @@ const mrSelect = `
104113 LEFT JOIN repos sr ON sr.id = m.source_repo_id
105114 LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id
106115 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`
108119
109120func scanMR(row interface{ Scan(...any) error }) (MR, error) {
110121 var m MR
111122 err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath,
112123 &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)
114126 return m, err
115127}
116128