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 {
805 } 805 }
806 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome, 806 c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome,
807 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA)) 807 fmt.Sprintf(`{"number":%d,"job":%q,"sha":%q}`, b.Number, b.Job, b.SHA))
808 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, b.SHA)
808 // A red build mails the repo's notify targets with the log tail — a 809 // A red build mails the repo's notify targets with the log tail — a
809 // failed scheduled job must not wait to be noticed. 810 // failed scheduled job must not wait to be noticed.
810 if outcome == "failure" { 811 if outcome == "failure" {
@@ -1037,6 +1038,7 @@ func resolveCancelledCommitStatus(c *Ctx, repo store.Repo, b store.Build) {
1037 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number) 1038 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number)
1038 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success", 1039 c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success",
1039 fmt.Sprintf("passed in build %d on %s", prev.Number, prev.Ref), url, c.User.ID) 1040 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)
1040 return 1042 return
1041 } 1043 }
1042 url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), b.Number) 1044 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 {
40 Author string `json:"author"` 40 Author string `json:"author"`
41 State string `json:"state"` 41 State string `json:"state"`
42 UpdatedAt string `json:"updated_at"` 42 UpdatedAt string `json:"updated_at"`
43 // Queued marks a merge request with a queued merge.
44 Queued bool `json:"queued,omitempty"`
43} 45}
44 46
45// PinnedOut is one pinned repository on the dashboard. 47// PinnedOut is one pinned repository on the dashboard.
@@ -119,7 +121,7 @@ func runDashboard(c *Ctx, args []string) int {
119 return c.fail(protocol.ExitFailure, "%v", err) 121 return c.fail(protocol.ExitFailure, "%v", err)
120 } 122 }
121 for _, m := range mrs { 123 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})
123 } 125 }
124 126
125 reviews, err := c.Store.ReviewQueue(c.User.ID) 127 reviews, err := c.Store.ReviewQueue(c.User.ID)
@@ -127,7 +129,7 @@ func runDashboard(c *Ctx, args []string) int {
127 return c.fail(protocol.ExitFailure, "%v", err) 129 return c.fail(protocol.ExitFailure, "%v", err)
128 } 130 }
129 for _, m := range reviews { 131 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})
131 } 133 }
132 134
133 assigned, err := c.Store.AssignedIssues(c.User.ID) 135 assigned, err := c.Store.AssignedIssues(c.User.ID)
@@ -135,7 +137,7 @@ func runDashboard(c *Ctx, args []string) int {
135 return c.fail(protocol.ExitFailure, "%v", err) 137 return c.fail(protocol.ExitFailure, "%v", err)
136 } 138 }
137 for _, i := range assigned { 139 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})
139 } 141 }
140 142
141 issues, err := c.Store.DashboardIssues(c.User.ID) 143 issues, err := c.Store.DashboardIssues(c.User.ID)
@@ -143,7 +145,7 @@ func runDashboard(c *Ctx, args []string) int {
143 return c.fail(protocol.ExitFailure, "%v", err) 145 return c.fail(protocol.ExitFailure, "%v", err)
144 } 146 }
145 for _, i := range issues { 147 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})
147 } 149 }
148 150
149 events, err := c.Store.RecentEvents(c.User.ID, 20, 0) 151 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 {
255 } 255 }
256 return c.fail(protocol.ExitFailure, "%v", err) 256 return c.fail(protocol.ExitFailure, "%v", err)
257 } 257 }
258 if resolved {
259 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
260 }
258 verb := "resolved" 261 verb := "resolved"
259 if !resolved { 262 if !resolved {
260 verb = "reopened" 263 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() {
188 Run: runMRLabel}) 188 Run: runMRLabel})
189 register(Command{Path: []string{"mr", "merge"}, 189 register(Command{Path: []string{"mr", "merge"},
190 Summary: "merge", 190 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]",
192 Flags: []Flag{ 192 Flags: []Flag{
193 {"--strategy", "ff|merge|squash|rebase", "how to merge", ""}, 193 {"--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", ""},
194 }, 196 },
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"},
196 Run: runMRMerge}) 198 Run: runMRMerge})
197 register(Command{Path: []string{"mr", "close"}, 199 register(Command{Path: []string{"mr", "close"},
198 Summary: "close without merging", 200 Summary: "close without merging",
@@ -560,6 +562,8 @@ type mrOut struct {
560 // SupersededBy is the merge request, by number, this one was closed 562 // SupersededBy is the merge request, by number, this one was closed
561 // in favour of. 0 means none. 563 // in favour of. 0 means none.
562 SupersededBy int64 `json:"superseded_by,omitempty"` 564 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"`
563} 567}
564 568
565type stackRef struct { 569type stackRef struct {
@@ -605,7 +609,8 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut {
605 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone, 609 Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone,
606 Labels: m.Labels, ReviewRequests: m.ReviewRequests, 610 Labels: m.Labels, ReviewRequests: m.ReviewRequests,
607 CreatedAt: m.CreatedAt, MergedAt: m.MergedAt, MergedBy: m.MergedBy, 611 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)}
609 if withBody { 614 if withBody {
610 o.Body = m.Body 615 o.Body = m.Body
611 o.BodyFormat = m.BodyFormat 616 o.BodyFormat = m.BodyFormat
@@ -795,6 +800,15 @@ func runMRShow(c *Ctx, args []string) int {
795 gates = fmt.Sprintf("%d unmet; %s", len(g.Unmet), ff) 800 gates = fmt.Sprintf("%d unmet; %s", len(g.Unmet), ff)
796 } 801 }
797 } 802 }
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 }
798 unresolved := "" 812 unresolved := ""
799 if d.UnresolvedThreads > 0 { 813 if d.UnresolvedThreads > 0 {
800 unresolved = fmt.Sprintf("%d", d.UnresolvedThreads) 814 unresolved = fmt.Sprintf("%d", d.UnresolvedThreads)
@@ -815,6 +829,8 @@ func runMRShow(c *Ctx, args []string) int {
815 "merged", merged, 829 "merged", merged,
816 "closed", closed, 830 "closed", closed,
817 "superseded by", superseded, 831 "superseded by", superseded,
832 "queued to merge", queued,
833 "waiting on", waiting,
818 "unresolved threads", unresolved, 834 "unresolved threads", unresolved,
819 "gates", gates, 835 "gates", gates,
820 } 836 }
@@ -1108,6 +1124,7 @@ func runMRReview(c *Ctx, args []string) int {
1108 } 1124 }
1109 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed", 1125 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed",
1110 fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict)) 1126 fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict))
1127 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
1111 if parts, err := c.Store.MRParticipants(mr.ID); err == nil { 1128 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
1112 notify(c, parts, notice{repo: repo, kind: "mr", 1129 notify(c, parts, notice{repo: repo, kind: "mr",
1113 subject: mrSubject(repo, mr.Number, mr.Title), 1130 subject: mrSubject(repo, mr.Number, mr.Title),
@@ -1258,7 +1275,8 @@ func runMRLabel(c *Ctx, args []string) int {
1258} 1275}
1259 1276
1260func runMRMerge(c *Ctx, args []string) int { 1277func 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})
1262 if err != nil { 1280 if err != nil {
1263 return c.fail(protocol.ExitUsage, "%v", err) 1281 return c.fail(protocol.ExitUsage, "%v", err)
1264 } 1282 }
@@ -1267,10 +1285,25 @@ func runMRMerge(c *Ctx, args []string) int {
1267 if !valid[strategy] { 1285 if !valid[strategy] {
1268 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase") 1286 return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase")
1269 } 1287 }
1288 if f.Has("--cancel") && (f.Has("--when-ready") || f.Has("--strategy")) {
1289 return c.fail(protocol.ExitUsage, "--cancel takes no other flag")
1290 }
1270 repo, mr, code := mrRef(c, rest, policy.CanWrite) 1291 repo, mr, code := mrRef(c, rest, policy.CanWrite)
1271 if code >= 0 { 1292 if code >= 0 {
1272 return code 1293 return code
1273 } 1294 }
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 {
1274 if code := refuseArchived(c, repo); code >= 0 { 1307 if code := refuseArchived(c, repo); code >= 0 {
1275 return code 1308 return code
1276 } 1309 }
@@ -1819,6 +1852,9 @@ func setMRDraft(c *Ctx, args []string, draft bool) int {
1819 } 1852 }
1820 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft", 1853 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft",
1821 fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft)) 1854 fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft))
1855 if !draft {
1856 TryQueuedMerge(c.Store, c.Cfg, mr.ID)
1857 }
1822 // Marking ready is the request for review; going back to draft 1858 // Marking ready is the request for review; going back to draft
1823 // withdraws it and is not worth anyone's inbox. 1859 // withdraws it and is not worth anyone's inbox.
1824 // 1860 //
internal/control/status.go +1
@@ -98,6 +98,7 @@ func runStatusSet(c *Ctx, args []string) int {
98 } 98 }
99 c.Store.RecordEvent(repo.ID, c.User.ID, "status", 99 c.Store.RecordEvent(repo.ID, c.User.ID, "status",
100 fmt.Sprintf(`{"sha":%q,"context":%q,"state":%q}`, full, context, state)) 100 fmt.Sprintf(`{"sha":%q,"context":%q,"state":%q}`, full, context, state))
101 TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, full)
101 return c.emit(map[string]string{"sha": full, "context": context, "state": state}, func(w io.Writer) { 102 return c.emit(map[string]string{"sha": full, "context": context, "state": state}, func(w io.Writer) {
102 fmt.Fprintf(w, "%s on %.10s: %s\n", context, full, state) 103 fmt.Fprintf(w, "%s on %.10s: %s\n", context, full, state)
103 }) 104 })
internal/hookd/hookd.go +5
@@ -362,6 +362,11 @@ func (s *Server) postReceive(req Request) {
362 if mr.State == "source_gone" { 362 if mr.State == "source_gone" {
363 s.st.SetMRState(mr.ID, "open") // branch came back 363 s.st.SetMRState(mr.ID, "open") // branch came back
364 } 364 }
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 }
365 } 370 }
366 } 371 }
367} 372}
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}