e2e/approvals_test.go
138 lines · 5878 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
56 // No approvals: refused. The author's own approval does not count.
57 _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
58 if code != 4 || !strings.Contains(errOut, "requires 1 fresh approval") {
59 t.Fatalf("no-approval merge: exit %d, %s", code, errOut)
60 }
61 if _, _, code = inst.ssh(t, aliceKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 {
62 t.Fatal("self review failed")
63 }
64 if _, _, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 4 {
65 t.Fatal("author self-approval counted")
66 }
67
68 // Bob approves — but CODEOWNERS demands carol for *.go.
69 if _, _, code = inst.ssh(t, bobKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 {
70 t.Fatal("bob review failed")
71 }
72 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
73 if code != 4 || !strings.Contains(errOut, "CODEOWNERS") || !strings.Contains(errOut, "carol") {
74 t.Fatalf("codeowners gate: exit %d, %s", code, errOut)
75 }
76
77 // A fresh request-changes blocks even with approvals present.
78 if _, _, code = inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--request-changes"); code != 0 {
79 t.Fatal("carol review failed")
80 }
81 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
82 if code != 4 || !strings.Contains(errOut, "carol requested changes") {
83 t.Fatalf("request-changes block: exit %d, %s", code, errOut)
84 }
85
86 // Carol's latest review wins: her approval satisfies both the count
87 // and CODEOWNERS.
88 if _, _, code = inst.ssh(t, carolKey, "", "mr", "review", "alice/svc", "1", "--approve"); code != 0 {
89 t.Fatal("carol approve failed")
90 }
91
92 // require-resolved: an open thread still blocks; resolving unblocks.
93 if _, _, code = inst.ssh(t, aliceKey, "", "repo", "settings", "require-resolved", "alice/svc", "on"); code != 0 {
94 t.Fatal("require-resolved failed")
95 }
96 tout, _, code2 := inst.ssh(t, bobKey, "", "mr", "diff-comment", "alice/svc", "1",
97 "--path", "svc.go", "--line", "3", "--message", "'name it better'", "--json")
98 if code2 != 0 {
99 t.Fatal("diff-comment failed")
100 }
101 var tenv struct {
102 Data struct {
103 Thread int64 `json:"thread"`
104 } `json:"data"`
105 }
106 json.Unmarshal([]byte(tout), &tenv)
107 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1")
108 if code != 4 || !strings.Contains(errOut, "threads resolved") {
109 t.Fatalf("resolved gate: exit %d, %s", code, errOut)
110 }
111 if _, _, code = inst.ssh(t, bobKey, "", "mr", "resolve", "alice/svc", "1", fmt.Sprint(tenv.Data.Thread)); code != 0 {
112 t.Fatal("resolve failed")
113 }
114 if _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "1"); code != 0 {
115 t.Fatalf("fully gated merge: %s", errOut)
116 }
117
118 // Stale approvals never count: new MR, approve, force-push, refused.
119 mustGit(t, dir, env, "fetch", "-q", "origin")
120 mustGit(t, dir, env, "checkout", "-q", "-b", "feat2", "origin/main")
121 os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("n\n"), 0o644)
122 mustGit(t, dir, env, "add", ".")
123 mustGit(t, dir, env, "commit", "-q", "-m", "notes")
124 mustGit(t, dir, env, "push", "-q", "origin", "feat2")
125 if _, _, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/svc",
126 "--source", "feat2", "--target", "main", "--title", "'notes'"); code != 0 {
127 t.Fatal("mr2 create failed")
128 }
129 if _, _, code = inst.ssh(t, bobKey, "", "mr", "review", "alice/svc", "2", "--approve"); code != 0 {
130 t.Fatal("bob approve 2 failed")
131 }
132 mustGit(t, dir, env, "commit", "-q", "--amend", "-m", "notes v2")
133 mustGit(t, dir, env, "push", "-q", "--force", "origin", "feat2")
134 _, errOut, code = inst.ssh(t, aliceKey, "", "mr", "merge", "alice/svc", "2")
135 if code != 4 || !strings.Contains(errOut, "requires 1 fresh approval") {
136 t.Fatalf("stale approval counted: exit %d, %s", code, errOut)
137 }
138}