e2e/approvals_test.go

6f16ad5df0845d8f44ec6530f1d1d6993d8dcb70
gitbay/e2e/approvals_test.go history · blame · raw

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