e2e/suggestion_test.go
213 lines · 8840 bytes
6 symbols in this file
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}