Commit c1f3c195a2

c1f3c195a2b37bedadb163355676c210754ecd3d

parent: 991b65712f

Verified · cmc

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

control: bind queued merges to their credential; dequeue on foreign push, retarget, source deletion

A push or retarget by an account that cannot write to the target, a
removed key or revoked token, an expired or narrowed credential, and a
deleted source branch each dequeue with a system comment. An expiring
credential cannot queue. Runner done and the cancelled-build status
trigger tested; an internal lookup failure records a reason; the
require-signed reason no longer says to merge again.

Ref #289

Layout: unified · split

internal/control/mergequeue.go +162 −21
@@ -3,11 +3,13 @@ package control
33import (
44 "bytes"
55 "encoding/json"
6 "errors"
67 "fmt"
78 "io"
89 "log/slog"
910 "strconv"
1011 "sync"
12 "time"
1113
1214 "gitbay.org/gitbay/internal/config"
1315 "gitbay.org/gitbay/internal/policy"
@@ -45,9 +47,11 @@ type queueResult struct {
4547
4648// TryQueuedMerge attempts the queued merge of one merge request, if it
4749// 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.
50// thread, a draft marked ready.
4951func TryQueuedMerge(st *store.Store, cfg config.Config, mrID int64) {
50 attemptQueuedMerge(st, cfg, mrID)
52 mergeQueueMu.Lock()
53 defer mergeQueueMu.Unlock()
54 attemptLocked(st, cfg, mrID)
5155}
5256
5357// TryQueuedMergesAt attempts the queued merges in a repository whose head
@@ -59,44 +63,106 @@ func TryQueuedMergesAt(st *store.Store, cfg config.Config, repoID int64, sha str
5963 return
6064 }
6165 for _, id := range ids {
62 attemptQueuedMerge(st, cfg, id)
66 TryQueuedMerge(st, cfg, id)
6367 }
6468}
6569
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 {
70// QueuedMergePushed is post-receive's call for a queued merge request
71// whose source branch pusherID just pushed or deleted. A push from an
72// account that cannot write to the target dequeues it: otherwise the
73// queuer's authority would merge commits someone else chose. A push from
74// one that can keeps it queued, and the new head has to pass on its own.
75func QueuedMergePushed(st *store.Store, cfg config.Config, mrID, pusherID int64) {
7276 mergeQueueMu.Lock()
7377 defer mergeQueueMu.Unlock()
7478 mr, err := st.MRByID(mrID)
7579 if err != nil || mr.QueuedAt == "" {
76 return queueResult{}
80 return
7781 }
7882 repo, err := st.RepoByID(mr.RepoID)
7983 if err != nil {
84 queueInternalError(st, mr.ID, err)
85 return
86 }
87 pusher, err := st.UserByID(pusherID)
88 if err != nil {
89 queueInternalError(st, mr.ID, err)
90 return
91 }
92 grant, err := st.AccessRole(repo.ID, pusher.ID)
93 if err != nil {
94 queueInternalError(st, mr.ID, err)
95 return
96 }
97 if !policy.CanWrite(pusher, repo, grant) {
98 dequeueWithReason(st, mr, fmt.Sprintf("%s pushed and cannot merge into %s", pusher.Username, repo.Path()))
99 return
100 }
101 attemptLocked(st, cfg, mrID)
102}
103
104// queueSource is Ctx.Source for a merge the queue performs.
105const queueSource = "queue"
106
107// queueInternalReason is what a queued merge says while a lookup fails.
108const queueInternalReason = "internal error, will retry on the next event"
109
110func queueInternalError(st *store.Store, mrID int64, err error) queueResult {
111 slog.Error("merge queue", "mr", mrID, "err", err)
112 st.SetMergeQueueReason(mrID, queueInternalReason)
113 return queueResult{reason: queueInternalReason}
114}
115
116// attemptLocked merges a queued request as the user who queued it,
117// checked against that user's rights and the credential it was queued
118// with now. A merge refused for anything the queuer can fix (unmet
119// gates, a branch behind a require-signed target, a conflict) stays
120// queued with the refusal recorded; a queuer or credential that can no
121// longer merge, or a source branch that is gone, dequeues it. The caller
122// holds mergeQueueMu.
123func attemptLocked(st *store.Store, cfg config.Config, mrID int64) queueResult {
124 mr, err := st.MRByID(mrID)
125 if errors.Is(err, store.ErrNotFound) {
80126 return queueResult{}
81127 }
82 user, err := st.UserByID(mr.QueuedByID)
83128 if err != nil {
129 return queueInternalError(st, mrID, err)
130 }
131 if mr.QueuedAt == "" {
84132 return queueResult{}
85133 }
134 if mr.State != "open" {
135 return dequeueWithReason(st, mr, "the source branch was deleted")
136 }
137 repo, err := st.RepoByID(mr.RepoID)
138 if err != nil {
139 return queueInternalError(st, mr.ID, err)
140 }
141 user, err := st.UserByID(mr.QueuedByID)
142 if err != nil {
143 return queueInternalError(st, mr.ID, err)
144 }
86145 grant, err := st.AccessRole(repo.ID, user.ID)
87146 if err != nil {
88 return queueResult{}
147 return queueInternalError(st, mr.ID, err)
89148 }
90149 switch {
91150 case user.Disabled || user.Pending:
92 return dequeueWithReason(st, repo, mr, user, user.Username+"'s account is not active")
151 return dequeueWithReason(st, mr, user.Username+"'s account is not active")
93152 case !policy.CanWrite(user, repo, grant):
94 return dequeueWithReason(st, repo, mr, user,
153 return dequeueWithReason(st, mr,
95154 fmt.Sprintf("%s no longer has write access to %s", user.Username, repo.Path()))
96155 }
156 lapsed, err := queueCredentialLapsed(st, mr.ID, time.Now())
157 if err != nil {
158 return queueInternalError(st, mr.ID, err)
159 }
160 if lapsed != "" {
161 return dequeueWithReason(st, mr, lapsed)
162 }
97163
98164 var out bytes.Buffer
99 c := &Ctx{User: user, Scope: "full", Source: "queue", Store: st, Cfg: cfg,
165 c := &Ctx{User: user, Scope: "full", Source: queueSource, Store: st, Cfg: cfg,
100166 Stdin: emptyReader{}, Stdout: &out, Stderr: io.Discard, JSON: true}
101167 code := mergeMR(c, repo, mr, mr.QueueStrategy)
102168 var env struct {
@@ -106,16 +172,66 @@ func attemptQueuedMerge(st *store.Store, cfg config.Config, mrID int64) queueRes
106172 json.Unmarshal(out.Bytes(), &env)
107173 if code == protocol.ExitOK {
108174 st.Audit(user.ID, "cmd mr merge", map[string]any{
109 "argv": []string{repo.Path(), strconv.FormatInt(mr.Number, 10), "--when-ready"}, "source": "queue"})
175 "argv": []string{repo.Path(), strconv.FormatInt(mr.Number, 10), "--when-ready"}, "source": queueSource})
110176 return queueResult{merged: true, out: env.Data}
111177 }
112178 st.SetMergeQueueReason(mr.ID, env.Error)
113179 return queueResult{reason: env.Error}
114180}
115181
116func dequeueWithReason(st *store.Store, repo store.Repo, mr store.MR, user store.User, reason string) queueResult {
182// queueCredentialLapsed says why the key or token a merge was queued with
183// can no longer carry it: removed, expired, or narrowed below full scope.
184// "" means it still can, or the merge was queued from a web session and
185// rests on the account alone.
186func queueCredentialLapsed(st *store.Store, mrID int64, now time.Time) (string, error) {
187 q, err := st.MergeQueueCredential(mrID)
188 if err != nil {
189 return "", err
190 }
191 switch q.Kind {
192 case "key":
193 if q.KeyID == 0 {
194 return "the key it was queued with was removed", nil
195 }
196 k, err := st.SSHKeyByID(q.KeyID)
197 if errors.Is(err, store.ErrNotFound) {
198 return "the key it was queued with was removed", nil
199 }
200 if err != nil {
201 return "", err
202 }
203 if k.Expired(now) {
204 return "the key it was queued with has expired", nil
205 }
206 if k.Scope != "full" {
207 return "the key it was queued with no longer has full scope", nil
208 }
209 case "token":
210 if q.TokenID == 0 {
211 return "the token it was queued with was revoked", nil
212 }
213 t, err := st.APITokenByID(q.TokenID)
214 if errors.Is(err, store.ErrNotFound) {
215 return "the token it was queued with was revoked", nil
216 }
217 if err != nil {
218 return "", err
219 }
220 if t.ExpiresAt != nil && !t.ExpiresAt.After(now) {
221 return "the token it was queued with has expired", nil
222 }
223 if t.Scope != "full" {
224 return "the token it was queued with no longer has full scope", nil
225 }
226 }
227 return "", nil
228}
229
230// dequeueWithReason takes mr off the queue and says why on its timeline,
231// as the queuer, whose request it was.
232func dequeueWithReason(st *store.Store, mr store.MR, reason string) queueResult {
117233 st.DequeueMerge(mr.ID)
118 st.AddMRSystemComment(mr.ID, user.ID, "queued merge by "+user.Username+" cancelled: "+reason)
234 st.AddMRSystemComment(mr.ID, mr.QueuedByID, "dequeued the merge queued by "+mr.QueuedBy+": "+reason)
119235 return queueResult{dequeued: true, reason: reason}
120236}
121237
@@ -125,18 +241,30 @@ func queueMerge(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
125241 if code := refuseArchived(c, repo); code >= 0 {
126242 return code
127243 }
128 if mr.State != "open" && mr.State != "source_gone" {
244 if mr.State != "open" {
129245 return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State)
130246 }
247 // The merge happens later on this credential's authority, which must
248 // not outlive it (#257).
249 if c.Expires != nil {
250 return c.fail(protocol.ExitDenied,
251 "--when-ready merges later on the authority of the credential it is queued with, and this one expires; queue it with a key or token without an expiry, or from the web")
252 }
131253 // A strategy the repository refuses outright would wait forever.
132254 if repo.Settings.RequireSignedCommits && (strategy == "merge" || strategy == "squash") {
133255 return c.fail(protocol.ExitDenied,
134256 "%s requires signed commits, so only fast-forward merges are allowed; queue without --strategy or with --strategy ff", repo.Path())
135257 }
136 if err := c.Store.QueueMerge(mr.ID, c.User.ID, strategy); err != nil {
258 keyID, code := queueKeyID(c)
259 if code >= 0 {
260 return code
261 }
262 if err := c.Store.QueueMerge(mr.ID, c.User.ID, strategy, keyID, c.TokenID); err != nil {
137263 return c.fail(protocol.ExitFailure, "%v", err)
138264 }
139 res := attemptQueuedMerge(c.Store, c.Cfg, mr.ID)
265 mergeQueueMu.Lock()
266 res := attemptLocked(c.Store, c.Cfg, mr.ID)
267 mergeQueueMu.Unlock()
140268 switch {
141269 case res.merged:
142270 return c.emit(res.out, func(w io.Writer) {
@@ -155,6 +283,19 @@ func queueMerge(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
155283 })
156284}
157285
286// queueKeyID is the SSH key behind c, 0 for a token, a web session, or
287// a context with no credential. -1 as the code means go on.
288func queueKeyID(c *Ctx) (int64, int) {
289 if c.TokenID != 0 || c.Source == "" || c.Source == SourceWeb || c.Source == "api" {
290 return 0, -1
291 }
292 k, err := c.Store.SSHKeyByFingerprint(c.Source)
293 if err != nil {
294 return 0, c.fail(protocol.ExitFailure, "looking up the key behind this session: %v", err)
295 }
296 return k.ID, -1
297}
298
158299// cancelQueuedMerge is mr merge --cancel.
159300func cancelQueuedMerge(c *Ctx, repo store.Repo, mr store.MR) int {
160301 ok, err := c.Store.DequeueMerge(mr.ID)
internal/control/mergequeue_test.go +192 −5
@@ -8,6 +8,7 @@ import (
88 "strconv"
99 "strings"
1010 "testing"
11 "time"
1112
1213 "gitbay.org/gitbay/internal/config"
1314 "gitbay.org/gitbay/internal/protocol"
@@ -74,10 +75,20 @@ func (f *queueFixture) write(name, body string) {
7475
7576// run dispatches argv as u, returning the exit code and stderr.
7677func (f *queueFixture) run(u store.User, argv ...string) (int, string, string) {
78 f.t.Helper()
79 return f.runWith(u, nil, argv...)
80}
81
82// runWith is run with the Ctx adjusted first, for the credential behind
83// the request.
84func (f *queueFixture) runWith(u store.User, adjust func(*Ctx), argv ...string) (int, string, string) {
7785 f.t.Helper()
7886 var out, errOut bytes.Buffer
7987 c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut}
8088 c.Cfg.Server.Root = f.root
89 if adjust != nil {
90 adjust(c)
91 }
8192 code := Dispatch(c, argv)
8293 return code, out.String(), errOut.String()
8394}
@@ -281,10 +292,10 @@ func TestWhenReadySignedBehindStaysQueued(t *testing.T) {
281292 f.git(f.src, "commit", "-q", "-m", "target moves")
282293 f.git(f.src, "push", "-q", f.dir, "main")
283294 out := f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
284 if !strings.Contains(out, "rebase") {
295 if !strings.Contains(out, "rebase and push, and the merge stays queued") {
285296 t.Fatalf("output = %q, want the pending reason to name the rebase", out)
286297 }
287 f.wantQueued("rebase")
298 f.wantQueued("is behind main")
288299
289300 // A strategy that cannot ever pass there is refused, not queued.
290301 code, _, errOut := f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready", "--strategy", "squash")
@@ -311,9 +322,7 @@ func TestWhenReadyRightsLossDequeues(t *testing.T) {
311322 if mr.State != "open" || mr.QueuedAt != "" {
312323 t.Fatalf("MR = state %s queued_at %q, want open and dequeued", mr.State, mr.QueuedAt)
313324 }
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 }
325 f.wantDequeued("bob no longer has write access")
317326}
318327
319328// --cancel dequeues; a second cancel has nothing to take off.
@@ -349,3 +358,181 @@ func TestWhenReadyCloseDequeues(t *testing.T) {
349358 t.Fatalf("closed MR = %+v, want closed and dequeued", mr)
350359 }
351360}
361
362// wantDequeued checks !1 is open, off the queue, and its timeline says why.
363func (f *queueFixture) wantDequeued(why string) {
364 f.t.Helper()
365 mr := f.mr()
366 if mr.State != "open" || mr.QueuedAt != "" {
367 f.t.Fatalf("MR = state %s queued_at %q, want open and dequeued", mr.State, mr.QueuedAt)
368 }
369 if sys := f.systemComments(); !strings.Contains(sys, "dequeued the merge queued by") || !strings.Contains(sys, why) {
370 f.t.Fatalf("system comments = %q, want a dequeue saying %q", sys, why)
371 }
372}
373
374// branch points a new branch of the bare repository at sha.
375func (f *queueFixture) branch(name, sha string) {
376 f.t.Helper()
377 f.git(f.dir, "update-ref", "refs/heads/"+name, sha)
378}
379
380// Retargeting a queued merge request by someone who cannot write to the
381// repository dequeues it; by someone who can, it stays queued.
382func TestWhenReadyRetargetByNonWriterDequeues(t *testing.T) {
383 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
384 f.branch("dev", f.targetSH)
385 f.branch("dev2", f.targetSH)
386 // carol authored it and can read, so she may retarget it.
387 carol := f.user("carol", "read")
388 if _, err := f.st.DB.Exec("UPDATE merge_requests SET author_id = ? WHERE id = ?", carol.ID, f.mr().ID); err != nil {
389 t.Fatal(err)
390 }
391 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
392 f.mustRun(f.alice, "mr", "retarget", f.repo.Path(), "1", "dev")
393 f.wantQueued("approval")
394 f.mustRun(carol, "mr", "retarget", f.repo.Path(), "1", "dev2")
395 f.wantDequeued("carol retargeted it to dev2 and cannot merge")
396}
397
398// The stack moving up after a merge retargets the merge requests on it,
399// and a queued one stays queued.
400func TestWhenReadyStackRetargetKeepsQueue(t *testing.T) {
401 f := newQueueFixture(t, func(s *store.RepoSettings) {
402 s.RequireChecks = true
403 s.RequiredContexts = []string{"ext/test"}
404 })
405 f.write("stacked.txt", "s\n")
406 f.git(f.src, "add", ".")
407 f.git(f.src, "commit", "-q", "-m", "stacked")
408 stacked := strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
409 f.git(f.src, "push", "-q", f.dir, "HEAD:refs/heads/feature2")
410 f.git(f.dir, "update-ref", mrHeadRef(2), stacked)
411 if _, err := f.st.CreateMR(f.repo.ID, f.alice.ID, f.repo.ID, "feature2", "feature", "two", "", stacked, "md", false); err != nil {
412 t.Fatal(err)
413 }
414 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "2", "--when-ready")
415 f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success")
416 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1")
417 two, err := f.st.MRByNumber(f.repo.ID, 2)
418 if err != nil {
419 t.Fatal(err)
420 }
421 if two.TargetRef != "main" || two.State != "open" || two.QueuedAt == "" {
422 t.Fatalf("!2 after !1 merged = target %s state %s queued_at %q, want main, open and queued", two.TargetRef, two.State, two.QueuedAt)
423 }
424}
425
426// A merge queued with an SSH key is dequeued when the key is removed.
427func TestWhenReadyRemovedKeyDequeues(t *testing.T) {
428 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
429 if err := f.st.AddSSHKey(f.alice.ID, "SHA256:alice", "ssh-ed25519", []byte("a"), "full", ""); err != nil {
430 t.Fatal(err)
431 }
432 withKey := func(c *Ctx) { c.Source = "SHA256:alice" }
433 if code, _, errOut := f.runWith(f.alice, withKey, "mr", "merge", f.repo.Path(), "1", "--when-ready"); code != protocol.ExitOK {
434 t.Fatalf("queue: exit %d, %s", code, errOut)
435 }
436 if err := f.st.RemoveSSHKey(f.alice.ID, "SHA256:alice"); err != nil {
437 t.Fatal(err)
438 }
439 f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve")
440 f.wantDequeued("the key it was queued with was removed")
441}
442
443// A merge queued with an API token is dequeued when the token is revoked.
444func TestWhenReadyRevokedTokenDequeues(t *testing.T) {
445 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
446 if err := f.st.CreateAPIToken(f.alice.ID, "ci", "hash", "full", nil, 0); err != nil {
447 t.Fatal(err)
448 }
449 toks, err := f.st.ListAPITokens(f.alice.ID)
450 if err != nil || len(toks) != 1 {
451 t.Fatalf("tokens = %v, %v", toks, err)
452 }
453 withToken := func(c *Ctx) { c.Source, c.TokenID, c.ViaAPI = "api", toks[0].ID, true }
454 if code, _, errOut := f.runWith(f.alice, withToken, "mr", "merge", f.repo.Path(), "1", "--when-ready"); code != protocol.ExitOK {
455 t.Fatalf("queue: exit %d, %s", code, errOut)
456 }
457 if _, err := f.st.RevokeAPIToken(f.alice.ID, "ci", false); err != nil {
458 t.Fatal(err)
459 }
460 f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve")
461 f.wantDequeued("the token it was queued with was revoked")
462}
463
464// An expiring credential cannot queue a merge: the merge would happen on
465// its authority after it lapsed.
466func TestWhenReadyExpiringCredentialRefused(t *testing.T) {
467 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
468 soon := time.Now().Add(time.Hour)
469 code, _, errOut := f.runWith(f.alice, func(c *Ctx) { c.Source, c.TokenID, c.Expires = "api", 7, &soon },
470 "mr", "merge", f.repo.Path(), "1", "--when-ready")
471 if code != protocol.ExitDenied || !strings.Contains(errOut, "without an expiry, or from the web") {
472 t.Fatalf("exit %d, %s", code, errOut)
473 }
474 if mr := f.mr(); mr.QueuedAt != "" {
475 t.Fatalf("expiring credential queued the merge: %+v", mr)
476 }
477}
478
479// A merge queued from the web rests on the account alone and merges.
480func TestWhenReadyWebQueuedMerges(t *testing.T) {
481 f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 })
482 web := func(c *Ctx) { c.Source, c.ViaAPI = SourceWeb, true }
483 if code, _, errOut := f.runWith(f.alice, web, "mr", "merge", f.repo.Path(), "1", "--when-ready"); code != protocol.ExitOK {
484 t.Fatalf("queue: exit %d, %s", code, errOut)
485 }
486 f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve")
487 f.wantMergedBy("alice")
488}
489
490// runner done reporting the last required check green merges the queued
491// merge request.
492func TestWhenReadyRunnerDoneMerges(t *testing.T) {
493 f := newQueueFixture(t, func(s *store.RepoSettings) {
494 s.RequireChecks = true
495 s.RequiredContexts = []string{"ci/unit"}
496 })
497 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
498 f.wantQueued("ci/unit=missing")
499 if _, err := f.st.CreateBuild(f.repo.ID, "unit", f.headSHA, "feature", `["true"]`, "", "", true); err != nil {
500 t.Fatal(err)
501 }
502 b, ok, err := f.st.ClaimBuild([]int64{f.repo.ID}, false)
503 if err != nil || !ok {
504 t.Fatalf("claim: ok=%v err=%v", ok, err)
505 }
506 c, out := runnerCtx(f.st, f.alice.ID, f.root)
507 if code := runRunnerDone(c, []string{strconv.FormatInt(b.ID, 10), "success"}); code != protocol.ExitOK {
508 t.Fatalf("runner done: exit %d\n%s", code, out.String())
509 }
510 f.wantMergedBy("alice")
511}
512
513// Cancelling a build whose job already passed on the same commit puts
514// the success back, and that merges the queued merge request.
515func TestWhenReadyCancelledBuildSuccessMerges(t *testing.T) {
516 f := newQueueFixture(t, func(s *store.RepoSettings) {
517 s.RequireChecks = true
518 s.RequiredContexts = []string{"ci/unit"}
519 })
520 if _, err := f.st.CreateBuild(f.repo.ID, "unit", f.headSHA, "feature", `["true"]`, "", "", true); err != nil {
521 t.Fatal(err)
522 }
523 b, ok, err := f.st.ClaimBuild([]int64{f.repo.ID}, false)
524 if err != nil || !ok {
525 t.Fatalf("claim: ok=%v err=%v", ok, err)
526 }
527 if err := f.st.FinishBuild(b.ID, "success"); err != nil {
528 t.Fatal(err)
529 }
530 n, err := f.st.CreateBuild(f.repo.ID, "unit", f.headSHA, "other", `["true"]`, "", "", true)
531 if err != nil {
532 t.Fatal(err)
533 }
534 f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
535 f.wantQueued("ci/unit=missing")
536 f.mustRun(f.alice, "build", "cancel", f.repo.Path(), strconv.FormatInt(n, 10))
537 f.wantMergedBy("alice")
538}
internal/control/mr.go +15
@@ -1047,6 +1047,16 @@ func runMRRetarget(c *Ctx, args []string) int {
10471047 return c.fail(protocol.ExitFailure, "%v", err)
10481048 }
10491049 c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target))
1050 // A queued merge was asked for against the old target. Someone who
1051 // could not merge it themselves does not get to point it elsewhere.
1052 if mr.QueuedAt != "" {
1053 grant, err := c.Store.AccessRole(repo.ID, c.User.ID)
1054 if err != nil || !policy.CanWrite(c.User, repo, grant) {
1055 mergeQueueMu.Lock()
1056 dequeueWithReason(c.Store, mr, fmt.Sprintf("%s retargeted it to %s and cannot merge into %s", c.User.Username, target, repo.Path()))
1057 mergeQueueMu.Unlock()
1058 }
1059 }
10501060 c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted",
10511061 fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target))
10521062 if parts, err := c.Store.MRParticipants(mr.ID); err == nil {
@@ -1357,6 +1367,11 @@ func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int {
13571367 // rebase when fast-forward is already possible IS a fast-forward
13581368 // (nothing is rewritten), so it stays legal.
13591369 if repo.Settings.RequireSignedCommits {
1370 if c.Source == queueSource && !ffPossible {
1371 return c.fail(protocol.ExitDenied,
1372 "%s is behind %s and %s requires signed commits, so the server cannot rebase it; rebase and push, and the merge stays queued",
1373 mr.SourceRef, mr.TargetRef, repo.Path())
1374 }
13601375 if strategy == "merge" || strategy == "squash" || !ffPossible {
13611376 return c.fail(protocol.ExitDenied,
13621377 "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again",
internal/hookd/hookd.go +6 −3
@@ -336,6 +336,9 @@ func (s *Server) postReceive(req Request) {
336336 if mr.State == "open" {
337337 s.st.SetMRState(mr.ID, "source_gone")
338338 }
339 if mr.QueuedAt != "" {
340 control.TryQueuedMerge(s.st, s.cfg, mr.ID) // dequeues: the source is gone
341 }
339342 continue // head ref retained: the diff stays viewable
340343 }
341344 dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
@@ -362,10 +365,10 @@ func (s *Server) postReceive(req Request) {
362365 if mr.State == "source_gone" {
363366 s.st.SetMRState(mr.ID, "open") // branch came back
364367 }
365 // A queued merge stays queued across a push; the new head has
366 // to pass the gates on its own.
368 // A queued merge stays queued across a push by someone who can
369 // merge it, and the new head has to pass the gates on its own.
367370 if mr.QueuedAt != "" {
368 control.TryQueuedMerge(s.st, s.cfg, mr.ID)
371 control.QueuedMergePushed(s.st, s.cfg, mr.ID, req.UserID)
369372 }
370373 }
371374 }
internal/hookd/mergequeue_test.go +110
@@ -94,3 +94,113 @@ func TestPostReceiveTriesQueuedMerge(t *testing.T) {
9494 t.Fatalf("main = %s, want %s", main, rebased)
9595 }
9696}
97
98// A fork author who cannot write to the target pushes to the source of a
99// merge request someone else queued: the queue is not theirs to use, so
100// it is dequeued rather than tried. Deleting the source branch dequeues
101// it too.
102func TestPostReceiveDequeuesQueuedMerge(t *testing.T) {
103 st, err := store.Open(":memory:")
104 if err != nil {
105 t.Fatal(err)
106 }
107 t.Cleanup(func() { st.Close() })
108 if err := st.MigrateUp(); err != nil {
109 t.Fatal(err)
110 }
111 alice, _ := st.CreateUser("alice", false)
112 bob, _ := st.CreateUser("bob", false)
113 targetID, _ := st.CreateRepo("user", alice, "app", "public")
114 forkID, _ := st.CreateRepo("user", bob, "app", "public")
115 target, _ := st.RepoByID(targetID)
116 fork, _ := st.RepoByID(forkID)
117 if _, err := st.UpdateRepoSettings(target.ID, func(s *store.RepoSettings) { s.RequireApprovals = 1 }); err != nil {
118 t.Fatal(err)
119 }
120 root := t.TempDir()
121 f := &shapeFixture{t: t, st: st, repo: fork, uid: bob, root: root, src: filepath.Join(root, "src")}
122 cfg := config.Config{}
123 cfg.Server.Root, cfg.Server.SiteURL = root, "https://x.test"
124 srv := &Server{cfg: cfg, st: st}
125
126 os.MkdirAll(f.src, 0o755)
127 f.git(root, "init", "-q", "-b", "main", "src")
128 f.write("README", "x\n")
129 f.git(f.src, "add", ".")
130 f.git(f.src, "commit", "-q", "-m", "base")
131 f.git(f.src, "checkout", "-q", "-b", "feature")
132 f.write("feature.txt", "y\n")
133 f.git(f.src, "add", ".")
134 f.git(f.src, "commit", "-q", "-m", "change")
135 head := f.sha("HEAD")
136 for _, r := range []store.Repo{target, fork} {
137 dir := control.RepoDir(root, r.OwnerName, r.Name)
138 os.MkdirAll(filepath.Dir(dir), 0o755)
139 f.git(root, "init", "-q", "--bare", dir)
140 f.git(f.src, "push", "-q", dir, "main", "feature")
141 }
142 targetDir := control.RepoDir(root, target.OwnerName, target.Name)
143 f.git(targetDir, "update-ref", "refs/merge-requests/1/head", head)
144 f.git(targetDir, "update-ref", "refs/merge-requests/2/head", head)
145 for range 2 {
146 if _, err := st.CreateMR(target.ID, bob, fork.ID, "feature", "main", "t", "", head, "md", false); err != nil {
147 t.Fatal(err)
148 }
149 }
150 for _, n := range []string{"1", "2"} {
151 var out, errOut bytes.Buffer
152 c := &control.Ctx{User: store.User{ID: alice, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut}
153 if code := control.Dispatch(c, []string{"mr", "merge", target.Path(), n, "--when-ready"}); code != protocol.ExitOK {
154 t.Fatalf("queue !%s: exit %d, %s", n, code, errOut.String())
155 }
156 }
157 systemSays := func(n int64, want string) {
158 t.Helper()
159 mr, err := st.MRByNumber(target.ID, n)
160 if err != nil {
161 t.Fatal(err)
162 }
163 if mr.QueuedAt != "" || mr.State == "merged" {
164 t.Fatalf("!%d = state %s queued_at %q, want dequeued and not merged", n, mr.State, mr.QueuedAt)
165 }
166 cs, _ := st.ListMRComments(mr.ID)
167 for _, c := range cs {
168 if c.Kind == "system" && strings.Contains(c.Body, want) {
169 return
170 }
171 }
172 t.Fatalf("!%d timeline does not say %q: %+v", n, want, cs)
173 }
174
175 // !2 is closed first so the push reaches only !1.
176 st.MarkClosed(mustMR(t, st, target.ID, 2).ID, alice, "")
177 f.write("feature.txt", "z\n")
178 f.git(f.src, "commit", "-q", "-am", "more")
179 pushed := f.sha("HEAD")
180 forkDir := control.RepoDir(root, fork.OwnerName, fork.Name)
181 f.git(f.src, "push", "-q", forkDir, "feature")
182 srv.postReceive(Request{RepoID: fork.ID, UserID: bob, Scope: "full", Updates: []policy.RefUpdate{
183 {Ref: "refs/heads/feature", Old: head, New: pushed}}})
184 systemSays(1, "bob pushed and cannot merge into alice/app")
185
186 // Deleting the source dequeues a queued request.
187 st.SetMRState(mustMR(t, st, target.ID, 2).ID, "open")
188 var out, errOut bytes.Buffer
189 c := &control.Ctx{User: store.User{ID: alice, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut}
190 if code := control.Dispatch(c, []string{"mr", "merge", target.Path(), "2", "--when-ready"}); code != protocol.ExitOK {
191 t.Fatalf("queue !2: exit %d, %s", code, errOut.String())
192 }
193 f.git(forkDir, "update-ref", "-d", "refs/heads/feature")
194 srv.postReceive(Request{RepoID: fork.ID, UserID: bob, Scope: "full", Updates: []policy.RefUpdate{
195 {Ref: "refs/heads/feature", Old: pushed, New: zeroSHA40, IsDelete: true}}})
196 systemSays(2, "the source branch was deleted")
197}
198
199func mustMR(t *testing.T, st *store.Store, repoID, n int64) store.MR {
200 t.Helper()
201 mr, err := st.MRByNumber(repoID, n)
202 if err != nil {
203 t.Fatal(err)
204 }
205 return mr
206}
internal/store/mergequeue.go +37 −7
@@ -16,18 +16,48 @@ func (s *Store) MRByID(id int64) (MR, error) {
1616}
1717
1818// 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 {
19// ("" for the default) once its gates pass, bound to the SSH key or API
20// token it was queued with (both 0 for a web session). Queueing again
21// replaces the queuer, strategy and credential and clears the reason.
22func (s *Store) QueueMerge(mrID, userID int64, strategy string, keyID, tokenID int64) error {
23 credential := ""
24 switch {
25 case keyID != 0:
26 credential = "key"
27 case tokenID != 0:
28 credential = "token"
29 }
2230 _, err := s.DB.Exec(`
23 INSERT INTO mr_merge_queue (mr_id, user_id, strategy) VALUES (?, ?, ?)
31 INSERT INTO mr_merge_queue (mr_id, user_id, strategy, credential, key_id, token_id)
32 VALUES (?, ?, ?, ?, ?, ?)
2433 ON CONFLICT (mr_id) DO UPDATE SET user_id = excluded.user_id,
25 strategy = excluded.strategy, reason = '',
34 strategy = excluded.strategy, credential = excluded.credential,
35 key_id = excluded.key_id, token_id = excluded.token_id, reason = '',
2636 queued_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
27 mrID, userID, strategy)
37 mrID, userID, strategy, credential, nullID(keyID), nullID(tokenID))
2838 return err
2939}
3040
41// QueueCredential is the credential a queued merge was queued with. Kind
42// is "key", "token", or "" for a web session; an id of 0 under "key" or
43// "token" means that credential has since been removed.
44type QueueCredential struct {
45 Kind string
46 KeyID int64
47 TokenID int64
48}
49
50func (s *Store) MergeQueueCredential(mrID int64) (QueueCredential, error) {
51 var q QueueCredential
52 err := s.DB.QueryRow(
53 "SELECT credential, COALESCE(key_id, 0), COALESCE(token_id, 0) FROM mr_merge_queue WHERE mr_id = ?",
54 mrID).Scan(&q.Kind, &q.KeyID, &q.TokenID)
55 if errors.Is(err, sql.ErrNoRows) {
56 return q, ErrNotFound
57 }
58 return q, err
59}
60
3161// DequeueMerge takes a merge request off the queue, reporting whether it
3262// was on it.
3363func (s *Store) DequeueMerge(mrID int64) (bool, error) {
@@ -51,7 +81,7 @@ func (s *Store) SetMergeQueueReason(mrID int64, reason string) error {
5181func (s *Store) QueuedMRsAtHead(repoID int64, sha string) ([]int64, error) {
5282 rows, err := s.DB.Query(`
5383 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')
84 WHERE m.repo_id = ? AND m.head_sha = ? AND m.state = 'open'
5585 ORDER BY m.number`, repoID, sha)
5686 if err != nil {
5787 return nil, err
internal/store/mergequeue_test.go +49 −4
@@ -17,7 +17,7 @@ func TestMergeQueueRoundTrip(t *testing.T) {
1717 if mr.QueuedAt != "" {
1818 t.Fatalf("fresh MR is queued: %+v", mr)
1919 }
20 if err := s.QueueMerge(mr.ID, uid, "ff"); err != nil {
20 if err := s.QueueMerge(mr.ID, uid, "ff", 0, 0); err != nil {
2121 t.Fatal(err)
2222 }
2323 if err := s.SetMergeQueueReason(mr.ID, "checks pending"); err != nil {
@@ -32,7 +32,7 @@ func TestMergeQueueRoundTrip(t *testing.T) {
3232 t.Fatalf("MRByID = %+v, %v", byID, err)
3333 }
3434
35 if err := s.QueueMerge(mr.ID, uid, "merge"); err != nil {
35 if err := s.QueueMerge(mr.ID, uid, "merge", 0, 0); err != nil {
3636 t.Fatal(err)
3737 }
3838 mr, _ = s.MRByNumber(repoID, 1)
@@ -68,7 +68,7 @@ func TestMergeQueueLeftOnMergeOrClose(t *testing.T) {
6868 if err := s.SetMRState(mr.ID, "open"); err != nil {
6969 t.Fatal(err)
7070 }
71 if err := s.QueueMerge(mr.ID, uid, ""); err != nil {
71 if err := s.QueueMerge(mr.ID, uid, "", 0, 0); err != nil {
7272 t.Fatal(err)
7373 }
7474 if err := mark(); err != nil {
@@ -83,9 +83,54 @@ func TestMergeQueueLeftOnMergeOrClose(t *testing.T) {
8383 if err := s.SetMRState(mr.ID, "open"); err != nil {
8484 t.Fatal(err)
8585 }
86 s.QueueMerge(mr.ID, uid, "")
86 s.QueueMerge(mr.ID, uid, "", 0, 0)
8787 s.SetMRState(mr.ID, "source_gone")
8888 if got, _ := s.MRByNumber(repoID, 1); got.QueuedAt == "" {
8989 t.Fatal("source_gone dropped the queued merge")
9090 }
9191}
92
93// A queued merge remembers the credential it was queued with, and a
94// removed key or revoked token reads back as gone rather than as some
95// later credential that reused the id.
96func TestMergeQueueCredential(t *testing.T) {
97 s, repoID, uid := mrFixture(t)
98 mr, _ := s.MRByNumber(repoID, 1)
99 if err := s.AddSSHKey(uid, "SHA256:k1", "ssh-ed25519", []byte("b1"), "full", ""); err != nil {
100 t.Fatal(err)
101 }
102 key, err := s.SSHKeyByFingerprint("SHA256:k1")
103 if err != nil {
104 t.Fatal(err)
105 }
106 if err := s.QueueMerge(mr.ID, uid, "", key.ID, 0); err != nil {
107 t.Fatal(err)
108 }
109 if q, err := s.MergeQueueCredential(mr.ID); err != nil || q.Kind != "key" || q.KeyID != key.ID {
110 t.Fatalf("credential = %+v, %v", q, err)
111 }
112 if err := s.RemoveSSHKey(uid, "SHA256:k1"); err != nil {
113 t.Fatal(err)
114 }
115 if q, _ := s.MergeQueueCredential(mr.ID); q.Kind != "key" || q.KeyID != 0 {
116 t.Fatalf("credential after key removal = %+v, want key with id 0", q)
117 }
118 if err := s.QueueMerge(mr.ID, uid, "", 0, 0); err != nil {
119 t.Fatal(err)
120 }
121 if q, _ := s.MergeQueueCredential(mr.ID); q.Kind != "" {
122 t.Fatalf("web-queued credential = %+v", q)
123 }
124}
125
126// A merge request whose source branch is gone is not one a status can
127// merge.
128func TestQueuedMRsAtHeadSkipsSourceGone(t *testing.T) {
129 s, repoID, uid := mrFixture(t)
130 mr, _ := s.MRByNumber(repoID, 1)
131 s.QueueMerge(mr.ID, uid, "", 0, 0)
132 s.SetMRState(mr.ID, "source_gone")
133 if ids, _ := s.QueuedMRsAtHead(repoID, "abc123"); len(ids) != 0 {
134 t.Fatalf("QueuedMRsAtHead = %v, want none", ids)
135 }
136}
internal/store/migrations/0067_merge_queue.up.sql +13 −5
@@ -2,12 +2,20 @@
22-- with strategy once its gates pass. reason is why the last attempt did
33-- not merge. A merge request that is merged or closed leaves the queue,
44-- whichever path did it.
5--
6-- credential is what the merge was queued with: 'key' (key_id) or
7-- 'token' (token_id), or '' for a web session, which binds to the
8-- account alone. A removed key or revoked token nulls its id, so the id
9-- of a later credential never stands in for it.
510CREATE 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 mr_id INTEGER PRIMARY KEY REFERENCES merge_requests(id) ON DELETE CASCADE,
12 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
13 strategy TEXT NOT NULL DEFAULT '',
14 credential TEXT NOT NULL DEFAULT '' CHECK (credential IN ('', 'key', 'token')),
15 key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL,
16 token_id INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL,
17 reason TEXT NOT NULL DEFAULT '',
18 queued_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
1119);
1220CREATE INDEX mr_merge_queue_user ON mr_merge_queue(user_id);
1321CREATE TRIGGER mr_merge_queue_leave AFTER UPDATE OF state ON merge_requests
internal/store/tokens.go +13
@@ -64,6 +64,19 @@ func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) {
6464 return u, t, err
6565}
6666
67// APITokenByID loads a token by id, expired or not.
68func (s *Store) APITokenByID(id int64) (APIToken, error) {
69 var t APIToken
70 var exp sql.NullString
71 err := s.DB.QueryRow("SELECT id, name, scope, created_at, expires_at FROM api_tokens WHERE id = ?", id).
72 Scan(&t.ID, &t.Name, &t.Scope, &t.CreatedAt, &exp)
73 if errors.Is(err, sql.ErrNoRows) {
74 return t, ErrNotFound
75 }
76 t.ExpiresAt = parseTime(exp)
77 return t, err
78}
79
6780func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) {
6881 rows, err := s.DB.Query(`
6982 SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '')