Commit 8fa927e43a

8fa927e43a16203192794569aa632930098344e9

parent: 93177cdf69

Verified · cmc

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

control: mr merge --when-ready and --cancel

Queued merges are tried when a status is set, a review is submitted, a
thread is resolved, a draft is marked ready, and on a push to the
source branch, as the queuing user with their rights checked then.

Ref #289

Layout: unified · split

internal/control/build.go +2
@@ -805,6 +805,7 @@ func runRunnerDone(c *Ctx, args []string) int {
805805 }
806806 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome,
807807 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
808 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, b.SHA)
808809 // A red build mails the repo's notify targets with the log tail — a
809810 // failed scheduled job must not wait to be noticed.
810811 if outcome == "failure" {
@@ -1037,6 +1038,7 @@ func resolveCancelledCommitStatus(c *Ctx, repo store.Repo, b store.Build) {
10371038 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number)
10381039 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success",
10391040 fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID)
1041 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, b.SHA)
10401042 return
10411043 }
10421044 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number)
internal/control/dashboard.go +6 −4
@@ -40,6 +40,8 @@ type DashboardItem struct {
4040 Author string `json:"author"`
4141 State string `json:"state"`
4242 UpdatedAt string `json:"updated_at"`
43 // Queued marks a merge request with a queued merge.
44 Queued bool `json:"queued,omitempty"`
4345}
4446
4547// PinnedOut is one pinned repository on the dashboard.
@@ -119,7 +121,7 @@ func runDashboard(c *Ctx, args []string) int {
119121 return c.fail(protocol.ExitFailure, "%v", err)
120122 }
121123 for _, m := range mrs {
122 d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
124 d.MRs = append(d.MRs, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
123125 }
124126
125127 reviews, err := c.Store.ReviewQueue(c.User.ID)
@@ -127,7 +129,7 @@ func runDashboard(c *Ctx, args []string) int {
127129 return c.fail(protocol.ExitFailure, "%v", err)
128130 }
129131 for _, m := range reviews {
130 d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt})
132 d.Reviews = append(d.Reviews, DashboardItem{m.RepoPath, m.Number, m.Title, m.Author, m.State, m.UpdatedAt, m.Queued})
131133 }
132134
133135 assigned, err := c.Store.AssignedIssues(c.User.ID)
@@ -135,7 +137,7 @@ func runDashboard(c *Ctx, args []string) int {
135137 return c.fail(protocol.ExitFailure, "%v", err)
136138 }
137139 for _, i := range assigned {
138 d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
140 d.Assigned = append(d.Assigned, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
139141 }
140142
141143 issues, err := c.Store.DashboardIssues(c.User.ID)
@@ -143,7 +145,7 @@ func runDashboard(c *Ctx, args []string) int {
143145 return c.fail(protocol.ExitFailure, "%v", err)
144146 }
145147 for _, i := range issues {
146 d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt})
148 d.Issues = append(d.Issues, DashboardItem{i.RepoPath, i.Number, i.Title, i.Author, i.State, i.UpdatedAt, i.Queued})
147149 }
148150
149151 events, err := c.Store.RecentEvents(c.User.ID, 20, 0)
internal/control/diffcomment.go +3
@@ -255,6 +255,9 @@ func setThreadResolved(c *Ctx, args []string, resolved bool) int {
255255 }
256256 return c.fail(protocol.ExitFailure, "%v", err)
257257 }
258 if resolved {
259 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
260 }
258261 verb := "resolved"
259262 if !resolved {
260263 verb = "reopened"
internal/control/mergequeue.go added +171
@@ -0,0 +1,171 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log/slog"
9 "strconv"
10 "sync"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/policy"
14 "gitbay.org/gitbay/internal/protocol"
15 "gitbay.org/gitbay/internal/store"
16)
17
18// QueuedOut is a merge request's queued merge (mr merge --when-ready):
19// who queued it, the strategy it will use ("" for the default), and why
20// the last attempt did not merge.
21type QueuedOut struct {
22 By string `json:"by"`
23 Strategy string `json:"strategy,omitempty"`
24 Reason string `json:"reason,omitempty"`
25 QueuedAt string `json:"queued_at"`
26}
27
28func queuedOut(m store.MR) *QueuedOut {
29 if m.QueuedAt == "" {
30 return nil
31 }
32 return &QueuedOut{By: m.QueuedBy, Strategy: m.QueueStrategy, Reason: m.QueueReason, QueuedAt: m.QueuedAt}
33}
34
35// mergeQueueMu serialises queued merge attempts, so two triggers landing
36// together cannot both merge one request.
37var mergeQueueMu sync.Mutex
38
39type queueResult struct {
40 merged bool
41 dequeued bool
42 reason string
43 out map[string]any // the merge's output, when merged
44}
45
46// TryQueuedMerge attempts the queued merge of one merge request, if it
47// has one. Called wherever a gate can have changed: a review, a resolved
48// thread, a draft marked ready, a push to the source branch.
49func TryQueuedMerge(st *store.Store, cfg config.Config, mrID int64) {
50 attemptQueuedMerge(st, cfg, mrID)
51}
52
53// TryQueuedMergesAt attempts the queued merges in a repository whose head
54// is sha. Called when a status is reported on sha.
55func TryQueuedMergesAt(st *store.Store, cfg config.Config, repoID int64, sha string) {
56 ids, err := st.QueuedMRsAtHead(repoID, sha)
57 if err != nil {
58 slog.Error("merge queue: listing", "repo", repoID, "err", err)
59 return
60 }
61 for _, id := range ids {
62 attemptQueuedMerge(st, cfg, id)
63 }
64}
65
66// attemptQueuedMerge merges a queued request as the user who queued it,
67// checked against that user's rights now. A merge refused for anything
68// the queuer can fix (unmet gates, a branch behind a require-signed
69// target, a conflict) stays queued with the refusal recorded; a queuer
70// who can no longer merge is dequeued.
71func attemptQueuedMerge(st *store.Store, cfg config.Config, mrID int64) queueResult {
72 mergeQueueMu.Lock()
73 defer mergeQueueMu.Unlock()
74 mr, err := st.MRByID(mrID)
75 if err != nil || mr.QueuedAt == "" {
76 return queueResult{}
77 }
78 repo, err := st.RepoByID(mr.RepoID)
79 if err != nil {
80 return queueResult{}
81 }
82 user, err := st.UserByID(mr.QueuedByID)
83 if err != nil {
84 return queueResult{}
85 }
86 grant, err := st.AccessRole(repo.ID, user.ID)
87 if err != nil {
88 return queueResult{}
89 }
90 switch {
91 case user.Disabled || user.Pending:
92 return dequeueWithReason(st, repo, mr, user, user.Username+"'s account is not active")
93 case !policy.CanWrite(user, repo, grant):
94 return dequeueWithReason(st, repo, mr, user,
95 fmt.Sprintf("%s no longer has write access to %s", user.Username, repo.Path()))
96 }
97
98 var out bytes.Buffer
99 c := &Ctx{User: user, Scope: "full", Source: "queue", Store: st, Cfg: cfg,
100 Stdin: emptyReader{}, Stdout: &out, Stderr: io.Discard, JSON: true}
101 code := mergeMR(c, repo, mr, mr.QueueStrategy)
102 var env struct {
103 Data map[string]any `json:"data"`
104 Error string `json:"error"`
105 }
106 json.Unmarshal(out.Bytes(), &env)
107 if code == protocol.ExitOK {
108 st.Audit(user.ID, "cmd mr merge", map[string]any{
109 "argv": []string{repo.Path(), strconv.FormatInt(mr.Number, 10), "--when-ready"}, "source": "queue"})
110 return queueResult{merged: true, out: env.Data}
111 }
112 st.SetMergeQueueReason(mr.ID, env.Error)
113 return queueResult{reason: env.Error}
114}
115
116func dequeueWithReason(st *store.Store, repo store.Repo, mr store.MR, user store.User, reason string) queueResult {
117 st.DequeueMerge(mr.ID)
118 st.AddMRSystemComment(mr.ID, user.ID, "queued merge by "+user.Username+" cancelled: "+reason)
119 return queueResult{dequeued: true, reason: reason}
120}
121
122// queueMerge is mr merge --when-ready: queue the merge as c.User and try
123// it at once, so gates that already pass merge now.
124func queueMerge(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
125 if code := refuseArchived(c, repo); code >= 0 {
126 return code
127 }
128 if mr.State != "open" && mr.State != "source_gone" {
129 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
130 }
131 // A strategy the repository refuses outright would wait forever.
132 if repo.Settings.RequireSignedCommits && (strategy == "merge" || strategy == "squash") {
133 return c.fail(protocol.ExitDenied,
134 "%s requires signed commits, so only fast-forward merges are allowed; queue without --strategy or with --strategy ff", repo.Path())
135 }
136 if err := c.Store.QueueMerge(mr.ID, c.User.ID, strategy); err != nil {
137 return c.fail(protocol.ExitFailure, "%v", err)
138 }
139 res := attemptQueuedMerge(c.Store, c.Cfg, mr.ID)
140 switch {
141 case res.merged:
142 return c.emit(res.out, func(w io.Writer) {
143 fmt.Fprintf(w, "merged %s!%d into %s (%v) at %.10v\n", repo.Path(), mr.Number, mr.TargetRef, res.out["strategy"], res.out["sha"])
144 })
145 case res.dequeued:
146 return c.fail(protocol.ExitDenied, "%s", res.reason)
147 }
148 note := ""
149 if strategy != "" {
150 note = " (" + strategy + ")"
151 }
152 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("%s queued the merge%s for when the gates pass", c.User.Username, note))
153 return c.emit(map[string]any{"number": mr.Number, "queued": true, "strategy": strategy, "reason": res.reason}, func(w io.Writer) {
154 fmt.Fprintf(w, "queued %s!%d to merge when ready; waiting: %s\n", repo.Path(), mr.Number, res.reason)
155 })
156}
157
158// cancelQueuedMerge is mr merge --cancel.
159func cancelQueuedMerge(c *Ctx, repo store.Repo, mr store.MR) int {
160 ok, err := c.Store.DequeueMerge(mr.ID)
161 if err != nil {
162 return c.fail(protocol.ExitFailure, "%v", err)
163 }
164 if !ok {
165 return c.fail(protocol.ExitFailure, "!%d is not queued to merge", mr.Number)
166 }
167 c.Store.AddMRSystemComment(mr.ID, c.User.ID, c.User.Username+" cancelled the queued merge")
168 return c.emit(map[string]any{"number": mr.Number, "queued": false}, func(w io.Writer) {
169 fmt.Fprintf(w, "cancelled the queued merge of %s!%d\n", repo.Path(), mr.Number)
170 })
171}
internal/control/mergequeue_test.go added +351
@@ -0,0 +1,351 @@
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "testing"
11
12 "gitbay.org/gitbay/internal/config"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17// queueFixture is a repository on disk with one merge request, !1,
18// feature into main, whose head is one commit ahead of main.
19type queueFixture struct {
20 t *testing.T
21 st *store.Store
22 repo store.Repo
23 alice store.User // the owner
24 root string
25 dir string
26 src string
27 git func(dir string, args ...string) string
28 headSHA string
29 targetSH string
30}
31
32func newQueueFixture(t *testing.T, set func(*store.RepoSettings)) *queueFixture {
33 t.Helper()
34 st, repo, uid := newQueueTestRepo(t)
35 if set != nil {
36 if _, err := st.UpdateRepoSettings(repo.ID, set); err != nil {
37 t.Fatal(err)
38 }
39 var err error
40 if repo, err = st.RepoByID(repo.ID); err != nil {
41 t.Fatal(err)
42 }
43 }
44 f := &queueFixture{t: t, st: st, repo: repo, alice: store.User{ID: uid, Username: "alice"},
45 root: t.TempDir(), git: gitRunner(t)}
46 f.src = filepath.Join(f.root, "src")
47 f.git(f.root, "init", "-q", "-b", "main", "src")
48 f.write("README", "x\n")
49 f.git(f.src, "add", ".")
50 f.git(f.src, "commit", "-q", "-m", "base")
51 f.targetSH = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
52 f.git(f.src, "checkout", "-q", "-b", "feature")
53 f.write("feature.txt", "y\n")
54 f.git(f.src, "add", ".")
55 f.git(f.src, "commit", "-q", "-m", "change")
56 f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
57
58 f.dir = RepoDir(f.root, repo.OwnerName, repo.Name)
59 os.MkdirAll(filepath.Dir(f.dir), 0o755)
60 f.git(f.root, "clone", "-q", "--bare", f.src, f.dir)
61 f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA)
62 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", f.headSHA, "md", false); err != nil {
63 t.Fatal(err)
64 }
65 return f
66}
67
68func (f *queueFixture) write(name, body string) {
69 f.t.Helper()
70 if err := os.WriteFile(filepath.Join(f.src, name), []byte(body), 0o644); err != nil {
71 f.t.Fatal(err)
72 }
73}
74
75// run dispatches argv as u, returning the exit code and stderr.
76func (f *queueFixture) run(u store.User, argv ...string) (int, string, string) {
77 f.t.Helper()
78 var out, errOut bytes.Buffer
79 c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut}
80 c.Cfg.Server.Root = f.root
81 code := Dispatch(c, argv)
82 return code, out.String(), errOut.String()
83}
84
85func (f *queueFixture) mustRun(u store.User, argv ...string) string {
86 f.t.Helper()
87 code, out, errOut := f.run(u, argv...)
88 if code != protocol.ExitOK {
89 f.t.Fatalf("%v: exit %d, %s", argv, code, errOut)
90 }
91 return out
92}
93
94func (f *queueFixture) mr() store.MR {
95 f.t.Helper()
96 mr, err := f.st.MRByNumber(f.repo.ID, 1)
97 if err != nil {
98 f.t.Fatal(err)
99 }
100 return mr
101}
102
103// user creates an account granted role on the repository.
104func (f *queueFixture) user(name, role string) store.User {
105 f.t.Helper()
106 id, err := f.st.CreateUser(name, false)
107 if err != nil {
108 f.t.Fatal(err)
109 }
110 if role != "" {
111 if err := f.st.GrantAccess(f.repo.ID, id, role); err != nil {
112 f.t.Fatal(err)
113 }
114 }
115 u, err := f.st.UserByID(id)
116 if err != nil {
117 f.t.Fatal(err)
118 }
119 return u
120}
121
122// systemComments is every system comment on !1, joined.
123func (f *queueFixture) systemComments() string {
124 f.t.Helper()
125 cs, err := f.st.ListMRComments(f.mr().ID)
126 if err != nil {
127 f.t.Fatal(err)
128 }
129 var b strings.Builder
130 for _, c := range cs {
131 if c.Kind == "system" {
132 b.WriteString(c.Body + "\n")
133 }
134 }
135 return b.String()
136}
137
138func (f *queueFixture) wantMergedBy(who string) {
139 f.t.Helper()
140 mr := f.mr()
141 if mr.State != "merged" || mr.MergedBy != who || mr.QueuedAt != "" {
142 f.t.Fatalf("MR = state %s merged_by %q queued_at %q, want merged by %s and off the queue",
143 mr.State, mr.MergedBy, mr.QueuedAt, who)
144 }
145 if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != mr.HeadSHA {
146 f.t.Fatalf("main = %s, want the MR head %s", main, mr.HeadSHA)
147 }
148}
149
150func (f *queueFixture) wantQueued(reason string) store.MR {
151 f.t.Helper()
152 mr := f.mr()
153 if mr.State != "open" || mr.QueuedAt == "" || !strings.Contains(mr.QueueReason, reason) {
154 f.t.Fatalf("MR = state %s queued_at %q reason %q, want open and queued with %q",
155 mr.State, mr.QueuedAt, mr.QueueReason, reason)
156 }
157 return mr
158}
159
160// Gates that already pass merge at once: queueing is only for waiting.
161func TestWhenReadyMergesAtOnceWhenGatesPass(t *testing.T) {
162 f := newQueueFixture(t, nil)
163 out := f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
164 if !strings.Contains(out, "merged") {
165 t.Fatalf("output = %q, want it to say merged", out)
166 }
167 f.wantMergedBy("alice")
168}
169
170// A status that turns the checks green merges the queued request, as the
171// user who queued it, and mr show carries the queue until then.
172func TestWhenReadyStatusSetMerges(t *testing.T) {
173 f := newQueueFixture(t, func(s *store.RepoSettings) {
174 s.RequireChecks = true
175 s.RequiredContexts = []string{"ext/test"}
176 })
177 bob := f.user("bob", "write")
178 out := f.mustRun(bob, "mr", "merge", f.repo.Path(), "1", "--when-ready", "--strategy", "ff")
179 if !strings.Contains(out, "queued") {
180 t.Fatalf("output = %q, want it to say queued", out)
181 }
182 f.wantQueued("green checks")
183 got := mrShowJSONAt(t, f, f.alice)
184 if got.Queued == nil || got.Queued.By != "bob" || got.Queued.Strategy != "ff" || !strings.Contains(got.Queued.Reason, "green checks") {
185 t.Fatalf("mr show queued = %+v", got.Queued)
186 }
187
188 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "pending")
189 f.wantQueued("green checks")
190 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success")
191 f.wantMergedBy("bob")
192}
193
194func mrShowJSONAt(t *testing.T, f *queueFixture, u store.User) mrOut {
195 t.Helper()
196 var out, errOut bytes.Buffer
197 c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut}
198 c.Cfg.Server.Root = f.root
199 if code := Dispatch(c, []string{"mr", "show", f.repo.Path(), "1", "--json"}); code != protocol.ExitOK {
200 t.Fatalf("mr show: exit %d, %s", code, errOut.String())
201 }
202 var env struct {
203 Data mrOut `json:"data"`
204 }
205 if err := json.Unmarshal(out.Bytes(), &env); err != nil {
206 t.Fatalf("mr show JSON: %v\n%s", err, out.String())
207 }
208 return env.Data
209}
210
211// An approval that meets require_approvals merges the queued request.
212func TestWhenReadyReviewMerges(t *testing.T) {
213 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
214 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
215 f.wantQueued("approval")
216 bob := f.user("bob", "write")
217 f.mustRun(bob, "mr", "review", f.repo.Path(), "1", "--approve")
218 f.wantMergedBy("alice")
219}
220
221// Resolving the last open thread merges the queued request.
222func TestWhenReadyThreadResolveMerges(t *testing.T) {
223 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireResolved = true })
224 id, err := f.st.AddDiffComment(f.mr().ID, f.alice.ID, f.headSHA, "feature.txt", "new", 1, "why?", 0, false)
225 if err != nil {
226 t.Fatal(err)
227 }
228 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
229 f.wantQueued("threads resolved")
230 f.mustRun(f.alice, "mr", "resolve", f.repo.Path(), "1", strconv.FormatInt(id, 10))
231 f.wantMergedBy("alice")
232}
233
234// Marking a draft ready merges the queued request.
235func TestWhenReadyReadyMerges(t *testing.T) {
236 f := newQueueFixture(t, nil)
237 f.st.SetMRDraft(f.mr().ID, true)
238 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
239 f.wantQueued("draft")
240 f.mustRun(f.alice, "mr", "ready", f.repo.Path(), "1")
241 f.wantMergedBy("alice")
242}
243
244// A new head keeps the request queued and has to pass on its own: a
245// status on the old head moves nothing.
246func TestWhenReadyNewHeadMustPassAgain(t *testing.T) {
247 f := newQueueFixture(t, func(s *store.RepoSettings) {
248 s.RequireChecks = true
249 s.RequiredContexts = []string{"ext/test"}
250 })
251 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
252 old := f.headSHA
253 f.write("feature.txt", "z\n")
254 f.git(f.src, "commit", "-q", "-am", "more")
255 f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
256 f.git(f.src, "push", "-q", f.dir, "feature")
257 f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA)
258 if err := f.st.UpdateMRHead(f.mr().ID, f.headSHA, f.targetSH, false); err != nil {
259 t.Fatal(err)
260 }
261 TryQueuedMerge(f.st, f.cfg(), f.mr().ID)
262 f.wantQueued("green checks")
263 f.mustRun(f.alice, "status", "set", f.repo.Path(), old, "--context", "ext/test", "--state", "success")
264 f.wantQueued("green checks")
265 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success")
266 f.wantMergedBy("alice")
267}
268
269func (f *queueFixture) cfg() (c config.Config) {
270 c.Server.Root = f.root
271 return c
272}
273
274// On a require-signed repository the server cannot rebase: a branch
275// behind its target stays queued and says it needs a rebase.
276func TestWhenReadySignedBehindStaysQueued(t *testing.T) {
277 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireSignedCommits = true })
278 f.git(f.src, "checkout", "-q", "main")
279 f.write("other.txt", "o\n")
280 f.git(f.src, "add", ".")
281 f.git(f.src, "commit", "-q", "-m", "target moves")
282 f.git(f.src, "push", "-q", f.dir, "main")
283 out := f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
284 if !strings.Contains(out, "rebase") {
285 t.Fatalf("output = %q, want the pending reason to name the rebase", out)
286 }
287 f.wantQueued("rebase")
288
289 // A strategy that cannot ever pass there is refused, not queued.
290 code, _, errOut := f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready", "--strategy", "squash")
291 if code != protocol.ExitDenied || !strings.Contains(errOut, "signed") {
292 t.Fatalf("squash on require-signed: exit %d, %s", code, errOut)
293 }
294}
295
296// A queuer who lost write access is dequeued with the reason recorded,
297// and nothing merges.
298func TestWhenReadyRightsLossDequeues(t *testing.T) {
299 f := newQueueFixture(t, func(s *store.RepoSettings) {
300 s.RequireChecks = true
301 s.RequiredContexts = []string{"ext/test"}
302 })
303 bob := f.user("bob", "write")
304 f.mustRun(bob, "mr", "merge", f.repo.Path(), "1", "--when-ready")
305 f.wantQueued("green checks")
306 if err := f.st.RevokeAccess(f.repo.ID, bob.ID); err != nil {
307 t.Fatal(err)
308 }
309 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success")
310 mr := f.mr()
311 if mr.State != "open" || mr.QueuedAt != "" {
312 t.Fatalf("MR = state %s queued_at %q, want open and dequeued", mr.State, mr.QueuedAt)
313 }
314 if sys := f.systemComments(); !strings.Contains(sys, "bob no longer has write access") {
315 t.Fatalf("system comments = %q, want the dequeue reason", sys)
316 }
317}
318
319// --cancel dequeues; a second cancel has nothing to take off.
320func TestWhenReadyCancel(t *testing.T) {
321 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
322 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
323 f.wantQueued("approval")
324 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel")
325 if mr := f.mr(); mr.QueuedAt != "" {
326 t.Fatalf("cancel left the MR queued: %+v", mr)
327 }
328 code, _, errOut := f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel")
329 if code != protocol.ExitFailure || !strings.Contains(errOut, "not queued") {
330 t.Fatalf("second cancel: exit %d, %s", code, errOut)
331 }
332 code, _, _ = f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel", "--when-ready")
333 if code != protocol.ExitUsage {
334 t.Fatalf("--cancel --when-ready: exit %d, want usage", code)
335 }
336 // An approval after the cancel merges nothing.
337 f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve")
338 if mr := f.mr(); mr.State != "open" {
339 t.Fatalf("cancelled MR merged: %+v", mr)
340 }
341}
342
343// Closing a queued merge request dequeues it.
344func TestWhenReadyCloseDequeues(t *testing.T) {
345 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
346 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
347 f.mustRun(f.alice, "mr", "close", f.repo.Path(), "1")
348 if mr := f.mr(); mr.State != "closed" || mr.QueuedAt != "" {
349 t.Fatalf("closed MR = %+v, want closed and dequeued", mr)
350 }
351}
internal/control/mr.go +40 −4
@@ -188,11 +188,13 @@ func init() {
188188 Run: runMRLabel})
189189 register(Command{Path: []string{"mr", "merge"},
190190 Summary: "merge",
191 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]",
191 Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase] [--when-ready | --cancel]",
192192 Flags: []Flag{
193193 {"--strategy", "ff|merge|squash|rebase", "how to merge", ""},
194 {"--when-ready", "", "queue the merge until the gates pass; merges now if they already do", ""},
195 {"--cancel", "", "take a queued merge off the queue", ""},
194196 },
195 Examples: []string{"mr merge krz/gitbay 431 --strategy ff"},
197 Examples: []string{"mr merge krz/gitbay 431 --strategy ff", "mr merge krz/gitbay 431 --when-ready", "mr merge krz/gitbay 431 --cancel"},
196198 Run: runMRMerge})
197199 register(Command{Path: []string{"mr", "close"},
198200 Summary: "close without merging",
@@ -560,6 +562,8 @@ type mrOut struct {
560562 // SupersededBy is the merge request, by number, this one was closed
561563 // in favour of. 0 means none.
562564 SupersededBy int64 `json:"superseded_by,omitempty"`
565 // Queued is the merge queued with mr merge --when-ready, if any.
566 Queued *QueuedOut `json:"queued,omitempty"`
563567}
564568
565569type stackRef struct {
@@ -605,7 +609,8 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
605609 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
606610 Labels: m.Labels, ReviewRequests: m.ReviewRequests,
607611 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy,
608 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy}
612 ClosedAt: m.ClosedAt, ClosedBy: m.ClosedBy, SupersededBy: m.SupersededBy,
613 Queued: queuedOut(m)}
609614 if withBody {
610615 o.Body = m.Body
611616 o.BodyFormat = m.BodyFormat
@@ -795,6 +800,15 @@ func runMRShow(c *Ctx, args []string) int {
795800 gates = fmt.Sprintf("%d unmet; %s", len(g.Unmet), ff)
796801 }
797802 }
803 queued, waiting := "", ""
804 if q := d.Queued; q != nil {
805 queued = "by " + q.By
806 if q.Strategy != "" {
807 queued += " (" + q.Strategy + ")"
808 }
809 queued += ", " + c.when(q.QueuedAt)
810 waiting = q.Reason
811 }
798812 unresolved := ""
799813 if d.UnresolvedThreads > 0 {
800814 unresolved = fmt.Sprintf("%d", d.UnresolvedThreads)
@@ -815,6 +829,8 @@ func runMRShow(c *Ctx, args []string) int {
815829 "merged", merged,
816830 "closed", closed,
817831 "superseded by", superseded,
832 "queued to merge", queued,
833 "waiting on", waiting,
818834 "unresolved threads", unresolved,
819835 "gates", gates,
820836 }
@@ -1108,6 +1124,7 @@ func runMRReview(c *Ctx, args []string) int {
11081124 }
11091125 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed",
11101126 fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict))
1127 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
11111128 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
11121129 notify(c, parts, notice{repo: repo, kind: "mr",
11131130 subject: mrSubject(repo, mr.Number, mr.Title),
@@ -1258,7 +1275,8 @@ func runMRLabel(c *Ctx, args []string) int {
12581275}
12591276
12601277func runMRMerge(c *Ctx, args []string) int {
1261 f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, MaxPos: -1, Usage: "mr merge <owner/name> <n> [--strategy ff|merge|squash|rebase]"})
1278 f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, Bools: []string{"--when-ready", "--cancel"},
1279 MaxPos: -1, Usage: c.Cmd.Usage})
12621280 if err != nil {
12631281 return c.fail(protocol.ExitUsage, "%v", err)
12641282 }
@@ -1267,10 +1285,25 @@ func runMRMerge(c *Ctx, args []string) int {
12671285 if !valid[strategy] {
12681286 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
12691287 }
1288 if f.Has("--cancel") && (f.Has("--when-ready") || f.Has("--strategy")) {
1289 return c.fail(protocol.ExitUsage, "--cancel takes no other flag")
1290 }
12701291 repo, mr, code := mrRef(c, rest, policy.CanWrite)
12711292 if code >= 0 {
12721293 return code
12731294 }
1295 switch {
1296 case f.Has("--cancel"):
1297 return cancelQueuedMerge(c, repo, mr)
1298 case f.Has("--when-ready"):
1299 return queueMerge(c, repo, mr, strategy)
1300 }
1301 return mergeMR(c, repo, mr, strategy)
1302}
1303
1304// mergeMR merges mr now as c.User, or refuses saying why. The queue runs
1305// it too, as the user who queued the merge.
1306func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
12741307 if code := refuseArchived(c, repo); code >= 0 {
12751308 return code
12761309 }
@@ -1819,6 +1852,9 @@ func setMRDraft(c *Ctx, args []string, draft bool) int {
18191852 }
18201853 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft",
18211854 fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft))
1855 if !draft {
1856 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
1857 }
18221858 // Marking ready is the request for review; going back to draft
18231859 // withdraws it and is not worth anyone's inbox.
18241860 //
internal/control/status.go +1
@@ -98,6 +98,7 @@ func runStatusSet(c *Ctx, args []string) int {
9898 }
9999 c.Store.RecordEvent(repo.ID, c.User.ID, "status",
100100 fmt.Sprintf(`{"sha":%q,"context":%q,"state":%q}`, full, context, state))
101 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, full)
101102 return c.emit(map[string]string{"sha": full, "context": context, "state": state}, func(w io.Writer) {
102103 fmt.Fprintf(w, "%s on %.10s: %s\n", context, full, state)
103104 })
internal/hookd/hookd.go +5
@@ -362,6 +362,11 @@ func (s *Server) postReceive(req Request) {
362362 if mr.State == "source_gone" {
363363 s.st.SetMRState(mr.ID, "open") // branch came back
364364 }
365 // A queued merge stays queued across a push; the new head has
366 // to pass the gates on its own.
367 if mr.QueuedAt != "" {
368 control.TryQueuedMerge(s.st, s.cfg, mr.ID)
369 }
365370 }
366371 }
367372}
internal/hookd/mergequeue_test.go added +96
@@ -0,0 +1,96 @@
1package hookd
2
3import (
4 "bytes"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "gitbay.org/gitbay/internal/config"
11 "gitbay.org/gitbay/internal/control"
12 "gitbay.org/gitbay/internal/policy"
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17// A push to the source branch of a queued merge request tries the merge
18// again: a fast-forward merge queued while the branch was behind lands
19// once the rebased branch is pushed.
20func TestPostReceiveTriesQueuedMerge(t *testing.T) {
21 st, err := store.Open(":memory:")
22 if err != nil {
23 t.Fatal(err)
24 }
25 t.Cleanup(func() { st.Close() })
26 if err := st.MigrateUp(); err != nil {
27 t.Fatal(err)
28 }
29 uid, err := st.CreateUser("alice", false)
30 if err != nil {
31 t.Fatal(err)
32 }
33 repoID, err := st.CreateRepo("user", uid, "app", "public")
34 if err != nil {
35 t.Fatal(err)
36 }
37 repo, err := st.RepoByID(repoID)
38 if err != nil {
39 t.Fatal(err)
40 }
41 root := t.TempDir()
42 f := &shapeFixture{t: t, st: st, repo: repo, uid: uid, root: root, src: filepath.Join(root, "src")}
43 f.dir = control.RepoDir(root, repo.OwnerName, repo.Name)
44 cfg := config.Config{}
45 cfg.Server.Root, cfg.Server.SiteURL = root, "https://x.test"
46 srv := &Server{cfg: cfg, st: st}
47
48 os.MkdirAll(f.src, 0o755)
49 f.git(root, "init", "-q", "-b", "main", "src")
50 f.write("README", "x\n")
51 f.git(f.src, "add", ".")
52 f.git(f.src, "commit", "-q", "-m", "base")
53 f.git(f.src, "checkout", "-q", "-b", "feature")
54 f.write("feature.txt", "y\n")
55 f.git(f.src, "add", ".")
56 f.git(f.src, "commit", "-q", "-m", "change")
57 head := f.sha("HEAD")
58 f.git(f.src, "checkout", "-q", "main")
59 f.write("other.txt", "o\n")
60 f.git(f.src, "add", ".")
61 f.git(f.src, "commit", "-q", "-m", "target moves")
62 os.MkdirAll(filepath.Dir(f.dir), 0o755)
63 f.git(root, "init", "-q", "--bare", f.dir)
64 f.sync()
65 f.git(f.dir, "update-ref", "refs/merge-requests/1/head", head)
66 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", head, "md", false); err != nil {
67 t.Fatal(err)
68 }
69
70 var out, errOut bytes.Buffer
71 c := &control.Ctx{User: store.User{ID: uid, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut}
72 if code := control.Dispatch(c, []string{"mr", "merge", repo.Path(), "1", "--when-ready", "--strategy", "ff"}); code != protocol.ExitOK {
73 t.Fatalf("mr merge --when-ready: exit %d, %s", code, errOut.String())
74 }
75 if mr, _ := st.MRByNumber(repo.ID, 1); mr.QueuedAt == "" || !strings.Contains(mr.QueueReason, "fast-forward not possible") {
76 t.Fatalf("queued MR = %+v, want it waiting on a fast-forward", mr)
77 }
78
79 f.git(f.src, "checkout", "-q", "feature")
80 f.git(f.src, "rebase", "-q", "main")
81 rebased := f.sha("HEAD")
82 f.sync()
83 srv.postReceive(Request{RepoID: repo.ID, UserID: uid, Scope: "full", Updates: []policy.RefUpdate{
84 {Ref: "refs/heads/feature", Old: head, New: rebased, IsForce: true}}})
85
86 mr, err := st.MRByNumber(repo.ID, 1)
87 if err != nil {
88 t.Fatal(err)
89 }
90 if mr.State != "merged" || mr.MergedBy != "alice" || mr.QueuedAt != "" {
91 t.Fatalf("MR after push = %s by %q queued %q (%s), want merged by alice", mr.State, mr.MergedBy, mr.QueuedAt, mr.QueueReason)
92 }
93 if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != rebased {
94 t.Fatalf("main = %s, want %s", main, rebased)
95 }
96}