e2e/approvals_test.go
241 lines · 11061 bytes
2 symbols in this file
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}