e2e/suggestion_test.go

v1.39.0
gitbay/e2e/suggestion_test.go history · blame · raw

213 lines · 8840 bytes

  1package e2e
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"os"
  7	"path/filepath"
  8	"strings"
  9	"testing"
 10)
 11
 12// suggestionCLI is a CLI configured as key against inst.
 13func suggestionCLI(t *testing.T, inst *instance, key string) *cli {
 14	t.Helper()
 15	c := &cli{bin: buildGitbayCLI(t), configDir: t.TempDir(), inst: inst, key: key}
 16	c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
 17		"--port", fmt.Sprint(inst.port),
 18		"--ssh-option", "-i", "--ssh-option", key,
 19		"--ssh-option", "-oIdentitiesOnly=yes",
 20		"--ssh-option", "-oStrictHostKeyChecking=no",
 21		"--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"),
 22		"--ssh-option", "-oBatchMode=yes",
 23		"--default")
 24	return c
 25}
 26
 27// postSuggestion opens a thread on lib.txt line 2 of !1 in repo whose
 28// suggestion replaces it with two lines, and returns the thread id.
 29func postSuggestion(t *testing.T, inst *instance, key, repo string) string {
 30	t.Helper()
 31	body := "split this\n```suggestion\nTWO\nTWO AND A HALF\n```\n"
 32	out, errOut, code := inst.ssh(t, key, body, "mr", "diff-comment", repo, "1",
 33		"--path", "lib.txt", "--line", "2", "--file", "-", "--json")
 34	if code != 0 {
 35		t.Fatalf("diff-comment: exit %d %s", code, errOut)
 36	}
 37	var env struct {
 38		Data struct {
 39			Thread int64 `json:"thread"`
 40		} `json:"data"`
 41	}
 42	json.Unmarshal([]byte(out), &env)
 43	return fmt.Sprint(env.Data.Thread)
 44}
 45
 46type e2eThread struct {
 47	ID         int64  `json:"id"`
 48	Resolved   string `json:"resolved_by"`
 49	Suggestion *struct {
 50		StartLine   int    `json:"start_line"`
 51		EndLine     int    `json:"end_line"`
 52		Original    string `json:"original"`
 53		Replacement string `json:"replacement"`
 54		Outdated    bool   `json:"outdated"`
 55		Apply       string `json:"apply"`
 56	} `json:"suggestion"`
 57}
 58
 59func threadsOf(t *testing.T, inst *instance, key, repo string) []e2eThread {
 60	t.Helper()
 61	out, errOut, code := inst.ssh(t, key, "", "mr", "threads", repo, "1", "--json")
 62	if code != 0 {
 63		t.Fatalf("mr threads: %s", errOut)
 64	}
 65	var env struct {
 66		Data []e2eThread `json:"data"`
 67	}
 68	if err := json.Unmarshal([]byte(out), &env); err != nil {
 69		t.Fatalf("threads JSON: %v\n%s", err, out)
 70	}
 71	return env.Data
 72}
 73
 74// A reviewer's suggestion, applied by the author through the CLI, which
 75// asks the server to commit it: the source branch gets one commit by the
 76// author with the suggested lines, and the thread is resolved.
 77func TestSuggestionAppliedByServer(t *testing.T) {
 78	t.Parallel()
 79	inst := startInstance(t)
 80	aliceKey := inst.newKey(t, "alice")
 81	bobKey := inst.newKey(t, "bob")
 82	inst.admin(t, "admin", "user", "create", "alice",
 83		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 84	inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub")
 85	c := suggestionCLI(t, inst, aliceKey)
 86	c.must(t, "", "", "repo", "create", "alice/lib")
 87
 88	env := inst.gitEnv(aliceKey)
 89	work := t.TempDir()
 90	mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
 91	dir := filepath.Join(work, "w")
 92	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 93	os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\n"), 0o644)
 94	mustGit(t, dir, env, "add", ".")
 95	mustGit(t, dir, env, "commit", "-q", "-m", "base")
 96	mustGit(t, dir, env, "push", "-q", "origin", "main")
 97	mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
 98	os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\ntwo\nthree\n"), 0o644)
 99	mustGit(t, dir, env, "commit", "-q", "-am", "more")
100	mustGit(t, dir, env, "push", "-q", "origin", "feat")
101	c.must(t, dir, "", "mr", "create", "alice/lib", "--source", "feat", "--target", "main", "--title", "more")
102
103	thread := postSuggestion(t, inst, bobKey, "alice/lib")
104	th := threadsOf(t, inst, aliceKey, "alice/lib")
105	if len(th) != 1 || th[0].Suggestion == nil || th[0].Suggestion.Original != "two\n" ||
106		th[0].Suggestion.Replacement != "TWO\nTWO AND A HALF\n" || th[0].Suggestion.Apply != "server" {
107		t.Fatalf("threads = %+v", th)
108	}
109
110	// bob reads the repository and cannot push to it, so he cannot apply.
111	if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "apply-suggestion", "alice/lib", "1", thread); code != 4 {
112		t.Fatalf("reader applied a suggestion: exit %d %s", code, errOut)
113	}
114
115	out, errOut, code := c.run(t, dir, "", "mr", "apply-suggestion", "1", thread, "--json")
116	if code != 0 {
117		t.Fatalf("apply-suggestion: exit %d\n%s\n%s", code, out, errOut)
118	}
119	if !strings.Contains(out, `"resolved":true`) {
120		t.Errorf("apply-suggestion --json = %s", out)
121	}
122	mustGit(t, dir, env, "fetch", "-q", "origin", "feat")
123	if got := mustGit(t, dir, env, "show", "FETCH_HEAD:lib.txt"); got != "one\nTWO\nTWO AND A HALF\nthree\n" {
124		t.Fatalf("lib.txt = %q", got)
125	}
126	if who := mustGit(t, dir, env, "log", "-1", "--format=%an <%ae>", "FETCH_HEAD"); strings.TrimSpace(who) != "alice <alice@example.test>" {
127		t.Errorf("commit by %q", who)
128	}
129	if th := threadsOf(t, inst, aliceKey, "alice/lib"); th[0].Resolved != "alice" || !th[0].Suggestion.Outdated {
130		t.Errorf("after apply: thread = %+v", th[0])
131	}
132}
133
134// On a repository requiring signed commits the server refuses, and the
135// CLI applies the suggestion in the clone with the user's own signing
136// key: the push passes the signed-commit check, and the working tree is
137// left as it was.
138func TestSuggestionAppliedLocallyWhenSigned(t *testing.T) {
139	t.Parallel()
140	inst := startInstance(t)
141	aliceKey := inst.newKey(t, "alice")
142	inst.admin(t, "admin", "user", "create", "alice",
143		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
144	c := suggestionCLI(t, inst, aliceKey)
145	c.must(t, "", "", "repo", "create", "alice/sec")
146	c.must(t, "", "", "repo", "settings", "require-signed", "alice/sec", "on")
147
148	// alice signs with her SSH key, as git's own config says to.
149	ident := []string{"GIT_AUTHOR_NAME=alice", "GIT_AUTHOR_EMAIL=alice@example.test",
150		"GIT_COMMITTER_NAME=alice", "GIT_COMMITTER_EMAIL=alice@example.test"}
151	env := append(inst.gitEnv(aliceKey), ident...)
152	c.env = ident
153	work := t.TempDir()
154	mustGit(t, work, env, "clone", inst.sshURL("alice/sec"), "w")
155	dir := filepath.Join(work, "w")
156	mustGit(t, dir, env, "config", "gpg.format", "ssh")
157	mustGit(t, dir, env, "config", "user.signingkey", aliceKey)
158	mustGit(t, dir, env, "config", "commit.gpgsign", "true")
159	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
160	os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\n"), 0o644)
161	mustGit(t, dir, env, "add", ".")
162	mustGit(t, dir, env, "commit", "-q", "-m", "base")
163	mustGit(t, dir, env, "push", "-q", "origin", "main")
164	mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
165	os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("one\ntwo\nthree\n"), 0o644)
166	mustGit(t, dir, env, "commit", "-q", "-am", "more")
167	mustGit(t, dir, env, "push", "-q", "origin", "feat")
168	c.must(t, dir, "", "mr", "create", "alice/sec", "--source", "feat", "--target", "main", "--title", "more")
169	mustGit(t, dir, env, "checkout", "-q", "main")
170
171	thread := postSuggestion(t, inst, aliceKey, "alice/sec")
172	if th := threadsOf(t, inst, aliceKey, "alice/sec"); th[0].Suggestion == nil || th[0].Suggestion.Apply != "local" {
173		t.Fatalf("threads = %+v, want a suggestion applied locally", th)
174	}
175	_, errOut, code := inst.ssh(t, aliceKey, "", "mr", "apply-suggestion", "alice/sec", "1", thread)
176	if code != 4 || !strings.Contains(errOut, "gitbay mr apply-suggestion alice/sec 1 "+thread) {
177		t.Fatalf("server-side apply on a require-signed repository: exit %d %s", code, errOut)
178	}
179
180	out, errOut, code := c.run(t, dir, "", "mr", "apply-suggestion", "1", thread, "--json")
181	if code != 0 {
182		t.Fatalf("local apply-suggestion: exit %d\n%s\n%s", code, out, errOut)
183	}
184	var res struct {
185		Data struct {
186			SHA      string `json:"sha"`
187			Branch   string `json:"branch"`
188			Resolved bool   `json:"resolved"`
189		} `json:"data"`
190	}
191	if err := json.Unmarshal([]byte(out), &res); err != nil || res.Data.Branch != "feat" || !res.Data.Resolved {
192		t.Fatalf("local apply-suggestion --json = %s (%v)", out, err)
193	}
194	mustGit(t, dir, env, "fetch", "-q", "origin", "feat")
195	if tip := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "FETCH_HEAD")); tip != res.Data.SHA {
196		t.Errorf("reported sha %s, branch at %s", res.Data.SHA, tip)
197	}
198	if got := mustGit(t, dir, env, "show", "FETCH_HEAD:lib.txt"); got != "one\nTWO\nTWO AND A HALF\nthree\n" {
199		t.Fatalf("lib.txt = %q", got)
200	}
201	if msg := mustGit(t, dir, env, "log", "-1", "--format=%B", "FETCH_HEAD"); !strings.Contains(msg, "Thread "+thread+" on alice/sec!1") {
202		t.Errorf("commit message = %q", msg)
203	}
204	if branch := strings.TrimSpace(mustGit(t, dir, env, "branch", "--show-current")); branch != "main" {
205		t.Errorf("checked-out branch moved to %q", branch)
206	}
207	if st := mustGit(t, dir, env, "status", "--porcelain"); st != "" {
208		t.Errorf("working tree touched:\n%s", st)
209	}
210	if th := threadsOf(t, inst, aliceKey, "alice/sec"); th[0].Resolved != "alice" {
211		t.Errorf("thread not resolved: %+v", th[0])
212	}
213}