e2e/approvals_test.go

v1.36.0
gitbay/e2e/approvals_test.go history · blame · raw

241 lines · 11061 bytes

  1package e2e
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10)
 11
 12func TestMergeRequirements(t *testing.T) {
 13	t.Parallel()
 14	inst := startInstance(t)
 15	aliceKey := inst.newKey(t, "alice")
 16	bobKey := inst.newKey(t, "bob")
 17	carolKey := inst.newKey(t, "carol")
 18	inst.admin(t, "admin", "user", "create", "alice",
 19		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 20	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
 21	inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub")
 22
 23	// Repo with CODEOWNERS on main: carol owns *.go.
 24	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/svc"); code != 0 {
 25		t.Fatalf("repo create: %s", errOut)
 26	}
 27	for _, u := range []string{"bob", "carol"} {
 28		if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/svc", u, "write"); code != 0 {
 29			t.Fatal("grant failed")
 30		}
 31	}
 32	work := t.TempDir()
 33	env := inst.gitEnv(aliceKey)
 34	mustGit(t, work, env, "clone", inst.sshURL("alice/svc"), "w")
 35	dir := filepath.Join(work, "w")
 36	os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @carol\n"), 0o644)
 37	os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n"), 0o644)
 38	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 39	mustGit(t, dir, env, "add", ".")
 40	mustGit(t, dir, env, "commit", "-q", "-m", "base")
 41	mustGit(t, dir, env, "push", "-q", "origin", "main")
 42
 43	// MR by alice touching a .go file.
 44	mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
 45	os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n\nvar V = 1\n"), 0o644)
 46	mustGit(t, dir, env, "add", ".")
 47	mustGit(t, dir, env, "commit", "-q", "-m", "change")
 48	mustGit(t, dir, env, "push", "-q", "origin", "feat")
 49	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc",
 50		"--source", "feat", "--target", "main", "--title", "'change'"); code != 0 {
 51		t.Fatalf("mr create: %s", errOut)
 52	}
 53	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-approvals", "alice/svc", "1"); code != 0 {
 54		t.Fatal("require-approvals failed")
 55	}
 56	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-codeowners", "alice/svc", "on"); code != 0 {
 57		t.Fatal("require-codeowners failed")
 58	}
 59
 60	// No approvals: refused. The author's own approval does not count.
 61	_, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
 62	if code != 4 || !strings.Contains(errOut, "requires 1 fresh approval") {
 63		t.Fatalf("no-approval merge: exit %d, %s", code, errOut)
 64	}
 65	if _, _, code = inst.ssh(t, aliceKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 {
 66		t.Fatal("self review failed")
 67	}
 68	if _, _, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 4 {
 69		t.Fatal("author self-approval counted")
 70	}
 71
 72	// Bob approves — but CODEOWNERS demands carol for *.go.
 73	if _, _, code = inst.ssh(t, bobKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 {
 74		t.Fatal("bob review failed")
 75	}
 76	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
 77	if code != 4 || !strings.Contains(errOut, "CODEOWNERS") || !strings.Contains(errOut, "carol") {
 78		t.Fatalf("codeowners gate: exit %d, %s", code, errOut)
 79	}
 80
 81	// A fresh request-changes blocks even with approvals present.
 82	if _, _, code = inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--request-changes"); code != 0 {
 83		t.Fatal("carol review failed")
 84	}
 85	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
 86	if code != 4 || !strings.Contains(errOut, "carol requested changes") {
 87		t.Fatalf("request-changes block: exit %d, %s", code, errOut)
 88	}
 89
 90	// Carol's latest review wins: her approval satisfies both the count
 91	// and CODEOWNERS.
 92	if _, _, code = inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 {
 93		t.Fatal("carol approve failed")
 94	}
 95
 96	// require-resolved: an open thread still blocks; resolving unblocks.
 97	if _, _, code = inst.ssh(t, aliceKey, "", "repo", "settings", "require-resolved", "alice/svc", "on"); code != 0 {
 98		t.Fatal("require-resolved failed")
 99	}
100	tout, _, code2 := inst.ssh(t, bobKey, "", "mr", "diff-comment", "alice/svc", "1",
101		"--path", "svc.go", "--line", "3", "--message", "'name it better'", "--json")
102	if code2 != 0 {
103		t.Fatal("diff-comment failed")
104	}
105	var tenv struct {
106		Data struct {
107			Thread int64 `json:"thread"`
108		} `json:"data"`
109	}
110	json.Unmarshal([]byte(tout), &tenv)
111	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
112	if code != 4 || !strings.Contains(errOut, "threads resolved") {
113		t.Fatalf("resolved gate: exit %d, %s", code, errOut)
114	}
115	if _, _, code = inst.ssh(t, bobKey, "", "mr", "resolve", "alice/svc", "1", fmt.Sprint(tenv.Data.Thread)); code != 0 {
116		t.Fatal("resolve failed")
117	}
118	if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 {
119		t.Fatalf("fully gated merge: %s", errOut)
120	}
121
122	// Stale approvals never count: new MR, approve, force-push a changed
123	// diff, refused. (A force-push carrying the same diff keeps them, #198.)
124	mustGit(t, dir, env, "fetch", "-q", "origin")
125	mustGit(t, dir, env, "checkout", "-q", "-b", "feat2", "origin/main")
126	os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("n\n"), 0o644)
127	mustGit(t, dir, env, "add", ".")
128	mustGit(t, dir, env, "commit", "-q", "-m", "notes")
129	mustGit(t, dir, env, "push", "-q", "origin", "feat2")
130	if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc",
131		"--source", "feat2", "--target", "main", "--title", "'notes'"); code != 0 {
132		t.Fatal("mr2 create failed")
133	}
134	if _, _, code = inst.ssh(t, bobKey, "", "mr", "review", "alice/svc", "2", "--approve"); code != 0 {
135		t.Fatal("bob approve 2 failed")
136	}
137	os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("n2\n"), 0o644)
138	mustGit(t, dir, env, "commit", "-q", "-a", "--amend", "-m", "notes v2")
139	mustGit(t, dir, env, "push", "-q", "--force", "origin", "feat2")
140	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "2")
141	if code != 4 || !strings.Contains(errOut, "requires 1 fresh approval") {
142		t.Fatalf("stale approval counted: exit %d, %s", code, errOut)
143	}
144}
145
146// require_codeowners is the opt-in, not the file's presence: a repository
147// can carry CODEOWNERS as documentation of who to ask without it gating
148// merges. When it is on, it gates independently of require_approvals —
149// the coupling that left owners unenforced under default settings (#99).
150func TestCodeownersToggle(t *testing.T) {
151	t.Parallel()
152	inst := startInstance(t)
153	aliceKey := inst.newKey(t, "alice")
154	carolKey := inst.newKey(t, "carol")
155	inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
156	inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub")
157	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/svc"); code != 0 {
158		t.Fatalf("repo create: %s", errOut)
159	}
160	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/svc", "carol", "write"); code != 0 {
161		t.Fatal("grant failed")
162	}
163	work := t.TempDir()
164	env := inst.gitEnv(aliceKey)
165	mustGit(t, work, env, "clone", inst.sshURL("alice/svc"), "w")
166	dir := filepath.Join(work, "w")
167	os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @carol\n"), 0o644)
168	os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n"), 0o644)
169	os.WriteFile(filepath.Join(dir, "lib.go"), []byte("package svc\n"), 0o644)
170	os.WriteFile(filepath.Join(dir, "README"), []byte("svc\n"), 0o644)
171	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
172	mustGit(t, dir, env, "add", ".")
173	mustGit(t, dir, env, "commit", "-q", "-m", "base")
174	mustGit(t, dir, env, "push", "-q", "origin", "main")
175
176	// !1 and !3 touch owned files, !2 does not.
177	for i, f := range []string{"svc.go", "README", "lib.go"} {
178		mustGit(t, dir, env, "checkout", "-q", "-b", fmt.Sprintf("feat%d", i), "main")
179		os.WriteFile(filepath.Join(dir, f), []byte("changed\n"), 0o644)
180		mustGit(t, dir, env, "add", ".")
181		mustGit(t, dir, env, "commit", "-q", "-m", "change")
182		mustGit(t, dir, env, "push", "-q", "origin", fmt.Sprintf("feat%d", i))
183		if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc",
184			"--source", fmt.Sprintf("feat%d", i), "--target", "main", "--title", "'change'"); code != 0 {
185			t.Fatalf("mr create: %s", errOut)
186		}
187	}
188
189	// Toggle off, which is the default: the file is present and gates
190	// nothing, so an owned file merges without its owner.
191	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "3"); code != 0 {
192		t.Fatalf("owned file gated with the toggle off: %s", errOut)
193	}
194
195	// Toggle on with require_approvals still 0: the owned file is gated,
196	// the unowned one is not.
197	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-codeowners", "alice/svc", "on"); code != 0 {
198		t.Fatalf("require-codeowners: %s", errOut)
199	}
200	_, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
201	if code != 4 || !strings.Contains(errOut, "CODEOWNERS") || !strings.Contains(errOut, "carol") {
202		t.Fatalf("codeowners gate with require-approvals off: exit %d, %s", code, errOut)
203	}
204	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "2"); code != 0 {
205		t.Fatalf("unowned change refused: %s", errOut)
206	}
207	if _, _, code := inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 {
208		t.Fatal("carol review failed")
209	}
210	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 {
211		t.Fatalf("owner-approved merge refused: %s", errOut)
212	}
213
214	// The toggle on a repository with no CODEOWNERS file says so rather
215	// than silently gating nothing.
216	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/bare"); code != 0 {
217		t.Fatalf("repo create: %s", errOut)
218	}
219	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-codeowners", "alice/bare", "on"); code != 0 {
220		t.Fatal("require-codeowners on alice/bare failed")
221	}
222	bare := t.TempDir()
223	mustGit(t, bare, env, "clone", inst.sshURL("alice/bare"), "b")
224	bdir := filepath.Join(bare, "b")
225	os.WriteFile(filepath.Join(bdir, "a.txt"), []byte("a\n"), 0o644)
226	mustGit(t, bdir, env, "checkout", "-q", "-b", "main")
227	mustGit(t, bdir, env, "add", ".")
228	mustGit(t, bdir, env, "commit", "-q", "-m", "base")
229	mustGit(t, bdir, env, "push", "-q", "origin", "main")
230	mustGit(t, bdir, env, "checkout", "-q", "-b", "feat")
231	mustGit(t, bdir, env, "commit", "-q", "--allow-empty", "-m", "work")
232	mustGit(t, bdir, env, "push", "-q", "origin", "feat")
233	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/bare",
234		"--source", "feat", "--target", "main", "--title", "'work'"); code != 0 {
235		t.Fatalf("mr create: %s", errOut)
236	}
237	_, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/bare", "1")
238	if code != 4 || !strings.Contains(errOut, "no CODEOWNERS file") {
239		t.Fatalf("missing CODEOWNERS file: exit %d, %s", code, errOut)
240	}
241}