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