internal/hookd/mergequeue_test.go
286 lines · 10792 bytes
4 symbols in this file
1package hookd
2
3import (
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.
21func 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.
103func 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
200func 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.
212func 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}