e2e/reviewloop_test.go

e2a32d5f8d59e4213571c602bd9009b6c8fa86ed
gitbay/e2e/reviewloop_test.go history · blame · raw

207 lines · 8779 bytes

  1package e2e
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10)
 11
 12// TestTwoAccountReviewLoop drives the collaboration features end to end as
 13// two distinct accounts: the author never approves their own work, and the
 14// reviewer sees each step arrive.
 15//
 16// This is not the second human #139 asks for, and it is worth being exact
 17// about why. The CODEOWNERS bug (#99) was a gate that never fired; a test
 18// written from the same understanding as the code would have asserted the
 19// same wrong thing. What this does close is the narrower gap that nothing
 20// exercised the loop end to end at all — every feature had its own test
 21// and none of them met.
 22func TestTwoAccountReviewLoop(t *testing.T) {
 23	t.Parallel()
 24	inst := startInstance(t)
 25	authorKey := inst.newKey(t, "author")
 26	reviewerKey := inst.newKey(t, "reviewer")
 27	// The author merges, and a merge commit carries their identity, so
 28	// the account needs a verified address. The first merge below can
 29	// fast-forward and would not have needed one; the second cannot.
 30	inst.admin(t, "admin", "user", "create", "author", "--key", authorKey+".pub",
 31		"--email", "author@example.test", "--verified")
 32	inst.admin(t, "admin", "user", "create", "reviewer", "--key", reviewerKey+".pub")
 33	// A third account with write access who owns nothing, so an approval
 34	// from them satisfies the count and leaves CODEOWNERS unsatisfied.
 35	helperKey := inst.newKey(t, "helper")
 36	inst.admin(t, "admin", "user", "create", "helper", "--key", helperKey+".pub")
 37
 38	if _, errOut, code := inst.ssh(t, authorKey, "", "repo", "create", "author/lib"); code != 0 {
 39		t.Fatalf("repo create: %s", errOut)
 40	}
 41	for _, who := range []string{"reviewer", "helper"} {
 42		if _, _, code := inst.ssh(t, authorKey, "", "repo", "access", "grant", "author/lib", who, "write"); code != 0 {
 43			t.Fatalf("grant %s failed", who)
 44		}
 45	}
 46	// Every gate at once, which is how a repository that means it is set
 47	// up, and which no single-feature test covers together.
 48	for _, s := range [][]string{
 49		{"repo", "settings", "require-approvals", "author/lib", "1"},
 50		{"repo", "settings", "require-resolved", "author/lib", "on"},
 51		{"repo", "settings", "require-codeowners", "author/lib", "on"},
 52	} {
 53		if _, errOut, code := inst.ssh(t, authorKey, "", s...); code != 0 {
 54			t.Fatalf("%v: %s", s, errOut)
 55		}
 56	}
 57
 58	env := inst.gitEnv(authorKey)
 59	work := t.TempDir()
 60	mustGit(t, work, env, "clone", inst.sshURL("author/lib"), "w")
 61	dir := filepath.Join(work, "w")
 62	os.WriteFile(filepath.Join(dir, "CODEOWNERS"), []byte("*.go @reviewer\n"), 0o644)
 63	os.WriteFile(filepath.Join(dir, "lib.go"), []byte("package lib\n"), 0o644)
 64	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 65	mustGit(t, dir, env, "add", ".")
 66	mustGit(t, dir, env, "commit", "-q", "-m", "base")
 67	mustGit(t, dir, env, "push", "-q", "origin", "main")
 68
 69	mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
 70	os.WriteFile(filepath.Join(dir, "lib.go"), []byte("package lib\n\nvar V = 1\n"), 0o644)
 71	mustGit(t, dir, env, "add", ".")
 72	mustGit(t, dir, env, "commit", "-q", "-m", "add V")
 73	mustGit(t, dir, env, "push", "-q", "origin", "feat")
 74
 75	// Opened as a draft: the reviewer is not asked yet.
 76	if _, errOut, code := inst.ssh(t, authorKey, "", "mr", "create", "author/lib",
 77		"--source", "feat", "--target", "main", "--title", "'add V'", "--draft"); code != 0 {
 78		t.Fatalf("mr create: %s", errOut)
 79	}
 80	if q := reviewQueue(t, inst, reviewerKey); len(q) != 0 {
 81		t.Fatalf("a draft is waiting on the reviewer: %v", q)
 82	}
 83
 84	// Ready. Now it is theirs, and it reaches their inbox.
 85	if _, errOut, code := inst.ssh(t, authorKey, "", "mr", "ready", "author/lib", "1"); code != 0 {
 86		t.Fatalf("mr ready: %s", errOut)
 87	}
 88	if q := reviewQueue(t, inst, reviewerKey); len(q) != 1 || q[0] != 1 {
 89		t.Fatalf("review queue = %v, want !1", q)
 90	}
 91	// The queue is how this reviewer finds out: nobody ran "mr review
 92	// request" for them, so being an owner or already in the thread is
 93	// the only other way in, and this reviewer is neither. Writing this
 94	// test is what surfaced the gap — see #145; TestMRReviewRequest covers
 95	// the case where someone has been asked directly.
 96	if got := inbox(t, inst, reviewerKey); strings.Contains(got, "ready for review") {
 97		t.Fatalf("a reviewer is notified without being asked or involved:\n%s", got)
 98	}
 99
100	// The author cannot approve their own work past the gate.
101	if _, _, code := inst.ssh(t, authorKey, "", "mr", "review", "author/lib", "1", "--approve"); code != 0 {
102		t.Fatal("self review refused outright; it should be recorded and not counted")
103	}
104	_, errOut, code := inst.ssh(t, authorKey, "", "mr", "merge", "author/lib", "1")
105	if code != 4 || !strings.Contains(errOut, "requires 1 fresh approval") {
106		t.Fatalf("author's own approval counted: exit %d, %s", code, errOut)
107	}
108
109	// A review thread from the reviewer blocks on require-resolved.
110	out, errOut, code := inst.ssh(t, reviewerKey, "", "mr", "diff-comment", "author/lib", "1",
111		"--path", "lib.go", "--line", "3", "--message", "'name it better'", "--json")
112	if code != 0 {
113		t.Fatalf("diff-comment: %s", errOut)
114	}
115	var thread struct {
116		Data struct {
117			Thread int64 `json:"thread"`
118		} `json:"data"`
119	}
120	json.Unmarshal([]byte(out), &thread)
121	if !strings.Contains(inbox(t, inst, authorKey), "commented on lib.go") {
122		t.Fatalf("author not told about the review thread:\n%s", inbox(t, inst, authorKey))
123	}
124
125	if _, _, code := inst.ssh(t, reviewerKey, "", "mr", "review", "author/lib", "1", "--approve"); code != 0 {
126		t.Fatal("reviewer approve failed")
127	}
128	_, errOut, code = inst.ssh(t, authorKey, "", "mr", "merge", "author/lib", "1")
129	if code != 4 || !strings.Contains(errOut, "threads resolved") {
130		t.Fatalf("open thread did not block the merge: exit %d, %s", code, errOut)
131	}
132
133	// Resolve, and every gate is satisfied at once: an owner approved, the
134	// count is met, the thread is closed.
135	if _, errOut, code := inst.ssh(t, reviewerKey, "", "mr", "resolve", "author/lib", "1",
136		fmt.Sprint(thread.Data.Thread)); code != 0 {
137		t.Fatalf("resolve: %s", errOut)
138	}
139	if _, errOut, code := inst.ssh(t, authorKey, "", "mr", "merge", "author/lib", "1"); code != 0 {
140		t.Fatalf("fully reviewed MR refused: %s", errOut)
141	}
142	if !strings.Contains(inbox(t, inst, reviewerKey), "merged !1") {
143		t.Fatalf("reviewer not told about the merge:\n%s", inbox(t, inst, reviewerKey))
144	}
145
146	// A CODEOWNERS gate with no owner approval still refuses, on a second
147	// MR, so the pass above was the approval and not the gate being off.
148	mustGit(t, dir, env, "checkout", "-q", "-b", "feat2", "main")
149	os.WriteFile(filepath.Join(dir, "other.go"), []byte("package lib\n"), 0o644)
150	mustGit(t, dir, env, "add", ".")
151	mustGit(t, dir, env, "commit", "-q", "-m", "another")
152	mustGit(t, dir, env, "push", "-q", "origin", "feat2")
153	if _, errOut, code := inst.ssh(t, authorKey, "", "mr", "create", "author/lib",
154		"--source", "feat2", "--target", "main", "--title", "'another'"); code != 0 {
155		t.Fatalf("mr create: %s", errOut)
156	}
157	// helper's approval meets require-approvals but owns none of the
158	// changed files, so what refuses this merge is CODEOWNERS alone —
159	// the gate that used to be reachable only through the approval count
160	// (#99), now on its own toggle (#142).
161	if _, _, code := inst.ssh(t, helperKey, "", "mr", "review", "author/lib", "2", "--approve"); code != 0 {
162		t.Fatal("helper approve failed")
163	}
164	_, errOut, code = inst.ssh(t, authorKey, "", "mr", "merge", "author/lib", "2")
165	if code != 4 || !strings.Contains(errOut, "CODEOWNERS") {
166		t.Fatalf("CODEOWNERS gate did not fire on the second MR: exit %d, %s", code, errOut)
167	}
168	// And the owner's approval clears it.
169	if _, _, code := inst.ssh(t, reviewerKey, "", "mr", "review", "author/lib", "2", "--approve"); code != 0 {
170		t.Fatal("reviewer approve failed")
171	}
172	if _, errOut, code := inst.ssh(t, authorKey, "", "mr", "merge", "author/lib", "2"); code != 0 {
173		t.Fatalf("owner-approved MR refused: %s", errOut)
174	}
175}
176
177// reviewQueue returns the MR numbers waiting on this account.
178func reviewQueue(t *testing.T, inst *instance, key string) []int64 {
179	t.Helper()
180	out, errOut, code := inst.ssh(t, key, "", "dashboard", "--json")
181	if code != 0 {
182		t.Fatalf("dashboard: %s", errOut)
183	}
184	var env struct {
185		Data struct {
186			Reviews []struct {
187				Number int64 `json:"number"`
188			} `json:"review_queue"`
189		} `json:"data"`
190	}
191	json.Unmarshal([]byte(out), &env)
192	var ns []int64
193	for _, r := range env.Data.Reviews {
194		ns = append(ns, r.Number)
195	}
196	return ns
197}
198
199// inbox returns this account's notifications as raw JSON.
200func inbox(t *testing.T, inst *instance, key string) string {
201	t.Helper()
202	out, errOut, code := inst.ssh(t, key, "", "notifications", "list", "--all", "--json")
203	if code != 0 {
204		t.Fatalf("notifications list: %s", errOut)
205	}
206	return out
207}