mr merge --when-ready !520
22 files changed, +1687 −19
Layout: unified · split
.gitbay/wiki/Parity.org +1
| @@ -40,6 +40,7 @@ browser-only and the iOS build screen unable to say more than the log. | |||
| 40 | | resolve a thread | yes | yes | yes | | 40 | | resolve a thread | yes | yes | yes | |
| 41 | | comment on a diff line | yes | yes | yes | | 41 | | comment on a diff line | yes | yes | yes | |
| 42 | | merge (all strategies) | yes | yes | yes | | 42 | | merge (all strategies) | yes | yes | yes | |
| 43 | | merge when ready, cancel | yes | yes | no | | ||
| 43 | | close | yes | yes | yes | | 44 | | close | yes | yes | yes | |
| 44 | | close in favour of another | yes | yes | yes | | 45 | | close in favour of another | yes | yes | yes | |
| 45 | | create | yes | yes | yes | | 46 | | create | yes | yes | yes | |
.gitbay/wiki/Users.org +28
| @@ -453,6 +453,7 @@ gitbay mr checkout 4 # local branch mr/4 from the MR head | |||
| 453 | gitbay mr review 4 --approve # or --request-changes / --comment | 453 | gitbay mr review 4 --approve # or --request-changes / --comment |
| 454 | gitbay mr label 4 --add bug --remove wontfix | 454 | gitbay mr label 4 --add bug --remove wontfix |
| 455 | gitbay mr merge 4 [--strategy ff|merge|squash|rebase] | 455 | gitbay mr merge 4 [--strategy ff|merge|squash|rebase] |
| 456 | gitbay mr merge 4 --when-ready # merge once the gates pass; --cancel dequeues | ||
| 456 | gitbay mr close 4 | 457 | gitbay mr close 4 |
| 457 | #+end_src | 458 | #+end_src |
| 458 | 459 | ||
| @@ -511,6 +512,33 @@ merge= names every unmet gate at once rather than the first. =mr | |||
| 511 | review= says when a verdict is advisory, which it is from anyone | 512 | review= says when a verdict is advisory, which it is from anyone |
| 512 | without write access: the gates do not count it. | 513 | without write access: the gates do not count it. |
| 513 | 514 | ||
| 515 | =mr merge <n> --when-ready= queues the merge and merges once the gates | ||
| 516 | pass, or at once if they already do. It is tried again when a status | ||
| 517 | is reported on the head, a review is submitted, a thread is resolved, | ||
| 518 | the request is marked ready, and the source branch is pushed; a push | ||
| 519 | by someone who can write to the target keeps it queued, and the new | ||
| 520 | head has to pass on its own. The merge is made as the user who queued | ||
| 521 | it, with their rights checked at that moment. Anything the queuer can | ||
| 522 | fix (an unmet gate, a conflict, a branch behind a | ||
| 523 | =require_signed_commits= target that needs a rebase) leaves it queued | ||
| 524 | with the reason shown on =mr show= and the page. | ||
| 525 | |||
| 526 | The queue is bound to the credential it was made with. It is dequeued, | ||
| 527 | with the reason on the request's timeline, when: | ||
| 528 | |||
| 529 | - the queuer loses write access or their account is disabled; | ||
| 530 | - the SSH key or API token it was queued with is removed, revoked, | ||
| 531 | expires, or no longer has full scope (a merge queued from the web | ||
| 532 | rests on the account alone); | ||
| 533 | - someone who cannot write to the target pushes to the source branch | ||
| 534 | or retargets the request; | ||
| 535 | - the source branch is deleted, or the request is closed. | ||
| 536 | |||
| 537 | An expiring key or token cannot queue a merge; use one without an | ||
| 538 | expiry, or the web. =mr merge <n> --cancel= dequeues without closing. | ||
| 539 | =merge= and =squash= are refused at queue time on a | ||
| 540 | =require_signed_commits= repository. | ||
| 541 | |||
| 514 | Those gates apply to =mr merge=. A direct push to a protected branch | 542 | Those gates apply to =mr merge=. A direct push to a protected branch |
| 515 | passes none of them until =require-mr on=: then an existing protected | 543 | passes none of them until =require-mr on=: then an existing protected |
| 516 | branch refuses every push, including =repo commit-file= and the web | 544 | branch refuses every push, including =repo commit-file= and the web |
CHANGELOG.org +12
| @@ -29,6 +29,18 @@ anything beyond "replace the binary and restart" is needed. | |||
| 29 | the scratch-repository test: a build failing SSH logins must not | 29 | the scratch-repository test: a build failing SSH logins must not |
| 30 | lock the runner out. Run it before pointing the runner back at real | 30 | lock the runner out. Run it before pointing the runner back at real |
| 31 | repositories; the Admin page has the steps and the rollback. (#260) | 31 | repositories; the Admin page has the steps and the rollback. (#260) |
| 32 | - =mr merge <n> --when-ready= queues a merge until the gates pass, | ||
| 33 | merging as the queuing user with their rights checked then; tried on | ||
| 34 | a status, a review, a resolved thread, =mr ready= and a push to the | ||
| 35 | source branch. A refusal the queuer can fix, such as a branch behind | ||
| 36 | a =require_signed_commits= target, keeps it queued with the reason | ||
| 37 | on =mr show= and the page. The queue is bound to the key or token it | ||
| 38 | was made with: removing, revoking, expiring or narrowing it | ||
| 39 | dequeues, as do losing write access, a push or retarget by someone | ||
| 40 | who cannot write to the target, deleting the source branch, and | ||
| 41 | closing. An expiring credential cannot queue. =--cancel= dequeues. | ||
| 42 | =mr show=, =mr list= and the dashboard carry =queued=. Migration | ||
| 43 | 0067 (#289). | ||
| 32 | 44 | ||
| 33 | * v1.37.0 — 2026-09-29 | 45 | * v1.37.0 — 2026-09-29 |
| 34 | 46 | ||
e2e/mergequeue_test.go added +103
| @@ -0,0 +1,103 @@ | |||
| 1 | package e2e | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/url" | ||
| 5 | "os" | ||
| 6 | "path/filepath" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | ) | ||
| 10 | |||
| 11 | // A merge queued with mr merge --when-ready waits on its gates, shows on | ||
| 12 | // the page with a cancel button, and merges as the user who queued it | ||
| 13 | // when a status turns the checks green (#289). | ||
| 14 | func TestMergeWhenReady(t *testing.T) { | ||
| 15 | t.Parallel() | ||
| 16 | inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n") | ||
| 17 | aliceKey := inst.newKey(t, "alice") | ||
| 18 | bobKey := inst.newKey(t, "bob") | ||
| 19 | inst.admin(t, "admin", "user", "create", "alice", | ||
| 20 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | ||
| 21 | inst.admin(t, "admin", "user", "create", "bob", | ||
| 22 | "--key", bobKey+".pub", "--email", "bob@example.test", "--verified") | ||
| 23 | for _, args := range [][]string{ | ||
| 24 | {"repo", "create", "alice/lib"}, | ||
| 25 | {"repo", "access", "grant", "alice/lib", "bob", "write"}, | ||
| 26 | {"repo", "settings", "require-contexts", "alice/lib", "ext/test"}, | ||
| 27 | } { | ||
| 28 | if _, errOut, code := inst.ssh(t, aliceKey, "", args...); code != 0 { | ||
| 29 | t.Fatalf("%v: %s", args, errOut) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | env := inst.gitEnv(aliceKey) | ||
| 34 | work := t.TempDir() | ||
| 35 | mustGit(t, work, env, "clone", "-q", inst.sshURL("alice/lib"), "w") | ||
| 36 | dir := filepath.Join(work, "w") | ||
| 37 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | ||
| 38 | os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("v1\n"), 0o644) | ||
| 39 | mustGit(t, dir, env, "add", ".") | ||
| 40 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | ||
| 41 | mustGit(t, dir, env, "push", "-q", "origin", "main") | ||
| 42 | mustGit(t, dir, env, "checkout", "-q", "-b", "feature") | ||
| 43 | os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("v2\n"), 0o644) | ||
| 44 | mustGit(t, dir, env, "commit", "-q", "-am", "change") | ||
| 45 | mustGit(t, dir, env, "push", "-q", "origin", "feature") | ||
| 46 | head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD")) | ||
| 47 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/lib", | ||
| 48 | "--source", "feature", "--target", "main", "--title", "'change'"); code != 0 { | ||
| 49 | t.Fatalf("mr create: %s", errOut) | ||
| 50 | } | ||
| 51 | |||
| 52 | // Queued over SSH: the checks have not reported, so it waits. | ||
| 53 | out, errOut, code := inst.ssh(t, bobKey, "", "mr", "merge", "alice/lib", "1", "--when-ready") | ||
| 54 | if code != 0 || !strings.Contains(out, "queued") || !strings.Contains(out, "ext/test=missing") { | ||
| 55 | t.Fatalf("mr merge --when-ready: %d %s %s", code, out, errOut) | ||
| 56 | } | ||
| 57 | out, _, _ = inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1", "--json") | ||
| 58 | if !strings.Contains(out, `"queued":{"by":"bob"`) { | ||
| 59 | t.Fatalf("mr show does not carry the queue:\n%s", out) | ||
| 60 | } | ||
| 61 | out, _, _ = inst.ssh(t, bobKey, "", "dashboard", "--json") | ||
| 62 | if !strings.Contains(out, `"queued":true`) { | ||
| 63 | t.Fatalf("dashboard does not carry the queue:\n%s", out) | ||
| 64 | } | ||
| 65 | |||
| 66 | // The page shows it; its cancel button dequeues and its queue button | ||
| 67 | // queues again, now as alice. | ||
| 68 | mrURL := inst.base() + "/alice/lib/mrs/1" | ||
| 69 | alice := inst.login(t, aliceKey) | ||
| 70 | _, body := browserGet(t, alice, mrURL) | ||
| 71 | for _, want := range []string{"Queued to merge by", "waiting: ", "Cancel queued merge"} { | ||
| 72 | if !strings.Contains(body, want) { | ||
| 73 | t.Fatalf("MR page missing %q:\n%s", want, body) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | if status, _ := browserPost(t, alice, mrURL+"/merge", url.Values{"cancel": {"on"}}); status != 200 { | ||
| 77 | t.Fatalf("cancel post: %d", status) | ||
| 78 | } | ||
| 79 | if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/lib", "1", "--json"); strings.Contains(out, `"queued":`) { | ||
| 80 | t.Fatalf("web cancel left the queue:\n%s", out) | ||
| 81 | } | ||
| 82 | if status, _ := browserPost(t, alice, mrURL+"/merge", | ||
| 83 | url.Values{"strategy": {"auto"}, "when_ready": {"on"}}); status != 200 { | ||
| 84 | t.Fatalf("queue post: %d", status) | ||
| 85 | } | ||
| 86 | if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "open" { | ||
| 87 | t.Fatalf("queued MR is %s before its checks", st) | ||
| 88 | } | ||
| 89 | |||
| 90 | // The status that turns the checks green merges it, as alice. | ||
| 91 | if _, errOut, code := inst.ssh(t, bobKey, "", "status", "set", "alice/lib", head, | ||
| 92 | "--context", "ext/test", "--state", "success"); code != 0 { | ||
| 93 | t.Fatalf("status set: %s", errOut) | ||
| 94 | } | ||
| 95 | merged := inst.mrShow(t, aliceKey, "alice/lib", "1") | ||
| 96 | if merged.State != "merged" || merged.MergedBy != "alice" { | ||
| 97 | t.Fatalf("after green checks: state %s merged by %q", merged.State, merged.MergedBy) | ||
| 98 | } | ||
| 99 | mustGit(t, dir, env, "fetch", "-q", "origin") | ||
| 100 | if got := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "origin/main")); got != head { | ||
| 101 | t.Fatalf("main = %s, want %s", got, head) | ||
| 102 | } | ||
| 103 | } | ||
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 +328
| @@ -0,0 +1,328 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "errors" | ||
| 7 | "fmt" | ||
| 8 | "io" | ||
| 9 | "log/slog" | ||
| 10 | "strconv" | ||
| 11 | "sync" | ||
| 12 | "time" | ||
| 13 | |||
| 14 | "gitbay.org/gitbay/internal/config" | ||
| 15 | "gitbay.org/gitbay/internal/policy" | ||
| 16 | "gitbay.org/gitbay/internal/protocol" | ||
| 17 | "gitbay.org/gitbay/internal/store" | ||
| 18 | ) | ||
| 19 | |||
| 20 | // QueuedOut is a merge request's queued merge (mr merge --when-ready): | ||
| 21 | // who queued it, the strategy it will use ("" for the default), and why | ||
| 22 | // the last attempt did not merge. | ||
| 23 | type QueuedOut struct { | ||
| 24 | By string `json:"by"` | ||
| 25 | Strategy string `json:"strategy,omitempty"` | ||
| 26 | Reason string `json:"reason,omitempty"` | ||
| 27 | QueuedAt string `json:"queued_at"` | ||
| 28 | } | ||
| 29 | |||
| 30 | func queuedOut(m store.MR) *QueuedOut { | ||
| 31 | if m.QueuedAt == "" { | ||
| 32 | return nil | ||
| 33 | } | ||
| 34 | return &QueuedOut{By: m.QueuedBy, Strategy: m.QueueStrategy, Reason: m.QueueReason, QueuedAt: m.QueuedAt} | ||
| 35 | } | ||
| 36 | |||
| 37 | // mergeQueueMu serialises queued merge attempts, so two triggers landing | ||
| 38 | // together cannot both merge one request. | ||
| 39 | var mergeQueueMu sync.Mutex | ||
| 40 | |||
| 41 | type queueResult struct { | ||
| 42 | merged bool | ||
| 43 | dequeued bool | ||
| 44 | reason string | ||
| 45 | out map[string]any // the merge's output, when merged | ||
| 46 | } | ||
| 47 | |||
| 48 | // TryQueuedMerge attempts the queued merge of one merge request, if it | ||
| 49 | // has one. Called wherever a gate can have changed: a review, a resolved | ||
| 50 | // thread, a draft marked ready. | ||
| 51 | func TryQueuedMerge(st *store.Store, cfg config.Config, mrID int64) { | ||
| 52 | mergeQueueMu.Lock() | ||
| 53 | defer mergeQueueMu.Unlock() | ||
| 54 | attemptLocked(st, cfg, mrID) | ||
| 55 | } | ||
| 56 | |||
| 57 | // TryQueuedMergesAt attempts the queued merges in a repository whose head | ||
| 58 | // is sha. Called when a status is reported on sha. | ||
| 59 | func TryQueuedMergesAt(st *store.Store, cfg config.Config, repoID int64, sha string) { | ||
| 60 | ids, err := st.QueuedMRsAtHead(repoID, sha) | ||
| 61 | if err != nil { | ||
| 62 | slog.Error("merge queue: listing", "repo", repoID, "err", err) | ||
| 63 | return | ||
| 64 | } | ||
| 65 | for _, id := range ids { | ||
| 66 | TryQueuedMerge(st, cfg, id) | ||
| 67 | } | ||
| 68 | } | ||
| 69 | |||
| 70 | // QueuedMergePushed is post-receive's call for a queued merge request | ||
| 71 | // whose source branch was pushed by pusherID with a key of scope. A push | ||
| 72 | // the target's writers did not make dequeues it, since otherwise the | ||
| 73 | // queuer's authority would merge commits someone else chose: a deploy | ||
| 74 | // key (which can never merge, whoever registered it) or an account that | ||
| 75 | // cannot write to the target. A push that cannot be checked dequeues | ||
| 76 | // too. A push from a writer keeps it queued, and the new head has to | ||
| 77 | // pass on its own. | ||
| 78 | func QueuedMergePushed(st *store.Store, cfg config.Config, mrID, pusherID int64, scope string) { | ||
| 79 | mergeQueueMu.Lock() | ||
| 80 | defer mergeQueueMu.Unlock() | ||
| 81 | mr, err := st.MRByID(mrID) | ||
| 82 | if err != nil { | ||
| 83 | slog.Error("merge queue: push", "mr", mrID, "err", err) | ||
| 84 | st.DequeueMerge(mrID) | ||
| 85 | return | ||
| 86 | } | ||
| 87 | if mr.QueuedAt == "" { | ||
| 88 | return | ||
| 89 | } | ||
| 90 | if policy.IsDeployScope(scope) { | ||
| 91 | dequeueWithReason(st, mr, "a deploy key pushed, and a deploy key cannot merge") | ||
| 92 | return | ||
| 93 | } | ||
| 94 | const unchecked = "could not check who pushed" | ||
| 95 | repo, err := st.RepoByID(mr.RepoID) | ||
| 96 | if err != nil { | ||
| 97 | slog.Error("merge queue: push", "mr", mrID, "err", err) | ||
| 98 | dequeueWithReason(st, mr, unchecked) | ||
| 99 | return | ||
| 100 | } | ||
| 101 | pusher, err := st.UserByID(pusherID) | ||
| 102 | if err != nil { | ||
| 103 | slog.Error("merge queue: push", "mr", mrID, "err", err) | ||
| 104 | dequeueWithReason(st, mr, unchecked) | ||
| 105 | return | ||
| 106 | } | ||
| 107 | grant, err := st.AccessRole(repo.ID, pusher.ID) | ||
| 108 | if err != nil { | ||
| 109 | slog.Error("merge queue: push", "mr", mrID, "err", err) | ||
| 110 | dequeueWithReason(st, mr, unchecked) | ||
| 111 | return | ||
| 112 | } | ||
| 113 | if !policy.CanWrite(pusher, repo, grant) { | ||
| 114 | dequeueWithReason(st, mr, fmt.Sprintf("%s pushed and cannot merge into %s", pusher.Username, repo.Path())) | ||
| 115 | return | ||
| 116 | } | ||
| 117 | attemptLocked(st, cfg, mrID) | ||
| 118 | } | ||
| 119 | |||
| 120 | // queueSource is Ctx.Source for a merge the queue performs. | ||
| 121 | const queueSource = "queue" | ||
| 122 | |||
| 123 | // queueInternalReason is what a queued merge says while a lookup fails. | ||
| 124 | const queueInternalReason = "internal error, will retry on the next event" | ||
| 125 | |||
| 126 | func queueInternalError(st *store.Store, mrID int64, err error) queueResult { | ||
| 127 | slog.Error("merge queue", "mr", mrID, "err", err) | ||
| 128 | st.SetMergeQueueReason(mrID, queueInternalReason) | ||
| 129 | return queueResult{reason: queueInternalReason} | ||
| 130 | } | ||
| 131 | |||
| 132 | // attemptLocked merges a queued request as the user who queued it, | ||
| 133 | // checked against that user's rights and the credential it was queued | ||
| 134 | // with now. A merge refused for anything the queuer can fix (unmet | ||
| 135 | // gates, a branch behind a require-signed target, a conflict) stays | ||
| 136 | // queued with the refusal recorded; a queuer or credential that can no | ||
| 137 | // longer merge, or a source branch that is gone, dequeues it. The caller | ||
| 138 | // holds mergeQueueMu. | ||
| 139 | func attemptLocked(st *store.Store, cfg config.Config, mrID int64) queueResult { | ||
| 140 | mr, err := st.MRByID(mrID) | ||
| 141 | if errors.Is(err, store.ErrNotFound) { | ||
| 142 | return queueResult{} | ||
| 143 | } | ||
| 144 | if err != nil { | ||
| 145 | return queueInternalError(st, mrID, err) | ||
| 146 | } | ||
| 147 | if mr.QueuedAt == "" { | ||
| 148 | return queueResult{} | ||
| 149 | } | ||
| 150 | if mr.State != "open" { | ||
| 151 | return dequeueWithReason(st, mr, "the source branch was deleted") | ||
| 152 | } | ||
| 153 | repo, err := st.RepoByID(mr.RepoID) | ||
| 154 | if err != nil { | ||
| 155 | return queueInternalError(st, mr.ID, err) | ||
| 156 | } | ||
| 157 | user, err := st.UserByID(mr.QueuedByID) | ||
| 158 | if err != nil { | ||
| 159 | return queueInternalError(st, mr.ID, err) | ||
| 160 | } | ||
| 161 | grant, err := st.AccessRole(repo.ID, user.ID) | ||
| 162 | if err != nil { | ||
| 163 | return queueInternalError(st, mr.ID, err) | ||
| 164 | } | ||
| 165 | switch { | ||
| 166 | case user.Disabled || user.Pending: | ||
| 167 | return dequeueWithReason(st, mr, user.Username+"'s account is not active") | ||
| 168 | case !policy.CanWrite(user, repo, grant): | ||
| 169 | return dequeueWithReason(st, mr, | ||
| 170 | fmt.Sprintf("%s no longer has write access to %s", user.Username, repo.Path())) | ||
| 171 | } | ||
| 172 | lapsed, err := queueCredentialLapsed(st, mr.ID, time.Now()) | ||
| 173 | if err != nil { | ||
| 174 | return queueInternalError(st, mr.ID, err) | ||
| 175 | } | ||
| 176 | if lapsed != "" { | ||
| 177 | return dequeueWithReason(st, mr, lapsed) | ||
| 178 | } | ||
| 179 | |||
| 180 | var out bytes.Buffer | ||
| 181 | c := &Ctx{User: user, Scope: "full", Source: queueSource, Store: st, Cfg: cfg, | ||
| 182 | Stdin: emptyReader{}, Stdout: &out, Stderr: io.Discard, JSON: true} | ||
| 183 | code := mergeMR(c, repo, mr, mr.QueueStrategy) | ||
| 184 | var env struct { | ||
| 185 | Data map[string]any `json:"data"` | ||
| 186 | Error string `json:"error"` | ||
| 187 | } | ||
| 188 | json.Unmarshal(out.Bytes(), &env) | ||
| 189 | if code == protocol.ExitOK { | ||
| 190 | st.Audit(user.ID, "cmd mr merge", map[string]any{ | ||
| 191 | "argv": []string{repo.Path(), strconv.FormatInt(mr.Number, 10), "--when-ready"}, "source": queueSource}) | ||
| 192 | return queueResult{merged: true, out: env.Data} | ||
| 193 | } | ||
| 194 | st.SetMergeQueueReason(mr.ID, env.Error) | ||
| 195 | return queueResult{reason: env.Error} | ||
| 196 | } | ||
| 197 | |||
| 198 | // queueCredentialLapsed says why the key or token a merge was queued with | ||
| 199 | // can no longer carry it: removed, expired, or narrowed below full scope. | ||
| 200 | // "" means it still can, or the merge was queued from a web session and | ||
| 201 | // rests on the account alone. | ||
| 202 | func queueCredentialLapsed(st *store.Store, mrID int64, now time.Time) (string, error) { | ||
| 203 | q, err := st.MergeQueueCredential(mrID) | ||
| 204 | if err != nil { | ||
| 205 | return "", err | ||
| 206 | } | ||
| 207 | switch q.Kind { | ||
| 208 | case "key": | ||
| 209 | if q.KeyID == 0 { | ||
| 210 | return "the key it was queued with was removed", nil | ||
| 211 | } | ||
| 212 | k, err := st.SSHKeyByID(q.KeyID) | ||
| 213 | if errors.Is(err, store.ErrNotFound) { | ||
| 214 | return "the key it was queued with was removed", nil | ||
| 215 | } | ||
| 216 | if err != nil { | ||
| 217 | return "", err | ||
| 218 | } | ||
| 219 | if k.Expired(now) { | ||
| 220 | return "the key it was queued with has expired", nil | ||
| 221 | } | ||
| 222 | if k.Scope != "full" { | ||
| 223 | return "the key it was queued with no longer has full scope", nil | ||
| 224 | } | ||
| 225 | case "token": | ||
| 226 | if q.TokenID == 0 { | ||
| 227 | return "the token it was queued with was revoked", nil | ||
| 228 | } | ||
| 229 | t, err := st.APITokenByID(q.TokenID) | ||
| 230 | if errors.Is(err, store.ErrNotFound) { | ||
| 231 | return "the token it was queued with was revoked", nil | ||
| 232 | } | ||
| 233 | if err != nil { | ||
| 234 | return "", err | ||
| 235 | } | ||
| 236 | if t.ExpiresAt != nil && !t.ExpiresAt.After(now) { | ||
| 237 | return "the token it was queued with has expired", nil | ||
| 238 | } | ||
| 239 | if t.Scope != "full" { | ||
| 240 | return "the token it was queued with no longer has full scope", nil | ||
| 241 | } | ||
| 242 | } | ||
| 243 | return "", nil | ||
| 244 | } | ||
| 245 | |||
| 246 | // dequeueWithReason takes mr off the queue and says why on its timeline, | ||
| 247 | // as the queuer, whose request it was. | ||
| 248 | func dequeueWithReason(st *store.Store, mr store.MR, reason string) queueResult { | ||
| 249 | st.DequeueMerge(mr.ID) | ||
| 250 | st.AddMRSystemComment(mr.ID, mr.QueuedByID, "dequeued the merge queued by "+mr.QueuedBy+": "+reason) | ||
| 251 | return queueResult{dequeued: true, reason: reason} | ||
| 252 | } | ||
| 253 | |||
| 254 | // queueMerge is mr merge --when-ready: queue the merge as c.User and try | ||
| 255 | // it at once, so gates that already pass merge now. | ||
| 256 | func queueMerge(c *Ctx, repo store.Repo, mr store.MR, strategy string) int { | ||
| 257 | if code := refuseArchived(c, repo); code >= 0 { | ||
| 258 | return code | ||
| 259 | } | ||
| 260 | if mr.State != "open" { | ||
| 261 | return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) | ||
| 262 | } | ||
| 263 | // The merge happens later on this credential's authority, which must | ||
| 264 | // not outlive it (#257). | ||
| 265 | if c.Expires != nil { | ||
| 266 | return c.fail(protocol.ExitDenied, | ||
| 267 | "--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") | ||
| 268 | } | ||
| 269 | // A strategy the repository refuses outright would wait forever. | ||
| 270 | if repo.Settings.RequireSignedCommits && (strategy == "merge" || strategy == "squash") { | ||
| 271 | return c.fail(protocol.ExitDenied, | ||
| 272 | "%s requires signed commits, so only fast-forward merges are allowed; queue without --strategy or with --strategy ff", repo.Path()) | ||
| 273 | } | ||
| 274 | keyID, code := queueKeyID(c) | ||
| 275 | if code >= 0 { | ||
| 276 | return code | ||
| 277 | } | ||
| 278 | if err := c.Store.QueueMerge(mr.ID, c.User.ID, strategy, keyID, c.TokenID); err != nil { | ||
| 279 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 280 | } | ||
| 281 | mergeQueueMu.Lock() | ||
| 282 | res := attemptLocked(c.Store, c.Cfg, mr.ID) | ||
| 283 | mergeQueueMu.Unlock() | ||
| 284 | switch { | ||
| 285 | case res.merged: | ||
| 286 | return c.emit(res.out, func(w io.Writer) { | ||
| 287 | fmt.Fprintf(w, "merged %s!%d into %s (%v) at %.10v\n", repo.Path(), mr.Number, mr.TargetRef, res.out["strategy"], res.out["sha"]) | ||
| 288 | }) | ||
| 289 | case res.dequeued: | ||
| 290 | return c.fail(protocol.ExitDenied, "%s", res.reason) | ||
| 291 | } | ||
| 292 | note := "" | ||
| 293 | if strategy != "" { | ||
| 294 | note = " (" + strategy + ")" | ||
| 295 | } | ||
| 296 | c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("%s queued the merge%s for when the gates pass", c.User.Username, note)) | ||
| 297 | return c.emit(map[string]any{"number": mr.Number, "queued": true, "strategy": strategy, "reason": res.reason}, func(w io.Writer) { | ||
| 298 | fmt.Fprintf(w, "queued %s!%d to merge when ready; waiting: %s\n", repo.Path(), mr.Number, res.reason) | ||
| 299 | }) | ||
| 300 | } | ||
| 301 | |||
| 302 | // queueKeyID is the SSH key behind c, 0 for a token, a web session, or | ||
| 303 | // a context with no credential. -1 as the code means go on. | ||
| 304 | func queueKeyID(c *Ctx) (int64, int) { | ||
| 305 | if c.TokenID != 0 || c.Source == "" || c.Source == SourceWeb || c.Source == "api" { | ||
| 306 | return 0, -1 | ||
| 307 | } | ||
| 308 | k, err := c.Store.SSHKeyByFingerprint(c.Source) | ||
| 309 | if err != nil { | ||
| 310 | return 0, c.fail(protocol.ExitFailure, "looking up the key behind this session: %v", err) | ||
| 311 | } | ||
| 312 | return k.ID, -1 | ||
| 313 | } | ||
| 314 | |||
| 315 | // cancelQueuedMerge is mr merge --cancel. | ||
| 316 | func cancelQueuedMerge(c *Ctx, repo store.Repo, mr store.MR) int { | ||
| 317 | ok, err := c.Store.DequeueMerge(mr.ID) | ||
| 318 | if err != nil { | ||
| 319 | return c.fail(protocol.ExitFailure, "%v", err) | ||
| 320 | } | ||
| 321 | if !ok { | ||
| 322 | return c.fail(protocol.ExitFailure, "!%d is not queued to merge", mr.Number) | ||
| 323 | } | ||
| 324 | c.Store.AddMRSystemComment(mr.ID, c.User.ID, c.User.Username+" cancelled the queued merge") | ||
| 325 | return c.emit(map[string]any{"number": mr.Number, "queued": false}, func(w io.Writer) { | ||
| 326 | fmt.Fprintf(w, "cancelled the queued merge of %s!%d\n", repo.Path(), mr.Number) | ||
| 327 | }) | ||
| 328 | } | ||
internal/control/mergequeue_test.go added +538
| @@ -0,0 +1,538 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "encoding/json" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strconv" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | "time" | ||
| 12 | |||
| 13 | "gitbay.org/gitbay/internal/config" | ||
| 14 | "gitbay.org/gitbay/internal/protocol" | ||
| 15 | "gitbay.org/gitbay/internal/store" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // queueFixture is a repository on disk with one merge request, !1, | ||
| 19 | // feature into main, whose head is one commit ahead of main. | ||
| 20 | type queueFixture struct { | ||
| 21 | t *testing.T | ||
| 22 | st *store.Store | ||
| 23 | repo store.Repo | ||
| 24 | alice store.User // the owner | ||
| 25 | root string | ||
| 26 | dir string | ||
| 27 | src string | ||
| 28 | git func(dir string, args ...string) string | ||
| 29 | headSHA string | ||
| 30 | targetSH string | ||
| 31 | } | ||
| 32 | |||
| 33 | func newQueueFixture(t *testing.T, set func(*store.RepoSettings)) *queueFixture { | ||
| 34 | t.Helper() | ||
| 35 | st, repo, uid := newQueueTestRepo(t) | ||
| 36 | if set != nil { | ||
| 37 | if _, err := st.UpdateRepoSettings(repo.ID, set); err != nil { | ||
| 38 | t.Fatal(err) | ||
| 39 | } | ||
| 40 | var err error | ||
| 41 | if repo, err = st.RepoByID(repo.ID); err != nil { | ||
| 42 | t.Fatal(err) | ||
| 43 | } | ||
| 44 | } | ||
| 45 | f := &queueFixture{t: t, st: st, repo: repo, alice: store.User{ID: uid, Username: "alice"}, | ||
| 46 | root: t.TempDir(), git: gitRunner(t)} | ||
| 47 | f.src = filepath.Join(f.root, "src") | ||
| 48 | f.git(f.root, "init", "-q", "-b", "main", "src") | ||
| 49 | f.write("README", "x\n") | ||
| 50 | f.git(f.src, "add", ".") | ||
| 51 | f.git(f.src, "commit", "-q", "-m", "base") | ||
| 52 | f.targetSH = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD")) | ||
| 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 | f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD")) | ||
| 58 | |||
| 59 | f.dir = RepoDir(f.root, repo.OwnerName, repo.Name) | ||
| 60 | os.MkdirAll(filepath.Dir(f.dir), 0o755) | ||
| 61 | f.git(f.root, "clone", "-q", "--bare", f.src, f.dir) | ||
| 62 | f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA) | ||
| 63 | if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", f.headSHA, "md", false); err != nil { | ||
| 64 | t.Fatal(err) | ||
| 65 | } | ||
| 66 | return f | ||
| 67 | } | ||
| 68 | |||
| 69 | func (f *queueFixture) write(name, body string) { | ||
| 70 | f.t.Helper() | ||
| 71 | if err := os.WriteFile(filepath.Join(f.src, name), []byte(body), 0o644); err != nil { | ||
| 72 | f.t.Fatal(err) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | |||
| 76 | // run dispatches argv as u, returning the exit code and stderr. | ||
| 77 | func (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. | ||
| 84 | func (f *queueFixture) runWith(u store.User, adjust func(*Ctx), argv ...string) (int, string, string) { | ||
| 85 | f.t.Helper() | ||
| 86 | var out, errOut bytes.Buffer | ||
| 87 | c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut} | ||
| 88 | c.Cfg.Server.Root = f.root | ||
| 89 | if adjust != nil { | ||
| 90 | adjust(c) | ||
| 91 | } | ||
| 92 | code := Dispatch(c, argv) | ||
| 93 | return code, out.String(), errOut.String() | ||
| 94 | } | ||
| 95 | |||
| 96 | func (f *queueFixture) mustRun(u store.User, argv ...string) string { | ||
| 97 | f.t.Helper() | ||
| 98 | code, out, errOut := f.run(u, argv...) | ||
| 99 | if code != protocol.ExitOK { | ||
| 100 | f.t.Fatalf("%v: exit %d, %s", argv, code, errOut) | ||
| 101 | } | ||
| 102 | return out | ||
| 103 | } | ||
| 104 | |||
| 105 | func (f *queueFixture) mr() store.MR { | ||
| 106 | f.t.Helper() | ||
| 107 | mr, err := f.st.MRByNumber(f.repo.ID, 1) | ||
| 108 | if err != nil { | ||
| 109 | f.t.Fatal(err) | ||
| 110 | } | ||
| 111 | return mr | ||
| 112 | } | ||
| 113 | |||
| 114 | // user creates an account granted role on the repository. | ||
| 115 | func (f *queueFixture) user(name, role string) store.User { | ||
| 116 | f.t.Helper() | ||
| 117 | id, err := f.st.CreateUser(name, false) | ||
| 118 | if err != nil { | ||
| 119 | f.t.Fatal(err) | ||
| 120 | } | ||
| 121 | if role != "" { | ||
| 122 | if err := f.st.GrantAccess(f.repo.ID, id, role); err != nil { | ||
| 123 | f.t.Fatal(err) | ||
| 124 | } | ||
| 125 | } | ||
| 126 | u, err := f.st.UserByID(id) | ||
| 127 | if err != nil { | ||
| 128 | f.t.Fatal(err) | ||
| 129 | } | ||
| 130 | return u | ||
| 131 | } | ||
| 132 | |||
| 133 | // systemComments is every system comment on !1, joined. | ||
| 134 | func (f *queueFixture) systemComments() string { | ||
| 135 | f.t.Helper() | ||
| 136 | cs, err := f.st.ListMRComments(f.mr().ID) | ||
| 137 | if err != nil { | ||
| 138 | f.t.Fatal(err) | ||
| 139 | } | ||
| 140 | var b strings.Builder | ||
| 141 | for _, c := range cs { | ||
| 142 | if c.Kind == "system" { | ||
| 143 | b.WriteString(c.Body + "\n") | ||
| 144 | } | ||
| 145 | } | ||
| 146 | return b.String() | ||
| 147 | } | ||
| 148 | |||
| 149 | func (f *queueFixture) wantMergedBy(who string) { | ||
| 150 | f.t.Helper() | ||
| 151 | mr := f.mr() | ||
| 152 | if mr.State != "merged" || mr.MergedBy != who || mr.QueuedAt != "" { | ||
| 153 | f.t.Fatalf("MR = state %s merged_by %q queued_at %q, want merged by %s and off the queue", | ||
| 154 | mr.State, mr.MergedBy, mr.QueuedAt, who) | ||
| 155 | } | ||
| 156 | if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != mr.HeadSHA { | ||
| 157 | f.t.Fatalf("main = %s, want the MR head %s", main, mr.HeadSHA) | ||
| 158 | } | ||
| 159 | } | ||
| 160 | |||
| 161 | func (f *queueFixture) wantQueued(reason string) store.MR { | ||
| 162 | f.t.Helper() | ||
| 163 | mr := f.mr() | ||
| 164 | if mr.State != "open" || mr.QueuedAt == "" || !strings.Contains(mr.QueueReason, reason) { | ||
| 165 | f.t.Fatalf("MR = state %s queued_at %q reason %q, want open and queued with %q", | ||
| 166 | mr.State, mr.QueuedAt, mr.QueueReason, reason) | ||
| 167 | } | ||
| 168 | return mr | ||
| 169 | } | ||
| 170 | |||
| 171 | // Gates that already pass merge at once: queueing is only for waiting. | ||
| 172 | func TestWhenReadyMergesAtOnceWhenGatesPass(t *testing.T) { | ||
| 173 | f := newQueueFixture(t, nil) | ||
| 174 | out := f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | ||
| 175 | if !strings.Contains(out, "merged") { | ||
| 176 | t.Fatalf("output = %q, want it to say merged", out) | ||
| 177 | } | ||
| 178 | f.wantMergedBy("alice") | ||
| 179 | } | ||
| 180 | |||
| 181 | // A status that turns the checks green merges the queued request, as the | ||
| 182 | // user who queued it, and mr show carries the queue until then. | ||
| 183 | func TestWhenReadyStatusSetMerges(t *testing.T) { | ||
| 184 | f := newQueueFixture(t, func(s *store.RepoSettings) { | ||
| 185 | s.RequireChecks = true | ||
| 186 | s.RequiredContexts = []string{"ext/test"} | ||
| 187 | }) | ||
| 188 | bob := f.user("bob", "write") | ||
| 189 | out := f.mustRun(bob, "mr", "merge", f.repo.Path(), "1", "--when-ready", "--strategy", "ff") | ||
| 190 | if !strings.Contains(out, "queued") { | ||
| 191 | t.Fatalf("output = %q, want it to say queued", out) | ||
| 192 | } | ||
| 193 | f.wantQueued("green checks") | ||
| 194 | got := mrShowJSONAt(t, f, f.alice) | ||
| 195 | if got.Queued == nil || got.Queued.By != "bob" || got.Queued.Strategy != "ff" || !strings.Contains(got.Queued.Reason, "green checks") { | ||
| 196 | t.Fatalf("mr show queued = %+v", got.Queued) | ||
| 197 | } | ||
| 198 | |||
| 199 | f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "pending") | ||
| 200 | f.wantQueued("green checks") | ||
| 201 | f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success") | ||
| 202 | f.wantMergedBy("bob") | ||
| 203 | } | ||
| 204 | |||
| 205 | func mrShowJSONAt(t *testing.T, f *queueFixture, u store.User) mrOut { | ||
| 206 | t.Helper() | ||
| 207 | var out, errOut bytes.Buffer | ||
| 208 | c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut} | ||
| 209 | c.Cfg.Server.Root = f.root | ||
| 210 | if code := Dispatch(c, []string{"mr", "show", f.repo.Path(), "1", "--json"}); code != protocol.ExitOK { | ||
| 211 | t.Fatalf("mr show: exit %d, %s", code, errOut.String()) | ||
| 212 | } | ||
| 213 | var env struct { | ||
| 214 | Data mrOut `json:"data"` | ||
| 215 | } | ||
| 216 | if err := json.Unmarshal(out.Bytes(), &env); err != nil { | ||
| 217 | t.Fatalf("mr show JSON: %v\n%s", err, out.String()) | ||
| 218 | } | ||
| 219 | return env.Data | ||
| 220 | } | ||
| 221 | |||
| 222 | // An approval that meets require_approvals merges the queued request. | ||
| 223 | func TestWhenReadyReviewMerges(t *testing.T) { | ||
| 224 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 }) | ||
| 225 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | ||
| 226 | f.wantQueued("approval") | ||
| 227 | bob := f.user("bob", "write") | ||
| 228 | f.mustRun(bob, "mr", "review", f.repo.Path(), "1", "--approve") | ||
| 229 | f.wantMergedBy("alice") | ||
| 230 | } | ||
| 231 | |||
| 232 | // Resolving the last open thread merges the queued request. | ||
| 233 | func TestWhenReadyThreadResolveMerges(t *testing.T) { | ||
| 234 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireResolved = true }) | ||
| 235 | id, err := f.st.AddDiffComment(f.mr().ID, f.alice.ID, f.headSHA, "feature.txt", "new", 1, "why?", 0, false) | ||
| 236 | if err != nil { | ||
| 237 | t.Fatal(err) | ||
| 238 | } | ||
| 239 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | ||
| 240 | f.wantQueued("threads resolved") | ||
| 241 | f.mustRun(f.alice, "mr", "resolve", f.repo.Path(), "1", strconv.FormatInt(id, 10)) | ||
| 242 | f.wantMergedBy("alice") | ||
| 243 | } | ||
| 244 | |||
| 245 | // Marking a draft ready merges the queued request. | ||
| 246 | func TestWhenReadyReadyMerges(t *testing.T) { | ||
| 247 | f := newQueueFixture(t, nil) | ||
| 248 | f.st.SetMRDraft(f.mr().ID, true) | ||
| 249 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | ||
| 250 | f.wantQueued("draft") | ||
| 251 | f.mustRun(f.alice, "mr", "ready", f.repo.Path(), "1") | ||
| 252 | f.wantMergedBy("alice") | ||
| 253 | } | ||
| 254 | |||
| 255 | // A new head keeps the request queued and has to pass on its own: a | ||
| 256 | // status on the old head moves nothing. | ||
| 257 | func TestWhenReadyNewHeadMustPassAgain(t *testing.T) { | ||
| 258 | f := newQueueFixture(t, func(s *store.RepoSettings) { | ||
| 259 | s.RequireChecks = true | ||
| 260 | s.RequiredContexts = []string{"ext/test"} | ||
| 261 | }) | ||
| 262 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | ||
| 263 | old := f.headSHA | ||
| 264 | f.write("feature.txt", "z\n") | ||
| 265 | f.git(f.src, "commit", "-q", "-am", "more") | ||
| 266 | f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD")) | ||
| 267 | f.git(f.src, "push", "-q", f.dir, "feature") | ||
| 268 | f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA) | ||
| 269 | if err := f.st.UpdateMRHead(f.mr().ID, f.headSHA, f.targetSH, false); err != nil { | ||
| 270 | t.Fatal(err) | ||
| 271 | } | ||
| 272 | TryQueuedMerge(f.st, f.cfg(), f.mr().ID) | ||
| 273 | f.wantQueued("green checks") | ||
| 274 | f.mustRun(f.alice, "status", "set", f.repo.Path(), old, "--context", "ext/test", "--state", "success") | ||
| 275 | f.wantQueued("green checks") | ||
| 276 | f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success") | ||
| 277 | f.wantMergedBy("alice") | ||
| 278 | } | ||
| 279 | |||
| 280 | func (f *queueFixture) cfg() (c config.Config) { | ||
| 281 | c.Server.Root = f.root | ||
| 282 | return c | ||
| 283 | } | ||
| 284 | |||
| 285 | // On a require-signed repository the server cannot rebase: a branch | ||
| 286 | // behind its target stays queued and says it needs a rebase. | ||
| 287 | func TestWhenReadySignedBehindStaysQueued(t *testing.T) { | ||
| 288 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireSignedCommits = true }) | ||
| 289 | f.git(f.src, "checkout", "-q", "main") | ||
| 290 | f.write("other.txt", "o\n") | ||
| 291 | f.git(f.src, "add", ".") | ||
| 292 | f.git(f.src, "commit", "-q", "-m", "target moves") | ||
| 293 | f.git(f.src, "push", "-q", f.dir, "main") | ||
| 294 | out := f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | ||
| 295 | if !strings.Contains(out, "rebase and push, and the merge stays queued") { | ||
| 296 | t.Fatalf("output = %q, want the pending reason to name the rebase", out) | ||
| 297 | } | ||
| 298 | f.wantQueued("is behind main") | ||
| 299 | |||
| 300 | // A strategy that cannot ever pass there is refused, not queued. | ||
| 301 | code, _, errOut := f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready", "--strategy", "squash") | ||
| 302 | if code != protocol.ExitDenied || !strings.Contains(errOut, "signed") { | ||
| 303 | t.Fatalf("squash on require-signed: exit %d, %s", code, errOut) | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | // A queuer who lost write access is dequeued with the reason recorded, | ||
| 308 | // and nothing merges. | ||
| 309 | func TestWhenReadyRightsLossDequeues(t *testing.T) { | ||
| 310 | f := newQueueFixture(t, func(s *store.RepoSettings) { | ||
| 311 | s.RequireChecks = true | ||
| 312 | s.RequiredContexts = []string{"ext/test"} | ||
| 313 | }) | ||
| 314 | bob := f.user("bob", "write") | ||
| 315 | f.mustRun(bob, "mr", "merge", f.repo.Path(), "1", "--when-ready") | ||
| 316 | f.wantQueued("green checks") | ||
| 317 | if err := f.st.RevokeAccess(f.repo.ID, bob.ID); err != nil { | ||
| 318 | t.Fatal(err) | ||
| 319 | } | ||
| 320 | f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success") | ||
| 321 | mr := f.mr() | ||
| 322 | if mr.State != "open" || mr.QueuedAt != "" { | ||
| 323 | t.Fatalf("MR = state %s queued_at %q, want open and dequeued", mr.State, mr.QueuedAt) | ||
| 324 | } | ||
| 325 | f.wantDequeued("bob no longer has write access") | ||
| 326 | } | ||
| 327 | |||
| 328 | // --cancel dequeues; a second cancel has nothing to take off. | ||
| 329 | func TestWhenReadyCancel(t *testing.T) { | ||
| 330 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 }) | ||
| 331 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | ||
| 332 | f.wantQueued("approval") | ||
| 333 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel") | ||
| 334 | if mr := f.mr(); mr.QueuedAt != "" { | ||
| 335 | t.Fatalf("cancel left the MR queued: %+v", mr) | ||
| 336 | } | ||
| 337 | code, _, errOut := f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel") | ||
| 338 | if code != protocol.ExitFailure || !strings.Contains(errOut, "not queued") { | ||
| 339 | t.Fatalf("second cancel: exit %d, %s", code, errOut) | ||
| 340 | } | ||
| 341 | code, _, _ = f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel", "--when-ready") | ||
| 342 | if code != protocol.ExitUsage { | ||
| 343 | t.Fatalf("--cancel --when-ready: exit %d, want usage", code) | ||
| 344 | } | ||
| 345 | // An approval after the cancel merges nothing. | ||
| 346 | f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve") | ||
| 347 | if mr := f.mr(); mr.State != "open" { | ||
| 348 | t.Fatalf("cancelled MR merged: %+v", mr) | ||
| 349 | } | ||
| 350 | } | ||
| 351 | |||
| 352 | // Closing a queued merge request dequeues it. | ||
| 353 | func TestWhenReadyCloseDequeues(t *testing.T) { | ||
| 354 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 }) | ||
| 355 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | ||
| 356 | f.mustRun(f.alice, "mr", "close", f.repo.Path(), "1") | ||
| 357 | if mr := f.mr(); mr.State != "closed" || mr.QueuedAt != "" { | ||
| 358 | t.Fatalf("closed MR = %+v, want closed and dequeued", mr) | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | // wantDequeued checks !1 is open, off the queue, and its timeline says why. | ||
| 363 | func (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. | ||
| 375 | func (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. | ||
| 382 | func 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. | ||
| 400 | func 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. | ||
| 427 | func 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. | ||
| 444 | func 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. | ||
| 466 | func 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. | ||
| 480 | func 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. | ||
| 492 | func 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. | ||
| 515 | func 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 +55 −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 | ||
| 565 | type stackRef struct { | 569 | type 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 | } |
| @@ -1031,6 +1047,16 @@ func runMRRetarget(c *Ctx, args []string) int { | |||
| 1031 | return c.fail(protocol.ExitFailure, "%v", err) | 1047 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1032 | } | 1048 | } |
| 1033 | c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("retargeted from %s to %s", old, target)) | 1049 | 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 | } | ||
| 1034 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted", | 1060 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted", |
| 1035 | fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target)) | 1061 | fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target)) |
| 1036 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | 1062 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| @@ -1108,6 +1134,7 @@ func runMRReview(c *Ctx, args []string) int { | |||
| 1108 | } | 1134 | } |
| 1109 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed", | 1135 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed", |
| 1110 | fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict)) | 1136 | fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict)) |
| 1137 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | ||
| 1111 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | 1138 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 1112 | notify(c, parts, notice{repo: repo, kind: "mr", | 1139 | notify(c, parts, notice{repo: repo, kind: "mr", |
| 1113 | subject: mrSubject(repo, mr.Number, mr.Title), | 1140 | subject: mrSubject(repo, mr.Number, mr.Title), |
| @@ -1258,7 +1285,8 @@ func runMRLabel(c *Ctx, args []string) int { | |||
| 1258 | } | 1285 | } |
| 1259 | 1286 | ||
| 1260 | func runMRMerge(c *Ctx, args []string) int { | 1287 | func 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]"}) | 1288 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, Bools: []string{"--when-ready", "--cancel"}, |
| 1289 | MaxPos: -1, Usage: c.Cmd.Usage}) | ||
| 1262 | if err != nil { | 1290 | if err != nil { |
| 1263 | return c.fail(protocol.ExitUsage, "%v", err) | 1291 | return c.fail(protocol.ExitUsage, "%v", err) |
| 1264 | } | 1292 | } |
| @@ -1267,10 +1295,25 @@ func runMRMerge(c *Ctx, args []string) int { | |||
| 1267 | if !valid[strategy] { | 1295 | if !valid[strategy] { |
| 1268 | return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase") | 1296 | return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase") |
| 1269 | } | 1297 | } |
| 1298 | if f.Has("--cancel") && (f.Has("--when-ready") || f.Has("--strategy")) { | ||
| 1299 | return c.fail(protocol.ExitUsage, "--cancel takes no other flag") | ||
| 1300 | } | ||
| 1270 | repo, mr, code := mrRef(c, rest, policy.CanWrite) | 1301 | repo, mr, code := mrRef(c, rest, policy.CanWrite) |
| 1271 | if code >= 0 { | 1302 | if code >= 0 { |
| 1272 | return code | 1303 | return code |
| 1273 | } | 1304 | } |
| 1305 | switch { | ||
| 1306 | case f.Has("--cancel"): | ||
| 1307 | return cancelQueuedMerge(c, repo, mr) | ||
| 1308 | case f.Has("--when-ready"): | ||
| 1309 | return queueMerge(c, repo, mr, strategy) | ||
| 1310 | } | ||
| 1311 | return mergeMR(c, repo, mr, strategy) | ||
| 1312 | } | ||
| 1313 | |||
| 1314 | // mergeMR merges mr now as c.User, or refuses saying why. The queue runs | ||
| 1315 | // it too, as the user who queued the merge. | ||
| 1316 | func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int { | ||
| 1274 | if code := refuseArchived(c, repo); code >= 0 { | 1317 | if code := refuseArchived(c, repo); code >= 0 { |
| 1275 | return code | 1318 | return code |
| 1276 | } | 1319 | } |
| @@ -1324,6 +1367,11 @@ func runMRMerge(c *Ctx, args []string) int { | |||
| 1324 | // rebase when fast-forward is already possible IS a fast-forward | 1367 | // rebase when fast-forward is already possible IS a fast-forward |
| 1325 | // (nothing is rewritten), so it stays legal. | 1368 | // (nothing is rewritten), so it stays legal. |
| 1326 | if repo.Settings.RequireSignedCommits { | 1369 | 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 | } | ||
| 1327 | if strategy == "merge" || strategy == "squash" || !ffPossible { | 1375 | if strategy == "merge" || strategy == "squash" || !ffPossible { |
| 1328 | return c.fail(protocol.ExitDenied, | 1376 | return c.fail(protocol.ExitDenied, |
| 1329 | "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again", | 1377 | "%s requires signed commits, so only fast-forward merges are allowed; rebase %s onto %s locally, re-push, and merge again", |
| @@ -1819,6 +1867,9 @@ func setMRDraft(c *Ctx, args []string, draft bool) int { | |||
| 1819 | } | 1867 | } |
| 1820 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft", | 1868 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft", |
| 1821 | fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft)) | 1869 | fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft)) |
| 1870 | if !draft { | ||
| 1871 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | ||
| 1872 | } | ||
| 1822 | // Marking ready is the request for review; going back to draft | 1873 | // Marking ready is the request for review; going back to draft |
| 1823 | // withdraws it and is not worth anyone's inbox. | 1874 | // withdraws it and is not worth anyone's inbox. |
| 1824 | // | 1875 | // |
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 +8
| @@ -336,6 +336,9 @@ func (s *Server) postReceive(req Request) { | |||
| 336 | if mr.State == "open" { | 336 | if mr.State == "open" { |
| 337 | s.st.SetMRState(mr.ID, "source_gone") | 337 | s.st.SetMRState(mr.ID, "source_gone") |
| 338 | } | 338 | } |
| 339 | if mr.QueuedAt != "" { | ||
| 340 | control.TryQueuedMerge(s.st, s.cfg, mr.ID) // dequeues: the source is gone | ||
| 341 | } | ||
| 339 | continue // head ref retained: the diff stays viewable | 342 | continue // head ref retained: the diff stays viewable |
| 340 | } | 343 | } |
| 341 | dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name) | 344 | dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name) |
| @@ -362,6 +365,11 @@ func (s *Server) postReceive(req Request) { | |||
| 362 | if mr.State == "source_gone" { | 365 | if mr.State == "source_gone" { |
| 363 | s.st.SetMRState(mr.ID, "open") // branch came back | 366 | s.st.SetMRState(mr.ID, "open") // branch came back |
| 364 | } | 367 | } |
| 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. | ||
| 370 | if mr.QueuedAt != "" { | ||
| 371 | control.QueuedMergePushed(s.st, s.cfg, mr.ID, req.UserID, req.Scope) | ||
| 372 | } | ||
| 365 | } | 373 | } |
| 366 | } | 374 | } |
| 367 | } | 375 | } |
internal/hookd/mergequeue_test.go added +286
| @@ -0,0 +1,286 @@ | |||
| 1 | package hookd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bytes" | ||
| 5 | "fmt" | ||
| 6 | "os" | ||
| 7 | "path/filepath" | ||
| 8 | "strings" | ||
| 9 | "testing" | ||
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/config" | ||
| 12 | "gitbay.org/gitbay/internal/control" | ||
| 13 | "gitbay.org/gitbay/internal/policy" | ||
| 14 | "gitbay.org/gitbay/internal/protocol" | ||
| 15 | "gitbay.org/gitbay/internal/store" | ||
| 16 | ) | ||
| 17 | |||
| 18 | // A push to the source branch of a queued merge request tries the merge | ||
| 19 | // again: a fast-forward merge queued while the branch was behind lands | ||
| 20 | // once the rebased branch is pushed. | ||
| 21 | func TestPostReceiveTriesQueuedMerge(t *testing.T) { | ||
| 22 | st, err := store.Open(":memory:") | ||
| 23 | if err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | t.Cleanup(func() { st.Close() }) | ||
| 27 | if err := st.MigrateUp(); err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | uid, err := st.CreateUser("alice", false) | ||
| 31 | if err != nil { | ||
| 32 | t.Fatal(err) | ||
| 33 | } | ||
| 34 | repoID, err := st.CreateRepo("user", uid, "app", "public") | ||
| 35 | if err != nil { | ||
| 36 | t.Fatal(err) | ||
| 37 | } | ||
| 38 | repo, err := st.RepoByID(repoID) | ||
| 39 | if err != nil { | ||
| 40 | t.Fatal(err) | ||
| 41 | } | ||
| 42 | root := t.TempDir() | ||
| 43 | f := &shapeFixture{t: t, st: st, repo: repo, uid: uid, root: root, src: filepath.Join(root, "src")} | ||
| 44 | f.dir = control.RepoDir(root, repo.OwnerName, repo.Name) | ||
| 45 | cfg := config.Config{} | ||
| 46 | cfg.Server.Root, cfg.Server.SiteURL = root, "https://x.test" | ||
| 47 | srv := &Server{cfg: cfg, st: st} | ||
| 48 | |||
| 49 | os.MkdirAll(f.src, 0o755) | ||
| 50 | f.git(root, "init", "-q", "-b", "main", "src") | ||
| 51 | f.write("README", "x\n") | ||
| 52 | f.git(f.src, "add", ".") | ||
| 53 | f.git(f.src, "commit", "-q", "-m", "base") | ||
| 54 | f.git(f.src, "checkout", "-q", "-b", "feature") | ||
| 55 | f.write("feature.txt", "y\n") | ||
| 56 | f.git(f.src, "add", ".") | ||
| 57 | f.git(f.src, "commit", "-q", "-m", "change") | ||
| 58 | head := f.sha("HEAD") | ||
| 59 | f.git(f.src, "checkout", "-q", "main") | ||
| 60 | f.write("other.txt", "o\n") | ||
| 61 | f.git(f.src, "add", ".") | ||
| 62 | f.git(f.src, "commit", "-q", "-m", "target moves") | ||
| 63 | os.MkdirAll(filepath.Dir(f.dir), 0o755) | ||
| 64 | f.git(root, "init", "-q", "--bare", f.dir) | ||
| 65 | f.sync() | ||
| 66 | f.git(f.dir, "update-ref", "refs/merge-requests/1/head", head) | ||
| 67 | if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", head, "md", false); err != nil { | ||
| 68 | t.Fatal(err) | ||
| 69 | } | ||
| 70 | |||
| 71 | var out, errOut bytes.Buffer | ||
| 72 | c := &control.Ctx{User: store.User{ID: uid, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut} | ||
| 73 | if code := control.Dispatch(c, []string{"mr", "merge", repo.Path(), "1", "--when-ready", "--strategy", "ff"}); code != protocol.ExitOK { | ||
| 74 | t.Fatalf("mr merge --when-ready: exit %d, %s", code, errOut.String()) | ||
| 75 | } | ||
| 76 | if mr, _ := st.MRByNumber(repo.ID, 1); mr.QueuedAt == "" || !strings.Contains(mr.QueueReason, "fast-forward not possible") { | ||
| 77 | t.Fatalf("queued MR = %+v, want it waiting on a fast-forward", mr) | ||
| 78 | } | ||
| 79 | |||
| 80 | f.git(f.src, "checkout", "-q", "feature") | ||
| 81 | f.git(f.src, "rebase", "-q", "main") | ||
| 82 | rebased := f.sha("HEAD") | ||
| 83 | f.sync() | ||
| 84 | srv.postReceive(Request{RepoID: repo.ID, UserID: uid, Scope: "full", Updates: []policy.RefUpdate{ | ||
| 85 | {Ref: "refs/heads/feature", Old: head, New: rebased, IsForce: true}}}) | ||
| 86 | |||
| 87 | mr, err := st.MRByNumber(repo.ID, 1) | ||
| 88 | if err != nil { | ||
| 89 | t.Fatal(err) | ||
| 90 | } | ||
| 91 | if mr.State != "merged" || mr.MergedBy != "alice" || mr.QueuedAt != "" { | ||
| 92 | t.Fatalf("MR after push = %s by %q queued %q (%s), want merged by alice", mr.State, mr.MergedBy, mr.QueuedAt, mr.QueueReason) | ||
| 93 | } | ||
| 94 | if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != rebased { | ||
| 95 | t.Fatalf("main = %s, want %s", main, rebased) | ||
| 96 | } | ||
| 97 | } | ||
| 98 | |||
| 99 | // A fork author who cannot write to the target pushes to the source of a | ||
| 100 | // merge request someone else queued: the queue is not theirs to use, so | ||
| 101 | // it is dequeued rather than tried. Deleting the source branch dequeues | ||
| 102 | // it too. | ||
| 103 | func TestPostReceiveDequeuesQueuedMerge(t *testing.T) { | ||
| 104 | st, err := store.Open(":memory:") | ||
| 105 | if err != nil { | ||
| 106 | t.Fatal(err) | ||
| 107 | } | ||
| 108 | t.Cleanup(func() { st.Close() }) | ||
| 109 | if err := st.MigrateUp(); err != nil { | ||
| 110 | t.Fatal(err) | ||
| 111 | } | ||
| 112 | alice, _ := st.CreateUser("alice", false) | ||
| 113 | bob, _ := st.CreateUser("bob", false) | ||
| 114 | targetID, _ := st.CreateRepo("user", alice, "app", "public") | ||
| 115 | forkID, _ := st.CreateRepo("user", bob, "app", "public") | ||
| 116 | target, _ := st.RepoByID(targetID) | ||
| 117 | fork, _ := st.RepoByID(forkID) | ||
| 118 | if _, err := st.UpdateRepoSettings(target.ID, func(s *store.RepoSettings) { s.RequireApprovals = 1 }); err != nil { | ||
| 119 | t.Fatal(err) | ||
| 120 | } | ||
| 121 | root := t.TempDir() | ||
| 122 | f := &shapeFixture{t: t, st: st, repo: fork, uid: bob, root: root, src: filepath.Join(root, "src")} | ||
| 123 | cfg := config.Config{} | ||
| 124 | cfg.Server.Root, cfg.Server.SiteURL = root, "https://x.test" | ||
| 125 | srv := &Server{cfg: cfg, st: st} | ||
| 126 | |||
| 127 | os.MkdirAll(f.src, 0o755) | ||
| 128 | f.git(root, "init", "-q", "-b", "main", "src") | ||
| 129 | f.write("README", "x\n") | ||
| 130 | f.git(f.src, "add", ".") | ||
| 131 | f.git(f.src, "commit", "-q", "-m", "base") | ||
| 132 | f.git(f.src, "checkout", "-q", "-b", "feature") | ||
| 133 | f.write("feature.txt", "y\n") | ||
| 134 | f.git(f.src, "add", ".") | ||
| 135 | f.git(f.src, "commit", "-q", "-m", "change") | ||
| 136 | head := f.sha("HEAD") | ||
| 137 | for _, r := range []store.Repo{target, fork} { | ||
| 138 | dir := control.RepoDir(root, r.OwnerName, r.Name) | ||
| 139 | os.MkdirAll(filepath.Dir(dir), 0o755) | ||
| 140 | f.git(root, "init", "-q", "--bare", dir) | ||
| 141 | f.git(f.src, "push", "-q", dir, "main", "feature") | ||
| 142 | } | ||
| 143 | targetDir := control.RepoDir(root, target.OwnerName, target.Name) | ||
| 144 | f.git(targetDir, "update-ref", "refs/merge-requests/1/head", head) | ||
| 145 | f.git(targetDir, "update-ref", "refs/merge-requests/2/head", head) | ||
| 146 | for range 2 { | ||
| 147 | if _, err := st.CreateMR(target.ID, bob, fork.ID, "feature", "main", "t", "", head, "md", false); err != nil { | ||
| 148 | t.Fatal(err) | ||
| 149 | } | ||
| 150 | } | ||
| 151 | for _, n := range []string{"1", "2"} { | ||
| 152 | var out, errOut bytes.Buffer | ||
| 153 | c := &control.Ctx{User: store.User{ID: alice, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut} | ||
| 154 | if code := control.Dispatch(c, []string{"mr", "merge", target.Path(), n, "--when-ready"}); code != protocol.ExitOK { | ||
| 155 | t.Fatalf("queue !%s: exit %d, %s", n, code, errOut.String()) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | systemSays := func(n int64, want string) { | ||
| 159 | t.Helper() | ||
| 160 | mr, err := st.MRByNumber(target.ID, n) | ||
| 161 | if err != nil { | ||
| 162 | t.Fatal(err) | ||
| 163 | } | ||
| 164 | if mr.QueuedAt != "" || mr.State == "merged" { | ||
| 165 | t.Fatalf("!%d = state %s queued_at %q, want dequeued and not merged", n, mr.State, mr.QueuedAt) | ||
| 166 | } | ||
| 167 | cs, _ := st.ListMRComments(mr.ID) | ||
| 168 | for _, c := range cs { | ||
| 169 | if c.Kind == "system" && strings.Contains(c.Body, want) { | ||
| 170 | return | ||
| 171 | } | ||
| 172 | } | ||
| 173 | t.Fatalf("!%d timeline does not say %q: %+v", n, want, cs) | ||
| 174 | } | ||
| 175 | |||
| 176 | // !2 is closed first so the push reaches only !1. | ||
| 177 | st.MarkClosed(mustMR(t, st, target.ID, 2).ID, alice, "") | ||
| 178 | f.write("feature.txt", "z\n") | ||
| 179 | f.git(f.src, "commit", "-q", "-am", "more") | ||
| 180 | pushed := f.sha("HEAD") | ||
| 181 | forkDir := control.RepoDir(root, fork.OwnerName, fork.Name) | ||
| 182 | f.git(f.src, "push", "-q", forkDir, "feature") | ||
| 183 | srv.postReceive(Request{RepoID: fork.ID, UserID: bob, Scope: "full", Updates: []policy.RefUpdate{ | ||
| 184 | {Ref: "refs/heads/feature", Old: head, New: pushed}}}) | ||
| 185 | systemSays(1, "bob pushed and cannot merge into alice/app") | ||
| 186 | |||
| 187 | // Deleting the source dequeues a queued request. | ||
| 188 | st.SetMRState(mustMR(t, st, target.ID, 2).ID, "open") | ||
| 189 | var out, errOut bytes.Buffer | ||
| 190 | c := &control.Ctx{User: store.User{ID: alice, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut} | ||
| 191 | if code := control.Dispatch(c, []string{"mr", "merge", target.Path(), "2", "--when-ready"}); code != protocol.ExitOK { | ||
| 192 | t.Fatalf("queue !2: exit %d, %s", code, errOut.String()) | ||
| 193 | } | ||
| 194 | f.git(forkDir, "update-ref", "-d", "refs/heads/feature") | ||
| 195 | srv.postReceive(Request{RepoID: fork.ID, UserID: bob, Scope: "full", Updates: []policy.RefUpdate{ | ||
| 196 | {Ref: "refs/heads/feature", Old: pushed, New: zeroSHA40, IsDelete: true}}}) | ||
| 197 | systemSays(2, "the source branch was deleted") | ||
| 198 | } | ||
| 199 | |||
| 200 | func mustMR(t *testing.T, st *store.Store, repoID, n int64) store.MR { | ||
| 201 | t.Helper() | ||
| 202 | mr, err := st.MRByNumber(repoID, n) | ||
| 203 | if err != nil { | ||
| 204 | t.Fatal(err) | ||
| 205 | } | ||
| 206 | return mr | ||
| 207 | } | ||
| 208 | |||
| 209 | // A push the queue cannot attribute to a writer dequeues: one from a | ||
| 210 | // write deploy key, though the account that registered it can write, and | ||
| 211 | // one whose pusher cannot be looked up. | ||
| 212 | func TestPostReceiveDequeuesUncheckedPush(t *testing.T) { | ||
| 213 | for _, tc := range []struct { | ||
| 214 | name string | ||
| 215 | user func(alice int64) int64 | ||
| 216 | scope func(repoID int64) string | ||
| 217 | reason string | ||
| 218 | }{ | ||
| 219 | {"deploy key", func(a int64) int64 { return a }, func(id int64) string { return fmt.Sprintf("deploy:%d:rw", id) }, | ||
| 220 | "a deploy key pushed, and a deploy key cannot merge"}, | ||
| 221 | {"unknown pusher", func(int64) int64 { return 9999 }, func(int64) string { return "full" }, | ||
| 222 | "could not check who pushed"}, | ||
| 223 | } { | ||
| 224 | t.Run(tc.name, func(t *testing.T) { | ||
| 225 | st, err := store.Open(":memory:") | ||
| 226 | if err != nil { | ||
| 227 | t.Fatal(err) | ||
| 228 | } | ||
| 229 | t.Cleanup(func() { st.Close() }) | ||
| 230 | if err := st.MigrateUp(); err != nil { | ||
| 231 | t.Fatal(err) | ||
| 232 | } | ||
| 233 | alice, _ := st.CreateUser("alice", false) | ||
| 234 | repoID, _ := st.CreateRepo("user", alice, "app", "public") | ||
| 235 | st.UpdateRepoSettings(repoID, func(s *store.RepoSettings) { s.RequireApprovals = 1 }) | ||
| 236 | repo, _ := st.RepoByID(repoID) | ||
| 237 | root := t.TempDir() | ||
| 238 | f := &shapeFixture{t: t, st: st, repo: repo, uid: alice, root: root, src: filepath.Join(root, "src")} | ||
| 239 | f.dir = control.RepoDir(root, repo.OwnerName, repo.Name) | ||
| 240 | cfg := config.Config{} | ||
| 241 | cfg.Server.Root = root | ||
| 242 | srv := &Server{cfg: cfg, st: st} | ||
| 243 | os.MkdirAll(f.src, 0o755) | ||
| 244 | f.git(root, "init", "-q", "-b", "main", "src") | ||
| 245 | f.write("README", "x\n") | ||
| 246 | f.git(f.src, "add", ".") | ||
| 247 | f.git(f.src, "commit", "-q", "-m", "base") | ||
| 248 | f.git(f.src, "checkout", "-q", "-b", "feature") | ||
| 249 | f.write("feature.txt", "y\n") | ||
| 250 | f.git(f.src, "add", ".") | ||
| 251 | f.git(f.src, "commit", "-q", "-m", "change") | ||
| 252 | head := f.sha("HEAD") | ||
| 253 | os.MkdirAll(filepath.Dir(f.dir), 0o755) | ||
| 254 | f.git(root, "init", "-q", "--bare", f.dir) | ||
| 255 | f.sync() | ||
| 256 | f.git(f.dir, "update-ref", "refs/merge-requests/1/head", head) | ||
| 257 | if _, err := st.CreateMR(repo.ID, alice, repo.ID, "feature", "main", "t", "", head, "md", false); err != nil { | ||
| 258 | t.Fatal(err) | ||
| 259 | } | ||
| 260 | var out, errOut bytes.Buffer | ||
| 261 | c := &control.Ctx{User: store.User{ID: alice, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut} | ||
| 262 | if code := control.Dispatch(c, []string{"mr", "merge", repo.Path(), "1", "--when-ready"}); code != protocol.ExitOK { | ||
| 263 | t.Fatalf("queue: exit %d, %s", code, errOut.String()) | ||
| 264 | } | ||
| 265 | |||
| 266 | f.write("feature.txt", "z\n") | ||
| 267 | f.git(f.src, "commit", "-q", "-am", "more") | ||
| 268 | pushed := f.sha("HEAD") | ||
| 269 | f.sync() | ||
| 270 | srv.postReceive(Request{RepoID: repo.ID, UserID: tc.user(alice), Scope: tc.scope(repo.ID), | ||
| 271 | Updates: []policy.RefUpdate{{Ref: "refs/heads/feature", Old: head, New: pushed}}}) | ||
| 272 | |||
| 273 | mr := mustMR(t, st, repo.ID, 1) | ||
| 274 | if mr.QueuedAt != "" || mr.State != "open" { | ||
| 275 | t.Fatalf("!1 = state %s queued_at %q, want open and dequeued", mr.State, mr.QueuedAt) | ||
| 276 | } | ||
| 277 | cs, _ := st.ListMRComments(mr.ID) | ||
| 278 | for _, c := range cs { | ||
| 279 | if c.Kind == "system" && strings.Contains(c.Body, tc.reason) { | ||
| 280 | return | ||
| 281 | } | ||
| 282 | } | ||
| 283 | t.Fatalf("timeline does not say %q: %+v", tc.reason, cs) | ||
| 284 | }) | ||
| 285 | } | ||
| 286 | } | ||
internal/httpd/mractions.go +9 −2
| @@ -78,8 +78,15 @@ func (s *Server) mrLabelSubmit(w http.ResponseWriter, r *http.Request, u store.U | |||
| 78 | 78 | ||
| 79 | func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | 79 | func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 80 | args := []string{} | 80 | args := []string{} |
| 81 | if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" { | 81 | if r.FormValue("cancel") == "on" { |
| 82 | args = append(args, "--strategy", st) | 82 | args = append(args, "--cancel") |
| 83 | } else { | ||
| 84 | if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" { | ||
| 85 | args = append(args, "--strategy", st) | ||
| 86 | } | ||
| 87 | if r.FormValue("when_ready") == "on" { | ||
| 88 | args = append(args, "--when-ready") | ||
| 89 | } | ||
| 83 | } | 90 | } |
| 84 | _, msg, code := s.runControlCode(u, mrArgs(r, "merge", args...)) | 91 | _, msg, code := s.runControlCode(u, mrArgs(r, "merge", args...)) |
| 85 | s.done(w, r, code, msg, s.mrRedirect) | 92 | s.done(w, r, code, msg, s.mrRedirect) |
internal/store/dashboard.go +10 −6
| @@ -10,6 +10,7 @@ type DashboardItem struct { | |||
| 10 | Author string | 10 | Author string |
| 11 | State string | 11 | State string |
| 12 | UpdatedAt string | 12 | UpdatedAt string |
| 13 | Queued bool // a merge request with a queued merge | ||
| 13 | } | 14 | } |
| 14 | 15 | ||
| 15 | // reachableCond filters to repositories the user owns, is granted on, or | 16 | // reachableCond filters to repositories the user owns, is granted on, or |
| @@ -39,7 +40,7 @@ func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) | |||
| 39 | var out []DashboardItem | 40 | var out []DashboardItem |
| 40 | for rows.Next() { | 41 | for rows.Next() { |
| 41 | var d DashboardItem | 42 | var d DashboardItem |
| 42 | if err := rows.Scan(&d.RepoPath, &d.Number, &d.Title, &d.Author, &d.State, &d.UpdatedAt); err != nil { | 43 | if err := rows.Scan(&d.RepoPath, &d.Number, &d.Title, &d.Author, &d.State, &d.UpdatedAt, &d.Queued); err != nil { |
| 43 | return nil, err | 44 | return nil, err |
| 44 | } | 45 | } |
| 45 | out = append(out, d) | 46 | out = append(out, d) |
| @@ -51,7 +52,8 @@ func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) | |||
| 51 | // each still walks the 0035 index that supplies its ORDER BY. | 52 | // each still walks the 0035 index that supplies its ORDER BY. |
| 52 | const dashboardMRsQuery = ` | 53 | const dashboardMRsQuery = ` |
| 53 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | 54 | SELECT COALESCE(u.username, o.name) || '/' || r.name, |
| 54 | x.number, x.title, au.username, x.state, x.updated_at | 55 | x.number, x.title, au.username, x.state, x.updated_at, |
| 56 | EXISTS (SELECT 1 FROM mr_merge_queue q WHERE q.mr_id = x.id) | ||
| 55 | FROM merge_requests x | 57 | FROM merge_requests x |
| 56 | JOIN repos r ON r.id = x.repo_id | 58 | JOIN repos r ON r.id = x.repo_id |
| 57 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | 59 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| @@ -69,7 +71,7 @@ func (s *Store) DashboardMRs(userID int64) ([]DashboardItem, error) { | |||
| 69 | // DashboardIssues is the issue counterpart of DashboardMRs. | 71 | // DashboardIssues is the issue counterpart of DashboardMRs. |
| 70 | const dashboardIssuesQuery = ` | 72 | const dashboardIssuesQuery = ` |
| 71 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | 73 | SELECT COALESCE(u.username, o.name) || '/' || r.name, |
| 72 | x.number, x.title, au.username, x.state, x.updated_at | 74 | x.number, x.title, au.username, x.state, x.updated_at, 0 |
| 73 | FROM issues x | 75 | FROM issues x |
| 74 | JOIN repos r ON r.id = x.repo_id | 76 | JOIN repos r ON r.id = x.repo_id |
| 75 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | 77 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| @@ -136,7 +138,8 @@ func (s *Store) PinnedRepos(userID int64) ([]Repo, error) { | |||
| 136 | // current head. | 138 | // current head. |
| 137 | const reviewQueueQuery = ` | 139 | const reviewQueueQuery = ` |
| 138 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | 140 | SELECT COALESCE(u.username, o.name) || '/' || r.name, |
| 139 | x.number, x.title, au.username, x.state, x.updated_at | 141 | x.number, x.title, au.username, x.state, x.updated_at, |
| 142 | EXISTS (SELECT 1 FROM mr_merge_queue q WHERE q.mr_id = x.id) | ||
| 140 | FROM merge_requests x | 143 | FROM merge_requests x |
| 141 | JOIN repos r ON r.id = x.repo_id | 144 | JOIN repos r ON r.id = x.repo_id |
| 142 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id | 145 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| @@ -159,7 +162,8 @@ const reviewQueueQuery = ` | |||
| 159 | // merge_requests table is the whole instance. | 162 | // merge_requests table is the whole instance. |
| 160 | const requestedReviewsQuery = ` | 163 | const requestedReviewsQuery = ` |
| 161 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | 164 | SELECT COALESCE(u.username, o.name) || '/' || r.name, |
| 162 | x.number, x.title, au.username, x.state, x.updated_at | 165 | x.number, x.title, au.username, x.state, x.updated_at, |
| 166 | EXISTS (SELECT 1 FROM mr_merge_queue q WHERE q.mr_id = x.id) | ||
| 163 | FROM mr_review_requests rr | 167 | FROM mr_review_requests rr |
| 164 | JOIN merge_requests x ON x.id = rr.mr_id | 168 | JOIN merge_requests x ON x.id = rr.mr_id |
| 165 | JOIN repos r ON r.id = x.repo_id | 169 | JOIN repos r ON r.id = x.repo_id |
| @@ -233,7 +237,7 @@ func (s *Store) OpenCounts(repoID int64) (issues, mrs int) { | |||
| 233 | // is the whole instance. | 237 | // is the whole instance. |
| 234 | const assignedIssuesQuery = ` | 238 | const assignedIssuesQuery = ` |
| 235 | SELECT COALESCE(u.username, o.name) || '/' || r.name, | 239 | SELECT COALESCE(u.username, o.name) || '/' || r.name, |
| 236 | x.number, x.title, au.username, x.state, x.updated_at | 240 | x.number, x.title, au.username, x.state, x.updated_at, 0 |
| 237 | FROM issue_assignees ia | 241 | FROM issue_assignees ia |
| 238 | JOIN issues x ON x.id = ia.issue_id | 242 | JOIN issues x ON x.id = ia.issue_id |
| 239 | JOIN repos r ON r.id = x.repo_id | 243 | JOIN repos r ON r.id = x.repo_id |
internal/store/mergequeue.go added +99
| @@ -0,0 +1,99 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "database/sql" | ||
| 5 | "errors" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // MRByID loads a merge request by its row id, without labels or review | ||
| 9 | // requests. | ||
| 10 | func (s *Store) MRByID(id int64) (MR, error) { | ||
| 11 | m, err := scanMR(s.DB.QueryRow(mrSelect+" WHERE m.id = ?", id)) | ||
| 12 | if errors.Is(err, sql.ErrNoRows) { | ||
| 13 | return m, ErrNotFound | ||
| 14 | } | ||
| 15 | return m, err | ||
| 16 | } | ||
| 17 | |||
| 18 | // QueueMerge queues a merge request to merge as userID with strategy | ||
| 19 | // ("" for the default) once its gates pass, 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. | ||
| 22 | func (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 | } | ||
| 30 | _, err := s.DB.Exec(` | ||
| 31 | INSERT INTO mr_merge_queue (mr_id, user_id, strategy, credential, key_id, token_id) | ||
| 32 | VALUES (?, ?, ?, ?, ?, ?) | ||
| 33 | ON CONFLICT (mr_id) DO UPDATE SET user_id = excluded.user_id, | ||
| 34 | strategy = excluded.strategy, credential = excluded.credential, | ||
| 35 | key_id = excluded.key_id, token_id = excluded.token_id, reason = '', | ||
| 36 | queued_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`, | ||
| 37 | mrID, userID, strategy, credential, nullID(keyID), nullID(tokenID)) | ||
| 38 | return err | ||
| 39 | } | ||
| 40 | |||
| 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. | ||
| 44 | type QueueCredential struct { | ||
| 45 | Kind string | ||
| 46 | KeyID int64 | ||
| 47 | TokenID int64 | ||
| 48 | } | ||
| 49 | |||
| 50 | func (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 | |||
| 61 | // DequeueMerge takes a merge request off the queue, reporting whether it | ||
| 62 | // was on it. | ||
| 63 | func (s *Store) DequeueMerge(mrID int64) (bool, error) { | ||
| 64 | res, err := s.DB.Exec("DELETE FROM mr_merge_queue WHERE mr_id = ?", mrID) | ||
| 65 | if err != nil { | ||
| 66 | return false, err | ||
| 67 | } | ||
| 68 | n, err := res.RowsAffected() | ||
| 69 | return n > 0, err | ||
| 70 | } | ||
| 71 | |||
| 72 | // SetMergeQueueReason records why the last attempt at a queued merge did | ||
| 73 | // not merge. | ||
| 74 | func (s *Store) SetMergeQueueReason(mrID int64, reason string) error { | ||
| 75 | _, err := s.DB.Exec("UPDATE mr_merge_queue SET reason = ? WHERE mr_id = ?", reason, mrID) | ||
| 76 | return err | ||
| 77 | } | ||
| 78 | |||
| 79 | // QueuedMRsAtHead is the queued, open merge requests of a repository | ||
| 80 | // whose head is sha: the ones a status reported on sha can move. | ||
| 81 | func (s *Store) QueuedMRsAtHead(repoID int64, sha string) ([]int64, error) { | ||
| 82 | rows, err := s.DB.Query(` | ||
| 83 | SELECT m.id FROM mr_merge_queue q JOIN merge_requests m ON m.id = q.mr_id | ||
| 84 | WHERE m.repo_id = ? AND m.head_sha = ? AND m.state = 'open' | ||
| 85 | ORDER BY m.number`, repoID, sha) | ||
| 86 | if err != nil { | ||
| 87 | return nil, err | ||
| 88 | } | ||
| 89 | defer rows.Close() | ||
| 90 | var ids []int64 | ||
| 91 | for rows.Next() { | ||
| 92 | var id int64 | ||
| 93 | if err := rows.Scan(&id); err != nil { | ||
| 94 | return nil, err | ||
| 95 | } | ||
| 96 | ids = append(ids, id) | ||
| 97 | } | ||
| 98 | return ids, rows.Err() | ||
| 99 | } | ||
internal/store/mergequeue_test.go added +136
| @@ -0,0 +1,136 @@ | |||
| 1 | package store | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 7 | |||
| 8 | // A queued merge rides on the merge request row, re-queueing replaces | ||
| 9 | // the queuer and strategy and clears the reason, and dequeueing says | ||
| 10 | // whether there was anything to take off. | ||
| 11 | func TestMergeQueueRoundTrip(t *testing.T) { | ||
| 12 | s, repoID, uid := mrFixture(t) | ||
| 13 | mr, err := s.MRByNumber(repoID, 1) | ||
| 14 | if err != nil { | ||
| 15 | t.Fatal(err) | ||
| 16 | } | ||
| 17 | if mr.QueuedAt != "" { | ||
| 18 | t.Fatalf("fresh MR is queued: %+v", mr) | ||
| 19 | } | ||
| 20 | if err := s.QueueMerge(mr.ID, uid, "ff", 0, 0); err != nil { | ||
| 21 | t.Fatal(err) | ||
| 22 | } | ||
| 23 | if err := s.SetMergeQueueReason(mr.ID, "checks pending"); err != nil { | ||
| 24 | t.Fatal(err) | ||
| 25 | } | ||
| 26 | mr, _ = s.MRByNumber(repoID, 1) | ||
| 27 | if mr.QueuedAt == "" || mr.QueuedBy != "cmc" || mr.QueuedByID != uid || mr.QueueStrategy != "ff" || mr.QueueReason != "checks pending" { | ||
| 28 | t.Fatalf("queued MR: %+v", mr) | ||
| 29 | } | ||
| 30 | byID, err := s.MRByID(mr.ID) | ||
| 31 | if err != nil || byID.Number != 1 || byID.QueueStrategy != "ff" { | ||
| 32 | t.Fatalf("MRByID = %+v, %v", byID, err) | ||
| 33 | } | ||
| 34 | |||
| 35 | if err := s.QueueMerge(mr.ID, uid, "merge", 0, 0); err != nil { | ||
| 36 | t.Fatal(err) | ||
| 37 | } | ||
| 38 | mr, _ = s.MRByNumber(repoID, 1) | ||
| 39 | if mr.QueueStrategy != "merge" || mr.QueueReason != "" { | ||
| 40 | t.Fatalf("re-queued MR: %+v", mr) | ||
| 41 | } | ||
| 42 | |||
| 43 | ids, err := s.QueuedMRsAtHead(repoID, "abc123") | ||
| 44 | if err != nil || !slices.Equal(ids, []int64{mr.ID}) { | ||
| 45 | t.Fatalf("QueuedMRsAtHead = %v, %v", ids, err) | ||
| 46 | } | ||
| 47 | if ids, _ := s.QueuedMRsAtHead(repoID, "other"); len(ids) != 0 { | ||
| 48 | t.Fatalf("QueuedMRsAtHead(other) = %v", ids) | ||
| 49 | } | ||
| 50 | |||
| 51 | if ok, err := s.DequeueMerge(mr.ID); err != nil || !ok { | ||
| 52 | t.Fatalf("DequeueMerge = %v, %v", ok, err) | ||
| 53 | } | ||
| 54 | if ok, _ := s.DequeueMerge(mr.ID); ok { | ||
| 55 | t.Fatal("second DequeueMerge found a row") | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | // A merge request that leaves the open states leaves the queue with it, | ||
| 60 | // whichever path merged or closed it. | ||
| 61 | func TestMergeQueueLeftOnMergeOrClose(t *testing.T) { | ||
| 62 | s, repoID, uid := mrFixture(t) | ||
| 63 | mr, _ := s.MRByNumber(repoID, 1) | ||
| 64 | for _, mark := range []func() error{ | ||
| 65 | func() error { return s.MarkMerged(mr.ID, "base", uid, "") }, | ||
| 66 | func() error { return s.MarkClosed(mr.ID, uid, "") }, | ||
| 67 | } { | ||
| 68 | if err := s.SetMRState(mr.ID, "open"); err != nil { | ||
| 69 | t.Fatal(err) | ||
| 70 | } | ||
| 71 | if err := s.QueueMerge(mr.ID, uid, "", 0, 0); err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 74 | if err := mark(); err != nil { | ||
| 75 | t.Fatal(err) | ||
| 76 | } | ||
| 77 | got, _ := s.MRByNumber(repoID, 1) | ||
| 78 | if got.QueuedAt != "" { | ||
| 79 | t.Fatalf("%s MR still queued: %+v", got.State, got) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | // source_gone keeps it: the branch can come back. | ||
| 83 | if err := s.SetMRState(mr.ID, "open"); err != nil { | ||
| 84 | t.Fatal(err) | ||
| 85 | } | ||
| 86 | s.QueueMerge(mr.ID, uid, "", 0, 0) | ||
| 87 | s.SetMRState(mr.ID, "source_gone") | ||
| 88 | if got, _ := s.MRByNumber(repoID, 1); got.QueuedAt == "" { | ||
| 89 | t.Fatal("source_gone dropped the queued merge") | ||
| 90 | } | ||
| 91 | } | ||
| 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. | ||
| 96 | func 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. | ||
| 128 | func 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.down.sql added +2
| @@ -0,0 +1,2 @@ | |||
| 1 | DROP TRIGGER mr_merge_queue_leave; | ||
| 2 | DROP TABLE mr_merge_queue; | ||
internal/store/migrations/0067_merge_queue.up.sql added +25
| @@ -0,0 +1,25 @@ | |||
| 1 | -- A merge request queued with `mr merge --when-ready`: merged as user_id | ||
| 2 | -- with strategy once its gates pass. reason is why the last attempt did | ||
| 3 | -- not merge. A merge request that is merged or closed leaves the queue, | ||
| 4 | -- whichever path did it. | ||
| 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. | ||
| 10 | CREATE TABLE mr_merge_queue ( | ||
| 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')) | ||
| 19 | ); | ||
| 20 | CREATE INDEX mr_merge_queue_user ON mr_merge_queue(user_id); | ||
| 21 | CREATE TRIGGER mr_merge_queue_leave AFTER UPDATE OF state ON merge_requests | ||
| 22 | WHEN NEW.state IN ('merged', 'closed') | ||
| 23 | BEGIN | ||
| 24 | DELETE FROM mr_merge_queue WHERE mr_id = NEW.id; | ||
| 25 | END; | ||
internal/store/mrs.go +15 −3
| @@ -38,6 +38,13 @@ type MR struct { | |||
| 38 | // ReviewRequests is who has been asked, directly, for a review — the | 38 | // ReviewRequests is who has been asked, directly, for a review — the |
| 39 | // mr review request counterpart of Issue.Assignees. | 39 | // mr review request counterpart of Issue.Assignees. |
| 40 | ReviewRequests []string | 40 | ReviewRequests []string |
| 41 | // The queued merge (mr merge --when-ready); QueuedAt is "" when there | ||
| 42 | // is none. QueueReason is why the last attempt did not merge. | ||
| 43 | QueuedByID int64 | ||
| 44 | QueuedBy string | ||
| 45 | QueueStrategy string | ||
| 46 | QueueReason string | ||
| 47 | QueuedAt string | ||
| 41 | } | 48 | } |
| 42 | 49 | ||
| 43 | type MRReview struct { | 50 | type MRReview struct { |
| @@ -96,7 +103,9 @@ const mrSelect = ` | |||
| 96 | m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, | 103 | m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, |
| 97 | COALESCE(ms.title, ''), m.head_sha, | 104 | COALESCE(ms.title, ''), m.head_sha, |
| 98 | m.merged_base, m.merged_at, COALESCE(mu.username, ''), | 105 | m.merged_base, m.merged_at, COALESCE(mu.username, ''), |
| 99 | m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at | 106 | m.closed_at, COALESCE(cu.username, ''), COALESCE(m.superseded_by, 0), m.created_at, m.updated_at, |
| 107 | COALESCE(q.user_id, 0), COALESCE(qu.username, ''), COALESCE(q.strategy, ''), | ||
| 108 | COALESCE(q.reason, ''), COALESCE(q.queued_at, '') | ||
| 100 | FROM merge_requests m | 109 | FROM merge_requests m |
| 101 | JOIN users u ON u.id = m.author_id | 110 | JOIN users u ON u.id = m.author_id |
| 102 | LEFT JOIN users mu ON mu.id = m.merged_by | 111 | LEFT JOIN users mu ON mu.id = m.merged_by |
| @@ -104,13 +113,16 @@ const mrSelect = ` | |||
| 104 | LEFT JOIN repos sr ON sr.id = m.source_repo_id | 113 | LEFT JOIN repos sr ON sr.id = m.source_repo_id |
| 105 | LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id | 114 | LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id |
| 106 | LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id | 115 | LEFT JOIN orgs so ON sr.owner_kind = 'org' AND so.id = sr.owner_id |
| 107 | LEFT JOIN milestones ms ON ms.id = m.milestone_id` | 116 | LEFT JOIN milestones ms ON ms.id = m.milestone_id |
| 117 | LEFT JOIN mr_merge_queue q ON q.mr_id = m.id | ||
| 118 | LEFT JOIN users qu ON qu.id = q.user_id` | ||
| 108 | 119 | ||
| 109 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { | 120 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { |
| 110 | var m MR | 121 | var m MR |
| 111 | err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, | 122 | err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, |
| 112 | &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase, | 123 | &m.SourceRef, &m.TargetRef, &m.Title, &m.Body, &m.BodyFormat, &m.State, &m.Draft, &m.Milestone, &m.HeadSHA, &m.MergedBase, |
| 113 | &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt) | 124 | &m.MergedAt, &m.MergedBy, &m.ClosedAt, &m.ClosedBy, &m.SupersededBy, &m.CreatedAt, &m.UpdatedAt, |
| 125 | &m.QueuedByID, &m.QueuedBy, &m.QueueStrategy, &m.QueueReason, &m.QueuedAt) | ||
| 114 | return m, err | 126 | return m, err |
| 115 | } | 127 | } |
| 116 | 128 | ||
internal/store/tokens.go +13
| @@ -64,6 +64,19 @@ func (s *Store) APITokenUser(tokenHash string) (User, APIToken, error) { | |||
| 64 | return u, t, err | 64 | return u, t, err |
| 65 | } | 65 | } |
| 66 | 66 | ||
| 67 | // APITokenByID loads a token by id, expired or not. | ||
| 68 | func (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 | |||
| 67 | func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { | 80 | func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { |
| 68 | rows, err := s.DB.Query(` | 81 | rows, err := s.DB.Query(` |
| 69 | SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '') | 82 | SELECT t.id, t.name, t.scope, t.created_at, t.expires_at, t.last_used_at, COALESCE(p.name, '') |
internal/web/templates/mr.html +7
| @@ -95,6 +95,12 @@ | |||
| 95 | <div class="grp"> | 95 | <div class="grp"> |
| 96 | <h2>Merge</h2> | 96 | <h2>Merge</h2> |
| 97 | {{if .Unresolved}}<p class="row none">{{.Unresolved}} unresolved thread{{if ne .Unresolved 1}}s{{end}}</p>{{end}} | 97 | {{if .Unresolved}}<p class="row none">{{.Unresolved}} unresolved thread{{if ne .Unresolved 1}}s{{end}}</p>{{end}} |
| 98 | {{if .MR.QueuedAt}}<p class="row"><span class="dot pend"></span>Queued to merge by <a href="/{{.MR.QueuedBy}}">{{.MR.QueuedBy}}</a>{{if .MR.QueueStrategy}} ({{.MR.QueueStrategy}}){{end}}<span class="sub">{{when .MR.QueuedAt}}</span></p> | ||
| 99 | {{with .MR.QueueReason}}<p class="row none">waiting: {{.}}</p>{{end}} | ||
| 100 | <form method="post" action="{{$base}}/merge" class="actions"> | ||
| 101 | <input type="hidden" name="cancel" value="on"> | ||
| 102 | <button type="submit" class="btn">Cancel queued merge</button> | ||
| 103 | </form>{{end}} | ||
| 98 | <form method="post" action="{{$base}}/merge" class="actions"> | 104 | <form method="post" action="{{$base}}/merge" class="actions"> |
| 99 | <label class="none" for="strategy">Strategy</label> | 105 | <label class="none" for="strategy">Strategy</label> |
| 100 | <select id="strategy" name="strategy"> | 106 | <select id="strategy" name="strategy"> |
| @@ -105,6 +111,7 @@ | |||
| 105 | <option value="rebase">Rebase</option> | 111 | <option value="rebase">Rebase</option> |
| 106 | </select> | 112 | </select> |
| 107 | <button type="submit">Merge</button> | 113 | <button type="submit">Merge</button> |
| 114 | <button type="submit" name="when_ready" value="on" class="btn">Merge when ready</button> | ||
| 108 | </form> | 115 | </form> |
| 109 | <form method="post" action="{{$base}}/close" class="actions"> | 116 | <form method="post" action="{{$base}}/close" class="actions"> |
| 110 | <label class="vh" for="by">Closed in favour of</label> | 117 | <label class="vh" for="by">Closed in favour of</label> |