internal/hookd/mergequeue_test.go

v1.43.1
gitbay/internal/hookd/mergequeue_test.go history · blame · raw

286 lines · 10792 bytes

  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}