e2e/approvals_test.go
239 lines · 11033 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 a changed
122 // diff, refused. (A force-push carrying the same diff keeps them, #198.)
123 mustGit(t, dir, env, "fetch", "-q", "origin")
124 mustGit(t, dir, env, "checkout", "-q", "-b", "feat2", "origin/main")
125 os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("n\n"), 0o644)
126 mustGit(t, dir, env, "add", ".")
127 mustGit(t, dir, env, "commit", "-q", "-m", "notes")
128 mustGit(t, dir, env, "push", "-q", "origin", "feat2")
129 if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc",
130 "--source", "feat2", "--target", "main", "--title", "'notes'"); code != 0 {
131 t.Fatal("mr2 create failed")
132 }
133 if _, _, code = inst.ssh(t, bobKey, "", "mr", "review", "alice/svc", "2", "--approve"); code != 0 {
134 t.Fatal("bob approve 2 failed")
135 }
136 os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("n2\n"), 0o644)
137 mustGit(t, dir, env, "commit", "-q", "-a", "--amend", "-m", "notes v2")
138 mustGit(t, dir, env, "push", "-q", "--force", "origin", "feat2")
139 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "2")
140 if code != 4 || !strings.Contains(errOut, "requires 1 fresh approval") {
141 t.Fatalf("stale approval counted: exit %d, %s", code, errOut)
142 }
143}
144
145// require_codeowners is the opt-in, not the file's presence: a repository
146// can carry CODEOWNERS as documentation of who to ask without it gating
147// merges. When it is on, it gates independently of require_approvals —
148// the coupling that left owners unenforced under default settings (#99).
149func TestCodeownersToggle(t *testing.T) {
150 inst := startInstance(t)
151 aliceKey := inst.newKey(t, "alice")
152 carolKey := inst.newKey(t, "carol")
153 inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
154 inst.admin(t, "admin", "user", "create", "carol", "--key", carolKey+".pub")
155 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/svc"); code != 0 {
156 t.Fatalf("repo create: %s", errOut)
157 }
158 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/svc", "carol", "write"); code != 0 {
159 t.Fatal("grant failed")
160 }
161 work := t.TempDir()
162 env := inst.gitEnv(aliceKey)
163 mustGit(t, work, env, "clone", inst.sshURL("alice/svc"), "w")
164 dir := filepath.Join(work, "w")
165 os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @carol\n"), 0o644)
166 os.WriteFile(filepath.Join(dir, "svc.go"), []byte("package svc\n"), 0o644)
167 os.WriteFile(filepath.Join(dir, "lib.go"), []byte("package svc\n"), 0o644)
168 os.WriteFile(filepath.Join(dir, "README"), []byte("svc\n"), 0o644)
169 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
170 mustGit(t, dir, env, "add", ".")
171 mustGit(t, dir, env, "commit", "-q", "-m", "base")
172 mustGit(t, dir, env, "push", "-q", "origin", "main")
173
174 // !1 and !3 touch owned files, !2 does not.
175 for i, f := range []string{"svc.go", "README", "lib.go"} {
176 mustGit(t, dir, env, "checkout", "-q", "-b", fmt.Sprintf("feat%d", i), "main")
177 os.WriteFile(filepath.Join(dir, f), []byte("changed\n"), 0o644)
178 mustGit(t, dir, env, "add", ".")
179 mustGit(t, dir, env, "commit", "-q", "-m", "change")
180 mustGit(t, dir, env, "push", "-q", "origin", fmt.Sprintf("feat%d", i))
181 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc",
182 "--source", fmt.Sprintf("feat%d", i), "--target", "main", "--title", "'change'"); code != 0 {
183 t.Fatalf("mr create: %s", errOut)
184 }
185 }
186
187 // Toggle off, which is the default: the file is present and gates
188 // nothing, so an owned file merges without its owner.
189 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "3"); code != 0 {
190 t.Fatalf("owned file gated with the toggle off: %s", errOut)
191 }
192
193 // Toggle on with require_approvals still 0: the owned file is gated,
194 // the unowned one is not.
195 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-codeowners", "alice/svc", "on"); code != 0 {
196 t.Fatalf("require-codeowners: %s", errOut)
197 }
198 _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
199 if code != 4 || !strings.Contains(errOut, "CODEOWNERS") || !strings.Contains(errOut, "carol") {
200 t.Fatalf("codeowners gate with require-approvals off: exit %d, %s", code, errOut)
201 }
202 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "2"); code != 0 {
203 t.Fatalf("unowned change refused: %s", errOut)
204 }
205 if _, _, code := inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 {
206 t.Fatal("carol review failed")
207 }
208 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 {
209 t.Fatalf("owner-approved merge refused: %s", errOut)
210 }
211
212 // The toggle on a repository with no CODEOWNERS file says so rather
213 // than silently gating nothing.
214 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/bare"); code != 0 {
215 t.Fatalf("repo create: %s", errOut)
216 }
217 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-codeowners", "alice/bare", "on"); code != 0 {
218 t.Fatal("require-codeowners on alice/bare failed")
219 }
220 bare := t.TempDir()
221 mustGit(t, bare, env, "clone", inst.sshURL("alice/bare"), "b")
222 bdir := filepath.Join(bare, "b")
223 os.WriteFile(filepath.Join(bdir, "a.txt"), []byte("a\n"), 0o644)
224 mustGit(t, bdir, env, "checkout", "-q", "-b", "main")
225 mustGit(t, bdir, env, "add", ".")
226 mustGit(t, bdir, env, "commit", "-q", "-m", "base")
227 mustGit(t, bdir, env, "push", "-q", "origin", "main")
228 mustGit(t, bdir, env, "checkout", "-q", "-b", "feat")
229 mustGit(t, bdir, env, "commit", "-q", "--allow-empty", "-m", "work")
230 mustGit(t, bdir, env, "push", "-q", "origin", "feat")
231 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/bare",
232 "--source", "feat", "--target", "main", "--title", "'work'"); code != 0 {
233 t.Fatalf("mr create: %s", errOut)
234 }
235 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/bare", "1")
236 if code != 4 || !strings.Contains(errOut, "no CODEOWNERS file") {
237 t.Fatalf("missing CODEOWNERS file: exit %d, %s", code, errOut)
238 }
239}