Commit 8fa927e43a
Verified · cmc
Layout: unified · split
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 +171
| @@ -0,0 +1,171 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "encoding/json" | |
| 6 | "fmt" | |
| 7 | "io" | |
| 8 | "log/slog" | |
| 9 | "strconv" | |
| 10 | "sync" | |
| 11 | ||
| 12 | "gitbay.org/gitbay/internal/config" | |
| 13 | "gitbay.org/gitbay/internal/policy" | |
| 14 | "gitbay.org/gitbay/internal/protocol" | |
| 15 | "gitbay.org/gitbay/internal/store" | |
| 16 | ) | |
| 17 | ||
| 18 | // QueuedOut is a merge request's queued merge (mr merge --when-ready): | |
| 19 | // who queued it, the strategy it will use ("" for the default), and why | |
| 20 | // the last attempt did not merge. | |
| 21 | type QueuedOut struct { | |
| 22 | By string `json:"by"` | |
| 23 | Strategy string `json:"strategy,omitempty"` | |
| 24 | Reason string `json:"reason,omitempty"` | |
| 25 | QueuedAt string `json:"queued_at"` | |
| 26 | } | |
| 27 | ||
| 28 | func queuedOut(m store.MR) *QueuedOut { | |
| 29 | if m.QueuedAt == "" { | |
| 30 | return nil | |
| 31 | } | |
| 32 | return &QueuedOut{By: m.QueuedBy, Strategy: m.QueueStrategy, Reason: m.QueueReason, QueuedAt: m.QueuedAt} | |
| 33 | } | |
| 34 | ||
| 35 | // mergeQueueMu serialises queued merge attempts, so two triggers landing | |
| 36 | // together cannot both merge one request. | |
| 37 | var mergeQueueMu sync.Mutex | |
| 38 | ||
| 39 | type queueResult struct { | |
| 40 | merged bool | |
| 41 | dequeued bool | |
| 42 | reason string | |
| 43 | out map[string]any // the merge's output, when merged | |
| 44 | } | |
| 45 | ||
| 46 | // TryQueuedMerge attempts the queued merge of one merge request, if it | |
| 47 | // has one. Called wherever a gate can have changed: a review, a resolved | |
| 48 | // thread, a draft marked ready, a push to the source branch. | |
| 49 | func TryQueuedMerge(st *store.Store, cfg config.Config, mrID int64) { | |
| 50 | attemptQueuedMerge(st, cfg, mrID) | |
| 51 | } | |
| 52 | ||
| 53 | // TryQueuedMergesAt attempts the queued merges in a repository whose head | |
| 54 | // is sha. Called when a status is reported on sha. | |
| 55 | func TryQueuedMergesAt(st *store.Store, cfg config.Config, repoID int64, sha string) { | |
| 56 | ids, err := st.QueuedMRsAtHead(repoID, sha) | |
| 57 | if err != nil { | |
| 58 | slog.Error("merge queue: listing", "repo", repoID, "err", err) | |
| 59 | return | |
| 60 | } | |
| 61 | for _, id := range ids { | |
| 62 | attemptQueuedMerge(st, cfg, id) | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | // attemptQueuedMerge merges a queued request as the user who queued it, | |
| 67 | // checked against that user's rights now. A merge refused for anything | |
| 68 | // the queuer can fix (unmet gates, a branch behind a require-signed | |
| 69 | // target, a conflict) stays queued with the refusal recorded; a queuer | |
| 70 | // who can no longer merge is dequeued. | |
| 71 | func attemptQueuedMerge(st *store.Store, cfg config.Config, mrID int64) queueResult { | |
| 72 | mergeQueueMu.Lock() | |
| 73 | defer mergeQueueMu.Unlock() | |
| 74 | mr, err := st.MRByID(mrID) | |
| 75 | if err != nil || mr.QueuedAt == "" { | |
| 76 | return queueResult{} | |
| 77 | } | |
| 78 | repo, err := st.RepoByID(mr.RepoID) | |
| 79 | if err != nil { | |
| 80 | return queueResult{} | |
| 81 | } | |
| 82 | user, err := st.UserByID(mr.QueuedByID) | |
| 83 | if err != nil { | |
| 84 | return queueResult{} | |
| 85 | } | |
| 86 | grant, err := st.AccessRole(repo.ID, user.ID) | |
| 87 | if err != nil { | |
| 88 | return queueResult{} | |
| 89 | } | |
| 90 | switch { | |
| 91 | case user.Disabled || user.Pending: | |
| 92 | return dequeueWithReason(st, repo, mr, user, user.Username+"'s account is not active") | |
| 93 | case !policy.CanWrite(user, repo, grant): | |
| 94 | return dequeueWithReason(st, repo, mr, user, | |
| 95 | fmt.Sprintf("%s no longer has write access to %s", user.Username, repo.Path())) | |
| 96 | } | |
| 97 | ||
| 98 | var out bytes.Buffer | |
| 99 | c := &Ctx{User: user, Scope: "full", Source: "queue", Store: st, Cfg: cfg, | |
| 100 | Stdin: emptyReader{}, Stdout: &out, Stderr: io.Discard, JSON: true} | |
| 101 | code := mergeMR(c, repo, mr, mr.QueueStrategy) | |
| 102 | var env struct { | |
| 103 | Data map[string]any `json:"data"` | |
| 104 | Error string `json:"error"` | |
| 105 | } | |
| 106 | json.Unmarshal(out.Bytes(), &env) | |
| 107 | if code == protocol.ExitOK { | |
| 108 | st.Audit(user.ID, "cmd mr merge", map[string]any{ | |
| 109 | "argv": []string{repo.Path(), strconv.FormatInt(mr.Number, 10), "--when-ready"}, "source": "queue"}) | |
| 110 | return queueResult{merged: true, out: env.Data} | |
| 111 | } | |
| 112 | st.SetMergeQueueReason(mr.ID, env.Error) | |
| 113 | return queueResult{reason: env.Error} | |
| 114 | } | |
| 115 | ||
| 116 | func dequeueWithReason(st *store.Store, repo store.Repo, mr store.MR, user store.User, reason string) queueResult { | |
| 117 | st.DequeueMerge(mr.ID) | |
| 118 | st.AddMRSystemComment(mr.ID, user.ID, "queued merge by "+user.Username+" cancelled: "+reason) | |
| 119 | return queueResult{dequeued: true, reason: reason} | |
| 120 | } | |
| 121 | ||
| 122 | // queueMerge is mr merge --when-ready: queue the merge as c.User and try | |
| 123 | // it at once, so gates that already pass merge now. | |
| 124 | func queueMerge(c *Ctx, repo store.Repo, mr store.MR, strategy string) int { | |
| 125 | if code := refuseArchived(c, repo); code >= 0 { | |
| 126 | return code | |
| 127 | } | |
| 128 | if mr.State != "open" && mr.State != "source_gone" { | |
| 129 | return c.fail(protocol.ExitUsage, "MR !%d is %s", mr.Number, mr.State) | |
| 130 | } | |
| 131 | // A strategy the repository refuses outright would wait forever. | |
| 132 | if repo.Settings.RequireSignedCommits && (strategy == "merge" || strategy == "squash") { | |
| 133 | return c.fail(protocol.ExitDenied, | |
| 134 | "%s requires signed commits, so only fast-forward merges are allowed; queue without --strategy or with --strategy ff", repo.Path()) | |
| 135 | } | |
| 136 | if err := c.Store.QueueMerge(mr.ID, c.User.ID, strategy); err != nil { | |
| 137 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 138 | } | |
| 139 | res := attemptQueuedMerge(c.Store, c.Cfg, mr.ID) | |
| 140 | switch { | |
| 141 | case res.merged: | |
| 142 | return c.emit(res.out, func(w io.Writer) { | |
| 143 | fmt.Fprintf(w, "merged %s!%d into %s (%v) at %.10v\n", repo.Path(), mr.Number, mr.TargetRef, res.out["strategy"], res.out["sha"]) | |
| 144 | }) | |
| 145 | case res.dequeued: | |
| 146 | return c.fail(protocol.ExitDenied, "%s", res.reason) | |
| 147 | } | |
| 148 | note := "" | |
| 149 | if strategy != "" { | |
| 150 | note = " (" + strategy + ")" | |
| 151 | } | |
| 152 | c.Store.AddMRSystemComment(mr.ID, c.User.ID, fmt.Sprintf("%s queued the merge%s for when the gates pass", c.User.Username, note)) | |
| 153 | return c.emit(map[string]any{"number": mr.Number, "queued": true, "strategy": strategy, "reason": res.reason}, func(w io.Writer) { | |
| 154 | fmt.Fprintf(w, "queued %s!%d to merge when ready; waiting: %s\n", repo.Path(), mr.Number, res.reason) | |
| 155 | }) | |
| 156 | } | |
| 157 | ||
| 158 | // cancelQueuedMerge is mr merge --cancel. | |
| 159 | func cancelQueuedMerge(c *Ctx, repo store.Repo, mr store.MR) int { | |
| 160 | ok, err := c.Store.DequeueMerge(mr.ID) | |
| 161 | if err != nil { | |
| 162 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 163 | } | |
| 164 | if !ok { | |
| 165 | return c.fail(protocol.ExitFailure, "!%d is not queued to merge", mr.Number) | |
| 166 | } | |
| 167 | c.Store.AddMRSystemComment(mr.ID, c.User.ID, c.User.Username+" cancelled the queued merge") | |
| 168 | return c.emit(map[string]any{"number": mr.Number, "queued": false}, func(w io.Writer) { | |
| 169 | fmt.Fprintf(w, "cancelled the queued merge of %s!%d\n", repo.Path(), mr.Number) | |
| 170 | }) | |
| 171 | } | |
internal/control/mergequeue_test.go added +351
| @@ -0,0 +1,351 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "encoding/json" | |
| 6 | "os" | |
| 7 | "path/filepath" | |
| 8 | "strconv" | |
| 9 | "strings" | |
| 10 | "testing" | |
| 11 | ||
| 12 | "gitbay.org/gitbay/internal/config" | |
| 13 | "gitbay.org/gitbay/internal/protocol" | |
| 14 | "gitbay.org/gitbay/internal/store" | |
| 15 | ) | |
| 16 | ||
| 17 | // queueFixture is a repository on disk with one merge request, !1, | |
| 18 | // feature into main, whose head is one commit ahead of main. | |
| 19 | type queueFixture struct { | |
| 20 | t *testing.T | |
| 21 | st *store.Store | |
| 22 | repo store.Repo | |
| 23 | alice store.User // the owner | |
| 24 | root string | |
| 25 | dir string | |
| 26 | src string | |
| 27 | git func(dir string, args ...string) string | |
| 28 | headSHA string | |
| 29 | targetSH string | |
| 30 | } | |
| 31 | ||
| 32 | func newQueueFixture(t *testing.T, set func(*store.RepoSettings)) *queueFixture { | |
| 33 | t.Helper() | |
| 34 | st, repo, uid := newQueueTestRepo(t) | |
| 35 | if set != nil { | |
| 36 | if _, err := st.UpdateRepoSettings(repo.ID, set); err != nil { | |
| 37 | t.Fatal(err) | |
| 38 | } | |
| 39 | var err error | |
| 40 | if repo, err = st.RepoByID(repo.ID); err != nil { | |
| 41 | t.Fatal(err) | |
| 42 | } | |
| 43 | } | |
| 44 | f := &queueFixture{t: t, st: st, repo: repo, alice: store.User{ID: uid, Username: "alice"}, | |
| 45 | root: t.TempDir(), git: gitRunner(t)} | |
| 46 | f.src = filepath.Join(f.root, "src") | |
| 47 | f.git(f.root, "init", "-q", "-b", "main", "src") | |
| 48 | f.write("README", "x\n") | |
| 49 | f.git(f.src, "add", ".") | |
| 50 | f.git(f.src, "commit", "-q", "-m", "base") | |
| 51 | f.targetSH = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD")) | |
| 52 | f.git(f.src, "checkout", "-q", "-b", "feature") | |
| 53 | f.write("feature.txt", "y\n") | |
| 54 | f.git(f.src, "add", ".") | |
| 55 | f.git(f.src, "commit", "-q", "-m", "change") | |
| 56 | f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD")) | |
| 57 | ||
| 58 | f.dir = RepoDir(f.root, repo.OwnerName, repo.Name) | |
| 59 | os.MkdirAll(filepath.Dir(f.dir), 0o755) | |
| 60 | f.git(f.root, "clone", "-q", "--bare", f.src, f.dir) | |
| 61 | f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA) | |
| 62 | if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", f.headSHA, "md", false); err != nil { | |
| 63 | t.Fatal(err) | |
| 64 | } | |
| 65 | return f | |
| 66 | } | |
| 67 | ||
| 68 | func (f *queueFixture) write(name, body string) { | |
| 69 | f.t.Helper() | |
| 70 | if err := os.WriteFile(filepath.Join(f.src, name), []byte(body), 0o644); err != nil { | |
| 71 | f.t.Fatal(err) | |
| 72 | } | |
| 73 | } | |
| 74 | ||
| 75 | // run dispatches argv as u, returning the exit code and stderr. | |
| 76 | func (f *queueFixture) run(u store.User, argv ...string) (int, string, string) { | |
| 77 | f.t.Helper() | |
| 78 | var out, errOut bytes.Buffer | |
| 79 | c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut} | |
| 80 | c.Cfg.Server.Root = f.root | |
| 81 | code := Dispatch(c, argv) | |
| 82 | return code, out.String(), errOut.String() | |
| 83 | } | |
| 84 | ||
| 85 | func (f *queueFixture) mustRun(u store.User, argv ...string) string { | |
| 86 | f.t.Helper() | |
| 87 | code, out, errOut := f.run(u, argv...) | |
| 88 | if code != protocol.ExitOK { | |
| 89 | f.t.Fatalf("%v: exit %d, %s", argv, code, errOut) | |
| 90 | } | |
| 91 | return out | |
| 92 | } | |
| 93 | ||
| 94 | func (f *queueFixture) mr() store.MR { | |
| 95 | f.t.Helper() | |
| 96 | mr, err := f.st.MRByNumber(f.repo.ID, 1) | |
| 97 | if err != nil { | |
| 98 | f.t.Fatal(err) | |
| 99 | } | |
| 100 | return mr | |
| 101 | } | |
| 102 | ||
| 103 | // user creates an account granted role on the repository. | |
| 104 | func (f *queueFixture) user(name, role string) store.User { | |
| 105 | f.t.Helper() | |
| 106 | id, err := f.st.CreateUser(name, false) | |
| 107 | if err != nil { | |
| 108 | f.t.Fatal(err) | |
| 109 | } | |
| 110 | if role != "" { | |
| 111 | if err := f.st.GrantAccess(f.repo.ID, id, role); err != nil { | |
| 112 | f.t.Fatal(err) | |
| 113 | } | |
| 114 | } | |
| 115 | u, err := f.st.UserByID(id) | |
| 116 | if err != nil { | |
| 117 | f.t.Fatal(err) | |
| 118 | } | |
| 119 | return u | |
| 120 | } | |
| 121 | ||
| 122 | // systemComments is every system comment on !1, joined. | |
| 123 | func (f *queueFixture) systemComments() string { | |
| 124 | f.t.Helper() | |
| 125 | cs, err := f.st.ListMRComments(f.mr().ID) | |
| 126 | if err != nil { | |
| 127 | f.t.Fatal(err) | |
| 128 | } | |
| 129 | var b strings.Builder | |
| 130 | for _, c := range cs { | |
| 131 | if c.Kind == "system" { | |
| 132 | b.WriteString(c.Body + "\n") | |
| 133 | } | |
| 134 | } | |
| 135 | return b.String() | |
| 136 | } | |
| 137 | ||
| 138 | func (f *queueFixture) wantMergedBy(who string) { | |
| 139 | f.t.Helper() | |
| 140 | mr := f.mr() | |
| 141 | if mr.State != "merged" || mr.MergedBy != who || mr.QueuedAt != "" { | |
| 142 | f.t.Fatalf("MR = state %s merged_by %q queued_at %q, want merged by %s and off the queue", | |
| 143 | mr.State, mr.MergedBy, mr.QueuedAt, who) | |
| 144 | } | |
| 145 | if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != mr.HeadSHA { | |
| 146 | f.t.Fatalf("main = %s, want the MR head %s", main, mr.HeadSHA) | |
| 147 | } | |
| 148 | } | |
| 149 | ||
| 150 | func (f *queueFixture) wantQueued(reason string) store.MR { | |
| 151 | f.t.Helper() | |
| 152 | mr := f.mr() | |
| 153 | if mr.State != "open" || mr.QueuedAt == "" || !strings.Contains(mr.QueueReason, reason) { | |
| 154 | f.t.Fatalf("MR = state %s queued_at %q reason %q, want open and queued with %q", | |
| 155 | mr.State, mr.QueuedAt, mr.QueueReason, reason) | |
| 156 | } | |
| 157 | return mr | |
| 158 | } | |
| 159 | ||
| 160 | // Gates that already pass merge at once: queueing is only for waiting. | |
| 161 | func TestWhenReadyMergesAtOnceWhenGatesPass(t *testing.T) { | |
| 162 | f := newQueueFixture(t, nil) | |
| 163 | out := f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | |
| 164 | if !strings.Contains(out, "merged") { | |
| 165 | t.Fatalf("output = %q, want it to say merged", out) | |
| 166 | } | |
| 167 | f.wantMergedBy("alice") | |
| 168 | } | |
| 169 | ||
| 170 | // A status that turns the checks green merges the queued request, as the | |
| 171 | // user who queued it, and mr show carries the queue until then. | |
| 172 | func TestWhenReadyStatusSetMerges(t *testing.T) { | |
| 173 | f := newQueueFixture(t, func(s *store.RepoSettings) { | |
| 174 | s.RequireChecks = true | |
| 175 | s.RequiredContexts = []string{"ext/test"} | |
| 176 | }) | |
| 177 | bob := f.user("bob", "write") | |
| 178 | out := f.mustRun(bob, "mr", "merge", f.repo.Path(), "1", "--when-ready", "--strategy", "ff") | |
| 179 | if !strings.Contains(out, "queued") { | |
| 180 | t.Fatalf("output = %q, want it to say queued", out) | |
| 181 | } | |
| 182 | f.wantQueued("green checks") | |
| 183 | got := mrShowJSONAt(t, f, f.alice) | |
| 184 | if got.Queued == nil || got.Queued.By != "bob" || got.Queued.Strategy != "ff" || !strings.Contains(got.Queued.Reason, "green checks") { | |
| 185 | t.Fatalf("mr show queued = %+v", got.Queued) | |
| 186 | } | |
| 187 | ||
| 188 | f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "pending") | |
| 189 | f.wantQueued("green checks") | |
| 190 | f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success") | |
| 191 | f.wantMergedBy("bob") | |
| 192 | } | |
| 193 | ||
| 194 | func mrShowJSONAt(t *testing.T, f *queueFixture, u store.User) mrOut { | |
| 195 | t.Helper() | |
| 196 | var out, errOut bytes.Buffer | |
| 197 | c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut} | |
| 198 | c.Cfg.Server.Root = f.root | |
| 199 | if code := Dispatch(c, []string{"mr", "show", f.repo.Path(), "1", "--json"}); code != protocol.ExitOK { | |
| 200 | t.Fatalf("mr show: exit %d, %s", code, errOut.String()) | |
| 201 | } | |
| 202 | var env struct { | |
| 203 | Data mrOut `json:"data"` | |
| 204 | } | |
| 205 | if err := json.Unmarshal(out.Bytes(), &env); err != nil { | |
| 206 | t.Fatalf("mr show JSON: %v\n%s", err, out.String()) | |
| 207 | } | |
| 208 | return env.Data | |
| 209 | } | |
| 210 | ||
| 211 | // An approval that meets require_approvals merges the queued request. | |
| 212 | func TestWhenReadyReviewMerges(t *testing.T) { | |
| 213 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 }) | |
| 214 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | |
| 215 | f.wantQueued("approval") | |
| 216 | bob := f.user("bob", "write") | |
| 217 | f.mustRun(bob, "mr", "review", f.repo.Path(), "1", "--approve") | |
| 218 | f.wantMergedBy("alice") | |
| 219 | } | |
| 220 | ||
| 221 | // Resolving the last open thread merges the queued request. | |
| 222 | func TestWhenReadyThreadResolveMerges(t *testing.T) { | |
| 223 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireResolved = true }) | |
| 224 | id, err := f.st.AddDiffComment(f.mr().ID, f.alice.ID, f.headSHA, "feature.txt", "new", 1, "why?", 0, false) | |
| 225 | if err != nil { | |
| 226 | t.Fatal(err) | |
| 227 | } | |
| 228 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | |
| 229 | f.wantQueued("threads resolved") | |
| 230 | f.mustRun(f.alice, "mr", "resolve", f.repo.Path(), "1", strconv.FormatInt(id, 10)) | |
| 231 | f.wantMergedBy("alice") | |
| 232 | } | |
| 233 | ||
| 234 | // Marking a draft ready merges the queued request. | |
| 235 | func TestWhenReadyReadyMerges(t *testing.T) { | |
| 236 | f := newQueueFixture(t, nil) | |
| 237 | f.st.SetMRDraft(f.mr().ID, true) | |
| 238 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | |
| 239 | f.wantQueued("draft") | |
| 240 | f.mustRun(f.alice, "mr", "ready", f.repo.Path(), "1") | |
| 241 | f.wantMergedBy("alice") | |
| 242 | } | |
| 243 | ||
| 244 | // A new head keeps the request queued and has to pass on its own: a | |
| 245 | // status on the old head moves nothing. | |
| 246 | func TestWhenReadyNewHeadMustPassAgain(t *testing.T) { | |
| 247 | f := newQueueFixture(t, func(s *store.RepoSettings) { | |
| 248 | s.RequireChecks = true | |
| 249 | s.RequiredContexts = []string{"ext/test"} | |
| 250 | }) | |
| 251 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | |
| 252 | old := f.headSHA | |
| 253 | f.write("feature.txt", "z\n") | |
| 254 | f.git(f.src, "commit", "-q", "-am", "more") | |
| 255 | f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD")) | |
| 256 | f.git(f.src, "push", "-q", f.dir, "feature") | |
| 257 | f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA) | |
| 258 | if err := f.st.UpdateMRHead(f.mr().ID, f.headSHA, f.targetSH, false); err != nil { | |
| 259 | t.Fatal(err) | |
| 260 | } | |
| 261 | TryQueuedMerge(f.st, f.cfg(), f.mr().ID) | |
| 262 | f.wantQueued("green checks") | |
| 263 | f.mustRun(f.alice, "status", "set", f.repo.Path(), old, "--context", "ext/test", "--state", "success") | |
| 264 | f.wantQueued("green checks") | |
| 265 | f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success") | |
| 266 | f.wantMergedBy("alice") | |
| 267 | } | |
| 268 | ||
| 269 | func (f *queueFixture) cfg() (c config.Config) { | |
| 270 | c.Server.Root = f.root | |
| 271 | return c | |
| 272 | } | |
| 273 | ||
| 274 | // On a require-signed repository the server cannot rebase: a branch | |
| 275 | // behind its target stays queued and says it needs a rebase. | |
| 276 | func TestWhenReadySignedBehindStaysQueued(t *testing.T) { | |
| 277 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireSignedCommits = true }) | |
| 278 | f.git(f.src, "checkout", "-q", "main") | |
| 279 | f.write("other.txt", "o\n") | |
| 280 | f.git(f.src, "add", ".") | |
| 281 | f.git(f.src, "commit", "-q", "-m", "target moves") | |
| 282 | f.git(f.src, "push", "-q", f.dir, "main") | |
| 283 | out := f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | |
| 284 | if !strings.Contains(out, "rebase") { | |
| 285 | t.Fatalf("output = %q, want the pending reason to name the rebase", out) | |
| 286 | } | |
| 287 | f.wantQueued("rebase") | |
| 288 | ||
| 289 | // A strategy that cannot ever pass there is refused, not queued. | |
| 290 | code, _, errOut := f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready", "--strategy", "squash") | |
| 291 | if code != protocol.ExitDenied || !strings.Contains(errOut, "signed") { | |
| 292 | t.Fatalf("squash on require-signed: exit %d, %s", code, errOut) | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | // A queuer who lost write access is dequeued with the reason recorded, | |
| 297 | // and nothing merges. | |
| 298 | func TestWhenReadyRightsLossDequeues(t *testing.T) { | |
| 299 | f := newQueueFixture(t, func(s *store.RepoSettings) { | |
| 300 | s.RequireChecks = true | |
| 301 | s.RequiredContexts = []string{"ext/test"} | |
| 302 | }) | |
| 303 | bob := f.user("bob", "write") | |
| 304 | f.mustRun(bob, "mr", "merge", f.repo.Path(), "1", "--when-ready") | |
| 305 | f.wantQueued("green checks") | |
| 306 | if err := f.st.RevokeAccess(f.repo.ID, bob.ID); err != nil { | |
| 307 | t.Fatal(err) | |
| 308 | } | |
| 309 | f.mustRun(f.alice, "status", "set", f.repo.Path(), f.headSHA, "--context", "ext/test", "--state", "success") | |
| 310 | mr := f.mr() | |
| 311 | if mr.State != "open" || mr.QueuedAt != "" { | |
| 312 | t.Fatalf("MR = state %s queued_at %q, want open and dequeued", mr.State, mr.QueuedAt) | |
| 313 | } | |
| 314 | if sys := f.systemComments(); !strings.Contains(sys, "bob no longer has write access") { | |
| 315 | t.Fatalf("system comments = %q, want the dequeue reason", sys) | |
| 316 | } | |
| 317 | } | |
| 318 | ||
| 319 | // --cancel dequeues; a second cancel has nothing to take off. | |
| 320 | func TestWhenReadyCancel(t *testing.T) { | |
| 321 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 }) | |
| 322 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | |
| 323 | f.wantQueued("approval") | |
| 324 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel") | |
| 325 | if mr := f.mr(); mr.QueuedAt != "" { | |
| 326 | t.Fatalf("cancel left the MR queued: %+v", mr) | |
| 327 | } | |
| 328 | code, _, errOut := f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel") | |
| 329 | if code != protocol.ExitFailure || !strings.Contains(errOut, "not queued") { | |
| 330 | t.Fatalf("second cancel: exit %d, %s", code, errOut) | |
| 331 | } | |
| 332 | code, _, _ = f.run(f.alice, "mr", "merge", f.repo.Path(), "1", "--cancel", "--when-ready") | |
| 333 | if code != protocol.ExitUsage { | |
| 334 | t.Fatalf("--cancel --when-ready: exit %d, want usage", code) | |
| 335 | } | |
| 336 | // An approval after the cancel merges nothing. | |
| 337 | f.mustRun(f.user("bob", "write"), "mr", "review", f.repo.Path(), "1", "--approve") | |
| 338 | if mr := f.mr(); mr.State != "open" { | |
| 339 | t.Fatalf("cancelled MR merged: %+v", mr) | |
| 340 | } | |
| 341 | } | |
| 342 | ||
| 343 | // Closing a queued merge request dequeues it. | |
| 344 | func TestWhenReadyCloseDequeues(t *testing.T) { | |
| 345 | f := newQueueFixture(t, func(s *store.RepoSettings) { s.RequireApprovals = 1 }) | |
| 346 | f.mustRun(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready") | |
| 347 | f.mustRun(f.alice, "mr", "close", f.repo.Path(), "1") | |
| 348 | if mr := f.mr(); mr.State != "closed" || mr.QueuedAt != "" { | |
| 349 | t.Fatalf("closed MR = %+v, want closed and dequeued", mr) | |
| 350 | } | |
| 351 | } | |
internal/control/mr.go +40 −4
| @@ -188,11 +188,13 @@ func init() { | ||
| 188 | 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 | } |
| @@ -1108,6 +1124,7 @@ func runMRReview(c *Ctx, args []string) int { | ||
| 1108 | 1124 | } |
| 1109 | 1125 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.reviewed", |
| 1110 | 1126 | fmt.Sprintf(`{"number":%d,"verdict":%q}`, mr.Number, verdict)) |
| 1127 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | |
| 1111 | 1128 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { |
| 1112 | 1129 | notify(c, parts, notice{repo: repo, kind: "mr", |
| 1113 | 1130 | subject: mrSubject(repo, mr.Number, mr.Title), |
| @@ -1258,7 +1275,8 @@ func runMRLabel(c *Ctx, args []string) int { | ||
| 1258 | 1275 | } |
| 1259 | 1276 | |
| 1260 | 1277 | 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]"}) | |
| 1278 | f, err := c.parseArgs(args, flagSpec{Values: []string{"--strategy"}, Bools: []string{"--when-ready", "--cancel"}, | |
| 1279 | MaxPos: -1, Usage: c.Cmd.Usage}) | |
| 1262 | 1280 | if err != nil { |
| 1263 | 1281 | return c.fail(protocol.ExitUsage, "%v", err) |
| 1264 | 1282 | } |
| @@ -1267,10 +1285,25 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 1267 | 1285 | if !valid[strategy] { |
| 1268 | 1286 | return c.fail(protocol.ExitUsage, "--strategy must be ff, merge, squash, or rebase") |
| 1269 | 1287 | } |
| 1288 | if f.Has("--cancel") && (f.Has("--when-ready") || f.Has("--strategy")) { | |
| 1289 | return c.fail(protocol.ExitUsage, "--cancel takes no other flag") | |
| 1290 | } | |
| 1270 | 1291 | repo, mr, code := mrRef(c, rest, policy.CanWrite) |
| 1271 | 1292 | if code >= 0 { |
| 1272 | 1293 | return code |
| 1273 | 1294 | } |
| 1295 | switch { | |
| 1296 | case f.Has("--cancel"): | |
| 1297 | return cancelQueuedMerge(c, repo, mr) | |
| 1298 | case f.Has("--when-ready"): | |
| 1299 | return queueMerge(c, repo, mr, strategy) | |
| 1300 | } | |
| 1301 | return mergeMR(c, repo, mr, strategy) | |
| 1302 | } | |
| 1303 | ||
| 1304 | // mergeMR merges mr now as c.User, or refuses saying why. The queue runs | |
| 1305 | // it too, as the user who queued the merge. | |
| 1306 | func mergeMR(c *Ctx, repo store.Repo, mr store.MR, strategy string) int { | |
| 1274 | 1307 | if code := refuseArchived(c, repo); code >= 0 { |
| 1275 | 1308 | return code |
| 1276 | 1309 | } |
| @@ -1819,6 +1852,9 @@ func setMRDraft(c *Ctx, args []string, draft bool) int { | ||
| 1819 | 1852 | } |
| 1820 | 1853 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.draft", |
| 1821 | 1854 | fmt.Sprintf(`{"number":%d,"draft":%t}`, mr.Number, draft)) |
| 1855 | if !draft { | |
| 1856 | TryQueuedMerge(c.Store, c.Cfg, mr.ID) | |
| 1857 | } | |
| 1822 | 1858 | // Marking ready is the request for review; going back to draft |
| 1823 | 1859 | // withdraws it and is not worth anyone's inbox. |
| 1824 | 1860 | // |
internal/control/status.go +1
| @@ -98,6 +98,7 @@ func runStatusSet(c *Ctx, args []string) int { | ||
| 98 | 98 | } |
| 99 | 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 +5
| @@ -362,6 +362,11 @@ func (s *Server) postReceive(req Request) { | ||
| 362 | 362 | if mr.State == "source_gone" { |
| 363 | 363 | s.st.SetMRState(mr.ID, "open") // branch came back |
| 364 | 364 | } |
| 365 | // A queued merge stays queued across a push; the new head has | |
| 366 | // to pass the gates on its own. | |
| 367 | if mr.QueuedAt != "" { | |
| 368 | control.TryQueuedMerge(s.st, s.cfg, mr.ID) | |
| 369 | } | |
| 365 | 370 | } |
| 366 | 371 | } |
| 367 | 372 | } |
internal/hookd/mergequeue_test.go added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | package hookd | |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "strings" | |
| 8 | "testing" | |
| 9 | ||
| 10 | "gitbay.org/gitbay/internal/config" | |
| 11 | "gitbay.org/gitbay/internal/control" | |
| 12 | "gitbay.org/gitbay/internal/policy" | |
| 13 | "gitbay.org/gitbay/internal/protocol" | |
| 14 | "gitbay.org/gitbay/internal/store" | |
| 15 | ) | |
| 16 | ||
| 17 | // A push to the source branch of a queued merge request tries the merge | |
| 18 | // again: a fast-forward merge queued while the branch was behind lands | |
| 19 | // once the rebased branch is pushed. | |
| 20 | func TestPostReceiveTriesQueuedMerge(t *testing.T) { | |
| 21 | st, err := store.Open(":memory:") | |
| 22 | if err != nil { | |
| 23 | t.Fatal(err) | |
| 24 | } | |
| 25 | t.Cleanup(func() { st.Close() }) | |
| 26 | if err := st.MigrateUp(); err != nil { | |
| 27 | t.Fatal(err) | |
| 28 | } | |
| 29 | uid, err := st.CreateUser("alice", false) | |
| 30 | if err != nil { | |
| 31 | t.Fatal(err) | |
| 32 | } | |
| 33 | repoID, err := st.CreateRepo("user", uid, "app", "public") | |
| 34 | if err != nil { | |
| 35 | t.Fatal(err) | |
| 36 | } | |
| 37 | repo, err := st.RepoByID(repoID) | |
| 38 | if err != nil { | |
| 39 | t.Fatal(err) | |
| 40 | } | |
| 41 | root := t.TempDir() | |
| 42 | f := &shapeFixture{t: t, st: st, repo: repo, uid: uid, root: root, src: filepath.Join(root, "src")} | |
| 43 | f.dir = control.RepoDir(root, repo.OwnerName, repo.Name) | |
| 44 | cfg := config.Config{} | |
| 45 | cfg.Server.Root, cfg.Server.SiteURL = root, "https://x.test" | |
| 46 | srv := &Server{cfg: cfg, st: st} | |
| 47 | ||
| 48 | os.MkdirAll(f.src, 0o755) | |
| 49 | f.git(root, "init", "-q", "-b", "main", "src") | |
| 50 | f.write("README", "x\n") | |
| 51 | f.git(f.src, "add", ".") | |
| 52 | f.git(f.src, "commit", "-q", "-m", "base") | |
| 53 | f.git(f.src, "checkout", "-q", "-b", "feature") | |
| 54 | f.write("feature.txt", "y\n") | |
| 55 | f.git(f.src, "add", ".") | |
| 56 | f.git(f.src, "commit", "-q", "-m", "change") | |
| 57 | head := f.sha("HEAD") | |
| 58 | f.git(f.src, "checkout", "-q", "main") | |
| 59 | f.write("other.txt", "o\n") | |
| 60 | f.git(f.src, "add", ".") | |
| 61 | f.git(f.src, "commit", "-q", "-m", "target moves") | |
| 62 | os.MkdirAll(filepath.Dir(f.dir), 0o755) | |
| 63 | f.git(root, "init", "-q", "--bare", f.dir) | |
| 64 | f.sync() | |
| 65 | f.git(f.dir, "update-ref", "refs/merge-requests/1/head", head) | |
| 66 | if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "t", "", head, "md", false); err != nil { | |
| 67 | t.Fatal(err) | |
| 68 | } | |
| 69 | ||
| 70 | var out, errOut bytes.Buffer | |
| 71 | c := &control.Ctx{User: store.User{ID: uid, Username: "alice"}, Scope: "full", Store: st, Cfg: cfg, Stdout: &out, Stderr: &errOut} | |
| 72 | if code := control.Dispatch(c, []string{"mr", "merge", repo.Path(), "1", "--when-ready", "--strategy", "ff"}); code != protocol.ExitOK { | |
| 73 | t.Fatalf("mr merge --when-ready: exit %d, %s", code, errOut.String()) | |
| 74 | } | |
| 75 | if mr, _ := st.MRByNumber(repo.ID, 1); mr.QueuedAt == "" || !strings.Contains(mr.QueueReason, "fast-forward not possible") { | |
| 76 | t.Fatalf("queued MR = %+v, want it waiting on a fast-forward", mr) | |
| 77 | } | |
| 78 | ||
| 79 | f.git(f.src, "checkout", "-q", "feature") | |
| 80 | f.git(f.src, "rebase", "-q", "main") | |
| 81 | rebased := f.sha("HEAD") | |
| 82 | f.sync() | |
| 83 | srv.postReceive(Request{RepoID: repo.ID, UserID: uid, Scope: "full", Updates: []policy.RefUpdate{ | |
| 84 | {Ref: "refs/heads/feature", Old: head, New: rebased, IsForce: true}}}) | |
| 85 | ||
| 86 | mr, err := st.MRByNumber(repo.ID, 1) | |
| 87 | if err != nil { | |
| 88 | t.Fatal(err) | |
| 89 | } | |
| 90 | if mr.State != "merged" || mr.MergedBy != "alice" || mr.QueuedAt != "" { | |
| 91 | t.Fatalf("MR after push = %s by %q queued %q (%s), want merged by alice", mr.State, mr.MergedBy, mr.QueuedAt, mr.QueueReason) | |
| 92 | } | |
| 93 | if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != rebased { | |
| 94 | t.Fatalf("main = %s, want %s", main, rebased) | |
| 95 | } | |
| 96 | } | |