v0.14.0 815fb98

ci_pull_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"encoding/json"
7	"fmt"
8	"io"
9	"net/http"
10	"strings"
11	"testing"
12
13	"connectrpc.com/connect"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16
17	pb "bnema.dev/ermite-api/gen/ermite"
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22// CI-3 D5/D6, AC5: the pull_request event of merge requests, the refs
23// refs/pull/<n>/{head,merge} and the REST files endpoint paths-filter reads.
24
25const pullWorkflow = `name: pr
26on:
27  pull_request:
28%s
29jobs:
30  j:
31    runs-on: ubuntu-latest
32%s
33    steps:
34      - run: echo hi
35`
36
37func pullWF(on, jobExtra string) map[string]string {
38	return map[string]string{".github/workflows/pr.yml": fmt.Sprintf(pullWorkflow, on, jobExtra)}
39}
40
41// openMR opens a merge request from source into main as client.
42func (c *ciHarness) openMR(cl *clients, source string) int64 {
43	c.t.Helper()
44	res, err := cl.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
45		Repo: c.repo, Title: "Title of " + source, Description: "About " + source, SourceBranch: source, TargetBranch: "main",
46	}))
47	require.NoError(c.t, err)
48	return res.Msg.GetMergeRequest().GetNumber()
49}
50
51func (c *ciHarness) ghContext(j *pb.Job) map[string]any {
52	c.t.Helper()
53	var ctxs map[string]map[string]any
54	require.NoError(c.t, json.Unmarshal([]byte(j.GetContexts()), &ctxs))
55	return ctxs["github"]
56}
57
58func (c *ciHarness) ref(name string) string {
59	c.t.Helper()
60	out, err := c.git(c.repoDirOf(), "rev-parse", "--verify", "-q", name)
61	if err != nil {
62		return ""
63	}
64	return strings.TrimSpace(out)
65}
66
67func (c *ciHarness) repoDirOf() string { return c.repoDir("proj") }
68
69// finish runs the job of the next run to success.
70func (c *ciHarness) finish(r *fakeRunner) *pb.Job {
71	c.t.Helper()
72	j := r.await()
73	r.runJob(j, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
74	return j
75}
76
77func TestPullRequestOpenedSynchronizeRuns(t *testing.T) {
78	c := newCIHarness(t, nil)
79	runner := c.registerRunner("alpha", "ubuntu-latest")
80	c.push("main", pullWF("", ""), "")
81	c.push("main", map[string]string{"a.txt": "a\n"}, "")
82	main := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
83	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
84	feat1 := c.push("feat", map[string]string{"b.txt": "b\n"}, "")
85
86	n := c.openMR(c.root, "feat")
87	job := c.finish(runner)
88
89	// The refs: head is the branch, merge a test merge commit of main and feat.
90	assert.Equal(t, feat1, c.ref(domain.PullHeadRef(n)))
91	merge := c.ref(domain.PullMergeRef(n))
92	require.NotEmpty(t, merge)
93	assert.Equal(t, main+" "+feat1, strings.TrimSpace(c.git2("log", "-1", "--format=%P", merge)), "parents: base then head")
94	assert.Equal(t, "Merge "+feat1+" into "+main, strings.TrimSpace(c.git2("log", "-1", "--format=%s", merge)))
95	assert.Contains(t, c.git2("ls-tree", "--name-only", merge), "a.txt", "the tree has main's file")
96	assert.Contains(t, c.git2("ls-tree", "--name-only", merge), "b.txt", "and feat's")
97
98	runs := c.runs()
99	require.Len(t, runs, 1)
100	assert.Equal(t, "pull_request", runs[0].GetEvent())
101	assert.Equal(t, "refs/pull/1/merge", runs[0].GetRef())
102	assert.Equal(t, merge, runs[0].GetSha())
103	assert.True(t, runs[0].GetTrusted())
104	assert.Equal(t, "Title of feat", runs[0].GetTitle())
105
106	// Checks attach to the head, not to the merge commit.
107	require.Len(t, c.checks(feat1), 1)
108	assert.Equal(t, "j", c.checks(feat1)[0].GetName())
109	assert.Empty(t, c.checks(merge))
110
111	gh := c.ghContext(job)
112	assert.Equal(t, "pull_request", gh["event_name"])
113	assert.Equal(t, "refs/pull/1/merge", gh["ref"])
114	assert.Equal(t, "1/merge", gh["ref_name"])
115	assert.Equal(t, merge, gh["sha"])
116	assert.Equal(t, "feat", gh["head_ref"])
117	assert.Equal(t, "main", gh["base_ref"])
118	assert.Equal(t, merge, job.GetSha())
119	ev := gh["event"].(map[string]any)
120	assert.Equal(t, "opened", ev["action"])
121	assert.EqualValues(t, 1, ev["number"])
122	pr := ev["pull_request"].(map[string]any)
123	assert.EqualValues(t, 1, pr["number"])
124	assert.Equal(t, "open", pr["state"])
125	assert.Equal(t, "Title of feat", pr["title"])
126	assert.Equal(t, "About feat", pr["body"])
127	assert.Equal(t, false, pr["merged"])
128	assert.Equal(t, merge, pr["merge_commit_sha"])
129	assert.Equal(t, publicOrigin+"/owner/proj/merge-requests/1", pr["html_url"])
130	head, base := pr["head"].(map[string]any), pr["base"].(map[string]any)
131	assert.Equal(t, "feat", head["ref"])
132	assert.Equal(t, feat1, head["sha"])
133	assert.Equal(t, "main", base["ref"])
134	assert.Equal(t, main, base["sha"])
135	assert.Equal(t, "owner/proj", head["repo"].(map[string]any)["full_name"])
136	assert.Equal(t, "owner/proj", ev["repository"].(map[string]any)["full_name"])
137	assert.NotEmpty(t, ev["sender"].(map[string]any)["login"])
138
139	// A push to the source branch is a synchronize run, with the new merge.
140	feat2 := c.push("feat", map[string]string{"c.txt": "c\n"}, "")
141	job = c.finish(runner)
142	c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
143	assert.Equal(t, feat2, c.ref(domain.PullHeadRef(n)))
144	merge2 := c.ref(domain.PullMergeRef(n))
145	assert.NotEqual(t, merge, merge2)
146	gh = c.ghContext(job)
147	assert.Equal(t, merge2, gh["sha"])
148	ev = gh["event"].(map[string]any)
149	assert.Equal(t, "synchronize", ev["action"])
150	assert.Equal(t, feat1, ev["before"])
151	assert.Equal(t, feat2, ev["after"])
152	assert.Len(t, c.checks(feat2), 1)
153}
154
155// git2 runs git in the bare repository.
156func (c *ciHarness) git2(args ...string) string { return c.bareGit("proj", args...) }
157
158func TestPullRequestConflictingMergeRunsNothing(t *testing.T) {
159	c := newCIHarness(t, nil)
160	runner := c.registerRunner("alpha", "ubuntu-latest")
161	files := pullWF("", "")
162	files["a.txt"] = "base\n"
163	c.push("main", files, "")
164	c.mustGit(c.wt, "checkout", "-q", "-b", "conflict")
165	c.push("conflict", map[string]string{"a.txt": "feature\n"}, "")
166	c.mustGit(c.wt, "checkout", "-q", "main")
167	c.push("main", map[string]string{"a.txt": "main\n"}, "")
168	c.mustGit(c.wt, "checkout", "-q", "-b", "clean", "main")
169	c.push("clean", map[string]string{"z.txt": "z\n"}, "")
170
171	n1 := c.openMR(c.root, "conflict")
172	n2 := c.openMR(c.root, "clean")
173	job := c.finish(runner) // the one run is the clean request's
174	assert.EqualValues(t, 2, c.ghContext(job)["event"].(map[string]any)["number"])
175	assert.NotEmpty(t, c.ref(domain.PullHeadRef(n1)), "the head ref is kept")
176	assert.Empty(t, c.ref(domain.PullMergeRef(n1)), "no merge ref when the merge conflicts")
177	assert.NotEmpty(t, c.ref(domain.PullMergeRef(n2)))
178	require.Len(t, c.runs(), 1)
179
180	// A push that does not fix the conflict makes no run either.
181	c.mustGit(c.wt, "checkout", "-q", "conflict")
182	c.push("conflict", map[string]string{"other.txt": "o\n"}, "")
183	c.mustGit(c.wt, "checkout", "-q", "-b", "later", "main")
184	c.push("later", map[string]string{"l.txt": "l\n"}, "")
185	c.openMR(c.root, "later")
186	job = c.finish(runner)
187	assert.EqualValues(t, 3, c.ghContext(job)["event"].(map[string]any)["number"])
188	assert.Len(t, c.runs(), 2)
189}
190
191func TestPullRequestTypesAndClosed(t *testing.T) {
192	c := newCIHarness(t, nil)
193	runner := c.registerRunner("alpha", "ubuntu-latest")
194	// closed only: opened and synchronize start nothing.
195	c.push("main", pullWF("    types: [closed]", ""), "")
196	c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
197	c.push("f1", map[string]string{"1.txt": "1\n"}, "")
198	n1 := c.openMR(c.root, "f1")
199	f1 := c.push("f1", map[string]string{"1b.txt": "1\n"}, "")
200	c.mustGit(c.wt, "checkout", "-q", "main")
201	c.mustGit(c.wt, "checkout", "-q", "-b", "f2")
202	f2 := c.push("f2", map[string]string{"2.txt": "2\n"}, "")
203	n2 := c.openMR(c.root, "f2")
204	// The refs follow the MR creation and the push asynchronously: wait for
205	// both of each request, at the latest source sha.
206	c.waitFor("the refs of both requests", func() bool {
207		mergesHead := func(n int64, head string) bool {
208			m := c.ref(domain.PullMergeRef(n))
209			parents, err := c.git(c.repoDirOf(), "log", "-1", "--format=%P", m) // no require: runs in the poller
210			return m != "" && err == nil && strings.HasSuffix(strings.TrimSpace(parents), " "+head)
211		}
212		return c.ref(domain.PullHeadRef(n1)) == f1 && mergesHead(n1, f1) &&
213			c.ref(domain.PullHeadRef(n2)) == f2 && mergesHead(n2, f2)
214	})
215	require.Empty(t, c.runs(), "no run for opened and synchronize")
216	head1 := c.ref(domain.PullHeadRef(n1))
217	merge1 := c.ref(domain.PullMergeRef(n1))
218	require.Equal(t, f1, head1)
219
220	// Closed without merging: merged false, the last computed merge commit.
221	_, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n1}))
222	require.NoError(t, err)
223	job := c.finish(runner)
224	gh := c.ghContext(job)
225	ev := gh["event"].(map[string]any)
226	assert.Equal(t, "closed", ev["action"])
227	pr := ev["pull_request"].(map[string]any)
228	assert.Equal(t, false, pr["merged"])
229	assert.Equal(t, "closed", pr["state"])
230	assert.Equal(t, merge1, gh["sha"], "the last computed merge commit")
231	assert.Equal(t, merge1, pr["merge_commit_sha"])
232	assert.Equal(t, "refs/pull/1/merge", gh["ref"])
233
234	// Merged: merged true, merge_commit_sha is the commit the merge made; the
235	// source branch is deleted and the refs are not computed again.
236	head2 := c.ref(domain.PullHeadRef(n2))
237	merge2 := c.ref(domain.PullMergeRef(n2))
238	_, err = c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
239		Repo: "proj", Number: n2, HeadId: head2, DeleteSourceBranch: true,
240	}))
241	require.NoError(t, err)
242	job = c.finish(runner)
243	gh = c.ghContext(job)
244	ev = gh["event"].(map[string]any)
245	pr = ev["pull_request"].(map[string]any)
246	assert.Equal(t, "closed", ev["action"])
247	assert.Equal(t, true, pr["merged"])
248	assert.Equal(t, head2, pr["merge_commit_sha"], "fast-forward: the merged commit is the head")
249	assert.Equal(t, merge2, gh["sha"], "github.sha stays the last test merge")
250	assert.Equal(t, head2, pr["head"].(map[string]any)["sha"])
251	assert.Equal(t, head2, c.ref(domain.PullHeadRef(n2)))
252	assert.Equal(t, merge2, c.ref(domain.PullMergeRef(n2)))
253	assert.Len(t, c.checks(head2), 1)
254}
255
256func TestPullRequestPathsAndBranchesFilters(t *testing.T) {
257	c := newCIHarness(t, nil)
258	runner := c.registerRunner("alpha", "ubuntu-latest")
259	c.push("main", pullWF("    branches: [main]\n    paths: ['docs/**']", ""), "")
260	c.mustGit(c.wt, "checkout", "-q", "-b", "f1")
261	c.push("f1", map[string]string{"src/x.go": "x\n"}, "")
262	n := c.openMR(c.root, "f1")
263	// Another request whose changes match: the only run is its.
264	c.mustGit(c.wt, "checkout", "-q", "-b", "f2", "main")
265	c.push("f2", map[string]string{"docs/readme.md": "d\n"}, "")
266	n2 := c.openMR(c.root, "f2")
267	job := c.finish(runner)
268	assert.EqualValues(t, n2, c.ghContext(job)["event"].(map[string]any)["number"])
269	require.Len(t, c.runs(), 1)
270
271	// A later push that adds a docs file to the first request: the files are
272	// those between the merge base and the head, so it runs now.
273	c.mustGit(c.wt, "checkout", "-q", "f1")
274	c.push("f1", map[string]string{"docs/new.md": "n\n"}, "")
275	job = c.finish(runner)
276	assert.EqualValues(t, n, c.ghContext(job)["event"].(map[string]any)["number"])
277	assert.Equal(t, "synchronize", c.ghContext(job)["event"].(map[string]any)["action"])
278}
279
280func TestPullRequestTrustFollowsTheActor(t *testing.T) {
281	c := newCIHarness(t, nil)
282	runner := c.registerRunner("alpha", "ubuntu-latest")
283	c.push("main", pullWF("", ""), "")
284	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
285	c.push("agent/x", map[string]string{"x.txt": "x\n"}, "")
286	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
287
288	// Opened by a branch-limited token: untrusted, no secrets, read-only token.
289	c.openMR(c.api(secret), "agent/x")
290	job := c.finish(runner)
291	assert.False(t, job.GetTrusted())
292	runs := c.runs()
293	require.Len(t, runs, 1)
294	assert.False(t, runs[0].GetTrusted())
295	assert.Equal(t, "claude", runs[0].GetActor())
296
297	// A synchronize pushed by the owner (root token) is trusted.
298	c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
299	job = c.finish(runner)
300	assert.True(t, job.GetTrusted())
301	assert.Equal(t, "root", c.runs()[0].GetActor())
302
303	// A synchronize pushed by the limited token is not.
304	c.writeFile(c.wt, "z.txt", []byte("z\n"))
305	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/x")
306	job = c.finish(runner)
307	assert.False(t, job.GetTrusted())
308}
309
310func TestPullRefsAreServedHiddenAndNotPushable(t *testing.T) {
311	c := newCIHarness(t, nil)
312	runner := c.registerRunner("alpha", "ubuntu-latest")
313	c.push("main", pullWF("", "    permissions:\n      contents: write"), "")
314	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
315	feat := c.push("feat", map[string]string{"f.txt": "f\n"}, "")
316	n := c.openMR(c.root, "feat")
317	job := runner.await()
318	defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
319	merge := c.ref(domain.PullMergeRef(n))
320
321	// Fetchable, as checkout does.
322	dir := c.t.TempDir()
323	c.mustGit(dir, "init", "-q")
324	c.mustGit(dir, "fetch", "-q", c.urlAuth(adminToken, "/proj.git"), "refs/pull/1/merge:refs/remotes/pull/1/merge", "refs/pull/1/head:refs/remotes/pull/1/head")
325	assert.Equal(t, merge, strings.TrimSpace(c.mustGit(dir, "rev-parse", "refs/remotes/pull/1/merge")))
326	assert.Equal(t, feat, strings.TrimSpace(c.mustGit(dir, "rev-parse", "refs/remotes/pull/1/head")))
327
328	// Hidden from the branch and tag lists.
329	res, err := c.root.git.ListRefs(c.ctx, connect.NewRequest(&pb.ListRefsRequest{Repo: "proj"}))
330	require.NoError(t, err)
331	for _, r := range res.Msg.GetRefs() {
332		assert.NotContains(t, r.GetName(), "refs/pull/")
333	}
334
335	// Refused on push: the owner, a stored token with repo:write, and the job
336	// token with contents: write (the workflow guard does not reach it first).
337	other, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), nil, "proj")
338	for _, tc := range []struct{ name, url string }{
339		{"owner", c.urlAuth(adminToken, "/proj.git")},
340		{"token", c.urlAuth(other, "/proj.git")},
341	} {
342		for _, ref := range []string{"refs/pull/1/head", "refs/pull/9/head", "refs/pull/1/merge", "refs/pull/x"} {
343			out, err := c.git(c.wt, "push", tc.url, "HEAD:"+ref)
344			require.Error(t, err, "%s pushes %s: %s", tc.name, ref, out)
345		}
346		out, err := c.git(c.wt, "push", "--delete", tc.url, "refs/pull/1/merge")
347		require.Error(t, err, out)
348	}
349	out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/pull/1/head")
350	require.Error(t, err, out)
351	assert.Equal(t, merge, c.ref(domain.PullMergeRef(n)))
352	assert.Equal(t, feat, c.ref(domain.PullHeadRef(n)))
353	assert.Empty(t, c.ref("refs/pull/9/head"))
354}
355
356func (c *ciHarness) apiGet(token, path string) (int, http.Header, string) {
357	c.t.Helper()
358	req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.url("/-/github/api"+path), nil)
359	require.NoError(c.t, err)
360	if token != "" {
361		req.Header.Set("Authorization", "Bearer "+token)
362	}
363	res, err := http.DefaultClient.Do(req)
364	require.NoError(c.t, err)
365	defer func() { _ = res.Body.Close() }()
366	b, err := io.ReadAll(res.Body)
367	require.NoError(c.t, err)
368	return res.StatusCode, res.Header, string(b)
369}
370
371func TestPullFilesEndpoint(t *testing.T) {
372	c := newCIHarness(t, nil)
373	runner := c.registerRunner("alpha", "ubuntu-latest")
374	c.push("main", pullWF("", "    permissions:\n      contents: read\n      pull-requests: read"), "")
375	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
376	files := map[string]string{}
377	for i := range 5 {
378		files[fmt.Sprintf("d/f%d.txt", i)] = fmt.Sprintf("line %d\n", i)
379	}
380	c.writeFile(c.wt, "d/gone.txt", []byte("x\n"))
381	for name, content := range files {
382		c.writeFile(c.wt, name, []byte(content))
383	}
384	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "feat")
385	c.openMR(c.root, "feat")
386	job := runner.await() // the job token lives while the job runs
387	defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
388
389	type entry struct {
390		Filename, Status, Sha, Patch  string
391		Additions, Deletions, Changes int
392	}
393	list := func(token, q string) (int, http.Header, []entry) {
394		code, hdr, body := c.apiGet(token, "/repos/owner/proj/pulls/1/files"+q)
395		var es []entry
396		if code == http.StatusOK {
397			require.NoError(t, json.Unmarshal([]byte(body), &es), body)
398		}
399		return code, hdr, es
400	}
401
402	code, hdr, es := list(adminToken, "?per_page=4&page=1")
403	require.Equal(t, http.StatusOK, code)
404	require.Len(t, es, 4)
405	assert.Equal(t, "d/f0.txt", es[0].Filename)
406	assert.Equal(t, "added", es[0].Status)
407	assert.Equal(t, 1, es[0].Additions)
408	assert.Equal(t, 1, es[0].Changes)
409	assert.Equal(t, "@@ -0,0 +1 @@\n+line 0", es[0].Patch)
410	assert.Len(t, es[0].Sha, 40)
411	link := hdr.Get("Link")
412	assert.Contains(t, link, `page=2>; rel="next"`)
413	assert.Contains(t, link, `page=2>; rel="last"`)
414	assert.NotContains(t, link, `rel="prev"`)
415
416	code, hdr, es = list(adminToken, "?per_page=4&page=2")
417	require.Equal(t, http.StatusOK, code)
418	require.Len(t, es, 2, "6 files in all")
419	assert.NotContains(t, hdr.Get("Link"), `rel="next"`)
420	assert.Contains(t, hdr.Get("Link"), `rel="prev"`)
421	assert.Contains(t, hdr.Get("Link"), `rel="first"`)
422
423	code, _, es = list(adminToken, "?page=9")
424	require.Equal(t, http.StatusOK, code)
425	assert.Empty(t, es)
426	for _, q := range []string{"?page=9223372036854775807", "?per_page=100&page=9223372036854775807", "?page=2305843009213693953&per_page=4"} {
427		code, _, es = list(adminToken, q)
428		require.Equal(t, http.StatusOK, code, q)
429		assert.Empty(t, es, q)
430	}
431	code, hdr, es = list(adminToken, "")
432	require.Equal(t, http.StatusOK, code)
433	assert.Len(t, es, 6, "30 per page by default")
434	assert.Empty(t, hdr.Get("Link"))
435	code, _, es = list(adminToken, "?per_page=1000")
436	require.Equal(t, http.StatusOK, code)
437	assert.Len(t, es, 6, "100 at most")
438
439	// The job token with pull-requests: read; the answer is 404 for a request
440	// that does not exist and for anonymous callers on a private repository.
441	code, _, es = list(job.GetGithubToken(), "")
442	require.Equal(t, http.StatusOK, code)
443	assert.Len(t, es, 6)
444	code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/7/files")
445	assert.Equal(t, http.StatusNotFound, code)
446	code, _, _ = c.apiGet("", "/repos/owner/proj/pulls/1/files")
447	assert.Equal(t, http.StatusNotFound, code)
448	code, _, _ = c.apiGet(adminToken, "/repos/owner/proj/pulls/1")
449	assert.Equal(t, http.StatusNotFound, code, "GET /pulls/{n} is not served: paths-filter does not call it")
450}
451
452// A job token with pull-requests: read and contents: none reads the merge
453// requests of its own private repository, and only of that one.
454func TestPullFilesWithPullRequestsPermissionOnly(t *testing.T) {
455	c := newCIHarness(t, nil)
456	runner := c.registerRunner("alpha", "ubuntu-latest")
457	c.push("main", pullWF("", "    permissions:\n      contents: none\n      pull-requests: read"), "")
458	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
459	c.push("feat", map[string]string{"f.txt": "f\n"}, "")
460	c.openMR(c.root, "feat")
461	job := runner.await()
462	defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
463	code, _, body := c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/1/files")
464	assert.Equal(t, http.StatusOK, code, body)
465	assert.Contains(t, body, "f.txt")
466	// Not the repository itself (contents: none) and not another repository.
467	code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/proj")
468	assert.Equal(t, http.StatusNotFound, code)
469	_, err := c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("other"), Visibility: domain.VisibilityPrivate})
470	require.NoError(t, err)
471	code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/other/pulls/1/files")
472	assert.Equal(t, http.StatusNotFound, code)
473}
474
475// The merge requests of a public repository are read by anyone, except by the
476// holder of an archive token, which reads one archive and nothing else.
477func TestPullFilesOfAPublicRepositoryAreNotReadByAnArchiveToken(t *testing.T) {
478	c := newCIHarness(t, nil)
479	c.push("main", pullWF("", ""), "")
480	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
481	c.push("feat", map[string]string{"f.txt": "f\n"}, "")
482	c.openMR(c.root, "feat")
483	public := domain.VisibilityPublic
484	_, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.name("proj"), ports.UpdateRepoParams{Visibility: &public})
485	require.NoError(t, err)
486
487	code, _, body := c.apiGet("", "/repos/owner/proj/pulls/1/files")
488	require.Equal(t, http.StatusOK, code, body)
489	assert.Contains(t, body, "f.txt", "anyone reads a public repository")
490
491	archive := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "a", Repo: c.name("proj"), Archive: true, Sha: strings.Repeat("a", 40)})
492	require.True(t, archive.ViaJobToken(), "the archive token passes ViaJobToken")
493	_, _, err = c.forge.CI.ListPullFiles(c.ctx, archive, "owner", "proj", 1, 1, 30)
494	assert.ErrorIs(t, err, domain.ErrNotFound, "but it reads no merge request")
495}
496
497func TestPullFilesNeedsPullRequestsPermission(t *testing.T) {
498	c := newCIHarness(t, nil)
499	runner := c.registerRunner("alpha", "ubuntu-latest")
500	c.push("main", pullWF("", ""), "") // the default permissions: contents read only
501	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
502	c.push("feat", map[string]string{"f.txt": "f\n"}, "")
503	c.openMR(c.root, "feat")
504	job := runner.await()
505	defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
506	code, _, body := c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/1/files")
507	assert.Equal(t, http.StatusForbidden, code, body)
508	assert.Contains(t, body, "Resource not accessible by integration")
509}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 43

↑↓ moveenter openesc close