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 | 40 | | resolve a thread | yes | yes | yes | |
| 41 | 41 | | comment on a diff line | yes | yes | yes | |
| 42 | 42 | | merge (all strategies) | yes | yes | yes | |
| 43 | | merge when ready, cancel | yes | yes | no | | |
| 43 | 44 | | close | yes | yes | yes | |
| 44 | 45 | | close in favour of another | yes | yes | yes | |
| 45 | 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 | 453 | gitbay mr review 4 --approve # or --request-changes / --comment |
| 454 | 454 | gitbay mr label 4 --add bug --remove wontfix |
| 455 | 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 | 457 | gitbay mr close 4 |
| 457 | 458 | #+end_src |
| 458 | 459 | |
| @@ -511,6 +512,33 @@ merge= names every unmet gate at once rather than the first. =mr | ||
| 511 | 512 | review= says when a verdict is advisory, which it is from anyone |
| 512 | 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 | 542 | Those gates apply to =mr merge=. A direct push to a protected branch |
| 515 | 543 | passes none of them until =require-mr on=: then an existing protected |
| 516 | 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 | 29 | the scratch-repository test: a build failing SSH logins must not |
| 30 | 30 | lock the runner out. Run it before pointing the runner back at real |
| 31 | 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 | 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 | 806 | c.Store.RecordEvent(repo.ID, c.User.ID, "build."+outcome, |
| 807 | 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 | 809 | // A red build mails the repo's notify targets with the log tail — a |
| 809 | 810 | // failed scheduled job must not wait to be noticed. |
| 810 | 811 | if outcome == "failure" { |
| @@ -1037,6 +1038,7 @@ func resolveCancelledCommitStatus(c *Ctx, repo store.Repo, b store.Build) { | ||
| 1037 | 1038 | url := fmt.Sprintf("%s/%s/builds/%d", c.Cfg.Server.SiteURL, repo.Path(), prev.Number) |
| 1038 | 1039 | c.Store.SetCommitStatus(repo.ID, b.SHA, "ci/"+b.Job, "success", |
| 1039 | 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 | 1042 | return |
| 1041 | 1043 | } |
| 1042 | 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 | 40 | Author string `json:"author"` |
| 41 | 41 | State string `json:"state"` |
| 42 | 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 | 47 | // PinnedOut is one pinned repository on the dashboard. |
| @@ -119,7 +121,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 119 | 121 | return c.fail(protocol.ExitFailure, "%v", err) |
| 120 | 122 | } |
| 121 | 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 | 127 | reviews, err := c.Store.ReviewQueue(c.User.ID) |
| @@ -127,7 +129,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 127 | 129 | return c.fail(protocol.ExitFailure, "%v", err) |
| 128 | 130 | } |
| 129 | 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 | 135 | assigned, err := c.Store.AssignedIssues(c.User.ID) |
| @@ -135,7 +137,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 135 | 137 | return c.fail(protocol.ExitFailure, "%v", err) |
| 136 | 138 | } |
| 137 | 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 | 143 | issues, err := c.Store.DashboardIssues(c.User.ID) |
| @@ -143,7 +145,7 @@ func runDashboard(c *Ctx, args []string) int { | ||
| 143 | 145 | return c.fail(protocol.ExitFailure, "%v", err) |
| 144 | 146 | } |
| 145 | 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 | 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 | 256 | return c.fail(protocol.ExitFailure, "%v", err) |
| 257 | 257 | } |
| 258 | if resolved { | |
| 259 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | |
| 260 | } | |
| 258 | 261 | verb := "resolved" |
| 259 | 262 | if !resolved { |
| 260 | 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 | 188 | Run: runMRLabel}) |
| 189 | 189 | register(Command{Path: []string{"mr", "merge"}, |
| 190 | 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 | 192 | Flags: []Flag{ |
| 193 | 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 | 198 | Run: runMRMerge}) |
| 197 | 199 | register(Command{Path: []string{"mr", "close"}, |
| 198 | 200 | Summary: "close without merging", |
| @@ -560,6 +562,8 @@ type mrOut struct { | ||
| 560 | 562 | // SupersededBy is the merge request, by number, this one was closed |
| 561 | 563 | // in favour of. 0 means none. |
| 562 | 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 | 569 | type stackRef struct { |
| @@ -605,7 +609,8 @@ func mrToOut(repo store.Repo, m store.MR, withBody bool) mrOut { | ||
| 605 | 609 | Source: src, TargetRef: m.TargetRef, HeadSHA: m.HeadSHA, Milestone: m.Milestone, |
| 606 | 610 | Labels: m.Labels, ReviewRequests: m.ReviewRequests, |
| 607 | 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 | 614 | if withBody { |
| 610 | 615 | o.Body = m.Body |
| 611 | 616 | o.BodyFormat = m.BodyFormat |
| @@ -795,6 +800,15 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 795 | 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 | 812 | unresolved := "" |
| 799 | 813 | if d.UnresolvedThreads > 0 { |
| 800 | 814 | unresolved = fmt.Sprintf("%d", d.UnresolvedThreads) |
| @@ -815,6 +829,8 @@ func runMRShow(c *Ctx, args []string) int { | ||
| 815 | 829 | "merged", merged, |
| 816 | 830 | "closed", closed, |
| 817 | 831 | "superseded by", superseded, |
| 832 | "queued to merge", queued, | |
| 833 | "waiting on", waiting, | |
| 818 | 834 | "unresolved threads", unresolved, |
| 819 | 835 | "gates", gates, |
| 820 | 836 | } |
| @@ -1031,6 +1047,16 @@ func runMRRetarget(c *Ctx, args []string) int { | ||
| 1031 | 1047 | return c.fail(protocol.ExitFailure, "%v", err) |
| 1032 | 1048 | } |
| 1033 | 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 | 1060 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.retargeted", |
| 1035 | 1061 | fmt.Sprintf(`{"number":%d,"from":%q,"to":%q}`, mr.Number, old, target)) |
| 1036 | 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 | 1135 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed", |
| 1110 | 1136 | fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict)) |
| 1137 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | |
| 1111 | 1138 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 1112 | 1139 | notify(c, parts, notice{repo: repo, kind: "mr", |
| 1113 | 1140 | subject: mrSubject(repo, mr.Number, mr.Title), |
| @@ -1258,7 +1285,8 @@ func runMRLabel(c *Ctx, args []string) int { | ||
| 1258 | 1285 | } |
| 1259 | 1286 | |
| 1260 | 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 | 1290 | if err != nil { |
| 1263 | 1291 | return c.fail(protocol.ExitUsage, "%v", err) |
| 1264 | 1292 | } |
| @@ -1267,10 +1295,25 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 1267 | 1295 | if !valid[strategy] { |
| 1268 | 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 | 1301 | repo, mr, code := mrRef(c, rest, policy.CanWrite) |
| 1271 | 1302 | if code >= 0 { |
| 1272 | 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 | 1317 | if code := refuseArchived(c, repo); code >= 0 { |
| 1275 | 1318 | return code |
| 1276 | 1319 | } |
| @@ -1324,6 +1367,11 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 1324 | 1367 | // rebase when fast-forward is already possible IS a fast-forward |
| 1325 | 1368 | // (nothing is rewritten), so it stays legal. |
| 1326 | 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 | 1375 | if strategy == "merge" || strategy == "squash" || !ffPossible { |
| 1328 | 1376 | return c.fail(protocol.ExitDenied, |
| 1329 | 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 | 1868 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft", |
| 1821 | 1869 | fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft)) |
| 1870 | if !draft { | |
| 1871 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | |
| 1872 | } | |
| 1822 | 1873 | // Marking ready is the request for review; going back to draft |
| 1823 | 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 | 99 | c.Store.RecordEvent(repo.ID, c.User.ID, "status", |
| 100 | 100 | fmt.Sprintf(`{"sha":%q,"context":%q,"state":%q}`, full, context, state)) |
| 101 | TryQueuedMergesAt(c.Store, c.Cfg, repo.ID, full) | |
| 101 | 102 | return c.emit(map[string]string{"sha": full, "context": context, "state": state}, func(w io.Writer) { |
| 102 | 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 | 336 | if mr.State == "open" { |
| 337 | 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 | 342 | continue // head ref retained: the diff stays viewable |
| 340 | 343 | } |
| 341 | 344 | dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name) |
| @@ -362,6 +365,11 @@ func (s *Server) postReceive(req Request) { | ||
| 362 | 365 | if mr.State == "source_gone" { |
| 363 | 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 | 79 | func (s *Server) mrMergeSubmit(w http.ResponseWriter, r *http.Request, u store.User) { |
| 80 | 80 | args := []string{} |
| 81 | if st := strings.TrimSpace(r.FormValue("strategy")); st != "" && st != "auto" { | |
| 82 | args = append(args, "--strategy", st) | |
| 81 | if r.FormValue("cancel") == "on" { | |
| 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 | 91 | _, msg, code := s.runControlCode(u, mrArgs(r, "merge", args...)) |
| 85 | 92 | s.done(w, r, code, msg, s.mrRedirect) |
internal/store/dashboard.go +10 −6
| @@ -10,6 +10,7 @@ type DashboardItem struct { | ||
| 10 | 10 | Author string |
| 11 | 11 | State string |
| 12 | 12 | UpdatedAt string |
| 13 | Queued bool // a merge request with a queued merge | |
| 13 | 14 | } |
| 14 | 15 | |
| 15 | 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 | 40 | var out []DashboardItem |
| 40 | 41 | for rows.Next() { |
| 41 | 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 | 44 | return nil, err |
| 44 | 45 | } |
| 45 | 46 | out = append(out, d) |
| @@ -51,7 +52,8 @@ func (s *Store) dashboardQuery(q string, userID int64) ([]DashboardItem, error) | ||
| 51 | 52 | // each still walks the 0035 index that supplies its ORDER BY. |
| 52 | 53 | const dashboardMRsQuery = ` |
| 53 | 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 | 57 | FROM merge_requests x |
| 56 | 58 | JOIN repos r ON r.id = x.repo_id |
| 57 | 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 | 71 | // DashboardIssues is the issue counterpart of DashboardMRs. |
| 70 | 72 | const dashboardIssuesQuery = ` |
| 71 | 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 | 75 | FROM issues x |
| 74 | 76 | JOIN repos r ON r.id = x.repo_id |
| 75 | 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 | 138 | // current head. |
| 137 | 139 | const reviewQueueQuery = ` |
| 138 | 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 | 143 | FROM merge_requests x |
| 141 | 144 | JOIN repos r ON r.id = x.repo_id |
| 142 | 145 | LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id |
| @@ -159,7 +162,8 @@ const reviewQueueQuery = ` | ||
| 159 | 162 | // merge_requests table is the whole instance. |
| 160 | 163 | const requestedReviewsQuery = ` |
| 161 | 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 | 167 | FROM mr_review_requests rr |
| 164 | 168 | JOIN merge_requests x ON x.id = rr.mr_id |
| 165 | 169 | JOIN repos r ON r.id = x.repo_id |
| @@ -233,7 +237,7 @@ func (s *Store) OpenCounts(repoID int64) (issues, mrs int) { | ||
| 233 | 237 | // is the whole instance. |
| 234 | 238 | const assignedIssuesQuery = ` |
| 235 | 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 | 241 | FROM issue_assignees ia |
| 238 | 242 | JOIN issues x ON x.id = ia.issue_id |
| 239 | 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 | 38 | // ReviewRequests is who has been asked, directly, for a review — the |
| 39 | 39 | // mr review request counterpart of Issue.Assignees. |
| 40 | 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 | 50 | type MRReview struct { |
| @@ -96,7 +103,9 @@ const mrSelect = ` | ||
| 96 | 103 | m.source_ref, m.target_ref, m.title, m.body, m.body_format, m.state, m.draft, |
| 97 | 104 | COALESCE(ms.title, ''), m.head_sha, |
| 98 | 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 | 109 | FROM merge_requests m |
| 101 | 110 | JOIN users u ON u.id = m.author_id |
| 102 | 111 | LEFT JOIN users mu ON mu.id = m.merged_by |
| @@ -104,13 +113,16 @@ const mrSelect = ` | ||
| 104 | 113 | LEFT JOIN repos sr ON sr.id = m.source_repo_id |
| 105 | 114 | LEFT JOIN users su ON sr.owner_kind = 'user' AND su.id = sr.owner_id |
| 106 | 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 | 120 | func scanMR(row interface{ Scan(...any) error }) (MR, error) { |
| 110 | 121 | var m MR |
| 111 | 122 | err := row.Scan(&m.ID, &m.RepoID, &m.Number, &m.Author, &m.SourceRepoID, &m.SourcePath, |
| 112 | 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 | 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 | 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 | 80 | func (s *Store) ListAPITokens(userID int64) ([]APIToken, error) { |
| 68 | 81 | rows, err := s.DB.Query(` |
| 69 | 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 | 95 | <div class="grp"> |
| 96 | 96 | <h2>Merge</h2> |
| 97 | 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 | 104 | <form method="post" action="{{$base}}/merge" class="actions"> |
| 99 | 105 | <label class="none" for="strategy">Strategy</label> |
| 100 | 106 | <select id="strategy" name="strategy"> |
| @@ -105,6 +111,7 @@ | ||
| 105 | 111 | <option value="rebase">Rebase</option> |
| 106 | 112 | </select> |
| 107 | 113 | <button type="submit">Merge</button> |
| 114 | <button type="submit" name="when_ready" value="on" class="btn">Merge when ready</button> | |
| 108 | 115 | </form> |
| 109 | 116 | <form method="post" action="{{$base}}/close" class="actions"> |
| 110 | 117 | <label class="vh" for="by">Closed in favour of</label> |