detached fda3c2b

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
310// CI-4 D1: the workflow of a pull_request run comes from the head, so the run
311// is trusted only if the head was pushed by a trusted principal too.
312func TestPullRequestTrustFollowsTheHeadsPusher(t *testing.T) {
313	c := newCIHarness(t, nil)
314	runner := c.registerRunner("alpha", "ubuntu-latest")
315	c.push("main", pullWF("    types: [opened, synchronize, closed]", ""), "")
316	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite), []string{"agent/"}, "proj")
317	agentPush := func(branch, file string) string {
318		c.writeFile(c.wt, file, []byte(file+"\n"))
319		c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), branch)
320		return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
321	}
322
323	// The owner opens the request of a branch the agent pushed: not trusted.
324	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/x")
325	agentPush("agent/x", "x.txt")
326	n := c.openMR(c.root, "agent/x")
327	job := c.finish(runner)
328	assert.False(t, job.GetTrusted(), "the agent's workflow does not get the owner's secrets")
329	run := c.runs()[0]
330	assert.False(t, run.GetTrusted())
331	assert.Equal(t, "root", run.GetActor(), "the actor is still who opened it")
332
333	// The owner pushes on top: the synchronize is trusted.
334	c.push("agent/x", map[string]string{"y.txt": "y\n"}, "")
335	assert.True(t, c.finish(runner).GetTrusted())
336
337	// The agent pushes again: not trusted.
338	agentPush("agent/x", "z.txt")
339	assert.False(t, c.finish(runner).GetTrusted())
340
341	// The owner closes it, the head being the agent's: not trusted.
342	_, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
343	require.NoError(t, err)
344	job = c.finish(runner)
345	assert.Equal(t, "closed", c.ghContext(job)["event"].(map[string]any)["action"])
346	assert.False(t, job.GetTrusted())
347
348	// The owner's own branch: trusted.
349	c.mustGit(c.wt, "checkout", "-q", "-b", "own", "main")
350	c.push("own", map[string]string{"o.txt": "o\n"}, "")
351	c.openMR(c.root, "own")
352	assert.True(t, c.finish(runner).GetTrusted())
353
354	// A head whose record names another commit (the branch moved since, or the
355	// owner's push of it was refused), or that has none: not trusted.
356	c.mustGit(c.wt, "checkout", "-q", "-b", "other", "main")
357	c.push("other", map[string]string{"p.txt": "p\n"}, "")
358	require.NoError(t, c.forge.store.CI().PutRefTip(c.ctx, domain.CIRefTip{
359		Repo: c.name("proj"), Ref: "refs/heads/other", Sha: "1111111111111111111111111111111111111111", Actor: "root", Trusted: true,
360	}))
361	c.openMR(c.root, "other")
362	assert.False(t, c.finish(runner).GetTrusted(), "the record names another commit")
363
364	c.mustGit(c.wt, "checkout", "-q", "-b", "none", "main")
365	c.push("none", map[string]string{"q.txt": "q\n"}, "")
366	require.NoError(t, c.forge.store.CI().DeleteRefTip(c.ctx, c.name("proj"), "refs/heads/none"))
367	c.openMR(c.root, "none")
368	assert.False(t, c.finish(runner).GetTrusted(), "no record")
369}
370
371// Merging takes the head into the target: the closing of a merged request has
372// the merger's trust, even with the source branch (and its record) deleted.
373func TestPullRequestMergedByTheOwnerIsTrusted(t *testing.T) {
374	c := newCIHarness(t, nil)
375	runner := c.registerRunner("alpha", "ubuntu-latest")
376	c.push("main", pullWF("    types: [closed]", ""), "")
377	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
378	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/m")
379	c.writeFile(c.wt, "m.txt", []byte("m\n"))
380	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/m")
381	n := c.openMR(c.root, "agent/m")
382	c.waitFor("the head ref", func() bool { return c.ref(domain.PullHeadRef(n)) != "" })
383	_, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
384		Repo: "proj", Number: n, HeadId: c.ref(domain.PullHeadRef(n)), DeleteSourceBranch: true,
385	}))
386	require.NoError(t, err)
387	job := c.finish(runner)
388	assert.Equal(t, true, c.ghContext(job)["event"].(map[string]any)["pull_request"].(map[string]any)["merged"])
389	assert.True(t, job.GetTrusted())
390}
391
392// openMRInto opens a merge request from source into target as the owner.
393func (c *ciHarness) openMRInto(source, target string) int64 {
394	c.t.Helper()
395	res, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
396		Repo: c.repo, Title: "Title of " + source, SourceBranch: source, TargetBranch: target,
397	}))
398	require.NoError(c.t, err)
399	return res.Msg.GetMergeRequest().GetNumber()
400}
401
402// The merge commit carries the target's files too: the base of the event must
403// have been pushed by a trusted principal as well, for every activity.
404func TestPullRequestTrustFollowsTheBasesPusher(t *testing.T) {
405	c, hook := newHookedHarness(t)
406	runner := c.registerRunner("alpha", "ubuntu-latest")
407	c.push("main", pullWF("    types: [opened, synchronize, closed]", ""), "")
408	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
409	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/base")
410	c.writeFile(c.wt, "a.txt", []byte("a\n"))
411	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/base")
412	c.mustGit(c.wt, "checkout", "-q", "-b", "feat", "main")
413	c.push("feat", map[string]string{"f.txt": "f\n"}, "")
414
415	// The owner's branch into the agent's: not trusted, opened or synchronized.
416	n := c.openMRInto("feat", "agent/base")
417	assert.False(t, c.finish(runner).GetTrusted(), "opened on the agent's target")
418	c.push("feat", map[string]string{"g.txt": "g\n"}, "")
419	job := c.finish(runner)
420	assert.Equal(t, "synchronize", c.ghContext(job)["event"].(map[string]any)["action"])
421	assert.False(t, job.GetTrusted(), "a synchronize by the owner on the agent's target")
422
423	// The owner pushes the target: its tip is the owner's, so are the next runs.
424	c.mustGit(c.wt, "checkout", "-q", "agent/base")
425	c.push("agent/base", map[string]string{"b.txt": "b\n"}, "")
426	c.mustGit(c.wt, "checkout", "-q", "feat")
427	c.push("feat", map[string]string{"h.txt": "h\n"}, "")
428	assert.True(t, c.finish(runner).GetTrusted(), "owner's source, owner's target")
429
430	// The agent pushes the target again; the owner closes: the run is the last
431	// merge (refreshed on the agent's tip), not trusted.
432	c.mustGit(c.wt, "checkout", "-q", "agent/base")
433	c.writeFile(c.wt, "c.txt", []byte("c\n"))
434	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/base")
435	agentTip := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
436	c.waitFor("the merge on the agent's tip", func() bool {
437		m := c.ref(domain.PullMergeRef(n))
438		return m != "" && strings.HasPrefix(c.parents(m), agentTip+" ")
439	})
440	_, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
441	require.NoError(t, err)
442	job = c.finish(runner)
443	assert.Equal(t, "closed", c.ghContext(job)["event"].(map[string]any)["action"])
444	assert.False(t, job.GetTrusted(), "closed on a merge of the agent's target")
445
446	// The trust is that of the snapshot: the owner moving the agent's target
447	// after the opening does not make the opening trusted.
448	c.mustGit(c.wt, "checkout", "-q", "-b", "feat2", "main")
449	c.push("feat2", map[string]string{"k.txt": "k\n"}, "")
450	reached, release := hook.block(t, "event")
451	c.openMRInto("feat2", "agent/base")
452	await(t, reached, "the opening event")
453	c.mustGit(c.wt, "checkout", "-q", "agent/base")
454	c.push("agent/base", map[string]string{"d.txt": "d\n"}, "")
455	release()
456	job = c.finish(runner)
457	assert.Equal(t, agentTip+" ", c.parents(c.ghContext(job)["sha"].(string))[:41], "the run is for the snapshot's base")
458	assert.False(t, job.GetTrusted())
459
460	// And the other way: the agent moving the owner's target after the opening
461	// does not make it untrusted.
462	c.mustGit(c.wt, "checkout", "-q", "-b", "feat3", "main")
463	c.push("feat3", map[string]string{"m.txt": "m\n"}, "")
464	ownerTip := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "agent/base"))
465	reached, release = hook.block(t, "event")
466	c.openMRInto("feat3", "agent/base")
467	await(t, reached, "the opening event")
468	c.mustGit(c.wt, "checkout", "-q", "agent/base")
469	c.writeFile(c.wt, "e.txt", []byte("e\n"))
470	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/base")
471	release()
472	job = c.finish(runner)
473	assert.Equal(t, ownerTip+" ", c.parents(c.ghContext(job)["sha"].(string))[:41])
474	assert.True(t, job.GetTrusted())
475}
476
477// A closed request runs its last merge commit, which can be of an older head
478// than the one its closing is for: the trust of that head says nothing of it.
479func TestPullRequestClosedOnAMergeOfAnOlderHeadIsNotTrusted(t *testing.T) {
480	c := newCIHarness(t, nil)
481	runner := c.registerRunner("alpha", "ubuntu-latest")
482	files := pullWF("    types: [closed]", "")
483	files["c.txt"] = "base\n"
484	c.push("main", files, "")
485	secret, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), []string{"agent/"}, "proj")
486	c.mustGit(c.wt, "checkout", "-q", "-b", "agent/o")
487	c.writeFile(c.wt, "x.txt", []byte("x\n"))
488	c.mustGit(c.wt, "push", "-q", c.urlAuth(secret, "/proj.git"), "agent/o")
489	agentHead := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
490	n := c.openMR(c.root, "agent/o")
491
492	// The target moves, the agent's head still merges; then the owner pushes a
493	// head that conflicts: the merge ref keeps the merge of the agent's head.
494	c.mustGit(c.wt, "checkout", "-q", "main")
495	main2 := c.push("main", map[string]string{"c.txt": "main\n"}, "")
496	c.waitFor("the merge of the agent's head on the new target", func() bool {
497		m := c.ref(domain.PullMergeRef(n))
498		return m != "" && c.parents(m) == main2+" "+agentHead
499	})
500	c.mustGit(c.wt, "checkout", "-q", "agent/o")
501	ownerHead := c.push("agent/o", map[string]string{"c.txt": "owner\n"}, "")
502	c.waitFor("the head ref", func() bool { return c.ref(domain.PullHeadRef(n)) == ownerHead })
503	require.Equal(t, main2+" "+agentHead, c.parents(c.ref(domain.PullMergeRef(n))))
504
505	_, err := c.root.mr.CloseMergeRequest(c.ctx, connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "proj", Number: n}))
506	require.NoError(t, err)
507	job := c.finish(runner)
508	assert.Equal(t, c.ref(domain.PullMergeRef(n)), c.ghContext(job)["sha"], "the run is the merge of the agent's head")
509	assert.False(t, job.GetTrusted())
510}
511
512func TestPullRefsAreServedHiddenAndNotPushable(t *testing.T) {
513	c := newCIHarness(t, nil)
514	runner := c.registerRunner("alpha", "ubuntu-latest")
515	c.push("main", pullWF("", "    permissions:\n      contents: write"), "")
516	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
517	feat := c.push("feat", map[string]string{"f.txt": "f\n"}, "")
518	n := c.openMR(c.root, "feat")
519	job := runner.await()
520	defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
521	merge := c.ref(domain.PullMergeRef(n))
522
523	// Fetchable, as checkout does.
524	dir := c.t.TempDir()
525	c.mustGit(dir, "init", "-q")
526	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")
527	assert.Equal(t, merge, strings.TrimSpace(c.mustGit(dir, "rev-parse", "refs/remotes/pull/1/merge")))
528	assert.Equal(t, feat, strings.TrimSpace(c.mustGit(dir, "rev-parse", "refs/remotes/pull/1/head")))
529
530	// Hidden from the branch and tag lists.
531	res, err := c.root.git.ListRefs(c.ctx, connect.NewRequest(&pb.ListRefsRequest{Repo: "proj"}))
532	require.NoError(t, err)
533	for _, r := range res.Msg.GetRefs() {
534		assert.NotContains(t, r.GetName(), "refs/pull/")
535	}
536
537	// Refused on push: the owner, a stored token with repo:write, and the job
538	// token with contents: write (the workflow guard does not reach it first).
539	other, _ := c.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite), nil, "proj")
540	for _, tc := range []struct{ name, url string }{
541		{"owner", c.urlAuth(adminToken, "/proj.git")},
542		{"token", c.urlAuth(other, "/proj.git")},
543	} {
544		for _, ref := range []string{"refs/pull/1/head", "refs/pull/9/head", "refs/pull/1/merge", "refs/pull/x"} {
545			out, err := c.git(c.wt, "push", tc.url, "HEAD:"+ref)
546			require.Error(t, err, "%s pushes %s: %s", tc.name, ref, out)
547		}
548		out, err := c.git(c.wt, "push", "--delete", tc.url, "refs/pull/1/merge")
549		require.Error(t, err, out)
550	}
551	out, err := c.tokenPush(c.wt, job.GetGithubToken(), "HEAD:refs/pull/1/head")
552	require.Error(t, err, out)
553	assert.Equal(t, merge, c.ref(domain.PullMergeRef(n)))
554	assert.Equal(t, feat, c.ref(domain.PullHeadRef(n)))
555	assert.Empty(t, c.ref("refs/pull/9/head"))
556}
557
558func (c *ciHarness) apiGet(token, path string) (int, http.Header, string) {
559	c.t.Helper()
560	req, err := http.NewRequestWithContext(c.ctx, http.MethodGet, c.url("/-/github/api"+path), nil)
561	require.NoError(c.t, err)
562	if token != "" {
563		req.Header.Set("Authorization", "Bearer "+token)
564	}
565	res, err := http.DefaultClient.Do(req)
566	require.NoError(c.t, err)
567	defer func() { _ = res.Body.Close() }()
568	b, err := io.ReadAll(res.Body)
569	require.NoError(c.t, err)
570	return res.StatusCode, res.Header, string(b)
571}
572
573func TestPullFilesEndpoint(t *testing.T) {
574	c := newCIHarness(t, nil)
575	runner := c.registerRunner("alpha", "ubuntu-latest")
576	c.push("main", pullWF("", "    permissions:\n      contents: read\n      pull-requests: read"), "")
577	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
578	files := map[string]string{}
579	for i := range 5 {
580		files[fmt.Sprintf("d/f%d.txt", i)] = fmt.Sprintf("line %d\n", i)
581	}
582	c.writeFile(c.wt, "d/gone.txt", []byte("x\n"))
583	for name, content := range files {
584		c.writeFile(c.wt, name, []byte(content))
585	}
586	c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "feat")
587	c.openMR(c.root, "feat")
588	job := runner.await() // the job token lives while the job runs
589	defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
590
591	type entry struct {
592		Filename, Status, Sha, Patch  string
593		Additions, Deletions, Changes int
594	}
595	list := func(token, q string) (int, http.Header, []entry) {
596		code, hdr, body := c.apiGet(token, "/repos/owner/proj/pulls/1/files"+q)
597		var es []entry
598		if code == http.StatusOK {
599			require.NoError(t, json.Unmarshal([]byte(body), &es), body)
600		}
601		return code, hdr, es
602	}
603
604	code, hdr, es := list(adminToken, "?per_page=4&page=1")
605	require.Equal(t, http.StatusOK, code)
606	require.Len(t, es, 4)
607	assert.Equal(t, "d/f0.txt", es[0].Filename)
608	assert.Equal(t, "added", es[0].Status)
609	assert.Equal(t, 1, es[0].Additions)
610	assert.Equal(t, 1, es[0].Changes)
611	assert.Equal(t, "@@ -0,0 +1 @@\n+line 0", es[0].Patch)
612	assert.Len(t, es[0].Sha, 40)
613	link := hdr.Get("Link")
614	assert.Contains(t, link, `page=2>; rel="next"`)
615	assert.Contains(t, link, `page=2>; rel="last"`)
616	assert.NotContains(t, link, `rel="prev"`)
617
618	code, hdr, es = list(adminToken, "?per_page=4&page=2")
619	require.Equal(t, http.StatusOK, code)
620	require.Len(t, es, 2, "6 files in all")
621	assert.NotContains(t, hdr.Get("Link"), `rel="next"`)
622	assert.Contains(t, hdr.Get("Link"), `rel="prev"`)
623	assert.Contains(t, hdr.Get("Link"), `rel="first"`)
624
625	code, _, es = list(adminToken, "?page=9")
626	require.Equal(t, http.StatusOK, code)
627	assert.Empty(t, es)
628	for _, q := range []string{"?page=9223372036854775807", "?per_page=100&page=9223372036854775807", "?page=2305843009213693953&per_page=4"} {
629		code, _, es = list(adminToken, q)
630		require.Equal(t, http.StatusOK, code, q)
631		assert.Empty(t, es, q)
632	}
633	code, hdr, es = list(adminToken, "")
634	require.Equal(t, http.StatusOK, code)
635	assert.Len(t, es, 6, "30 per page by default")
636	assert.Empty(t, hdr.Get("Link"))
637	code, _, es = list(adminToken, "?per_page=1000")
638	require.Equal(t, http.StatusOK, code)
639	assert.Len(t, es, 6, "100 at most")
640
641	// The job token with pull-requests: read; the answer is 404 for a request
642	// that does not exist and for anonymous callers on a private repository.
643	code, _, es = list(job.GetGithubToken(), "")
644	require.Equal(t, http.StatusOK, code)
645	assert.Len(t, es, 6)
646	code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/7/files")
647	assert.Equal(t, http.StatusNotFound, code)
648	code, _, _ = c.apiGet("", "/repos/owner/proj/pulls/1/files")
649	assert.Equal(t, http.StatusNotFound, code)
650	code, _, _ = c.apiGet(adminToken, "/repos/owner/proj/pulls/1")
651	assert.Equal(t, http.StatusNotFound, code, "GET /pulls/{n} is not served: paths-filter does not call it")
652}
653
654// A job token with pull-requests: read and contents: none reads the merge
655// requests of its own private repository, and only of that one.
656func TestPullFilesWithPullRequestsPermissionOnly(t *testing.T) {
657	c := newCIHarness(t, nil)
658	runner := c.registerRunner("alpha", "ubuntu-latest")
659	c.push("main", pullWF("", "    permissions:\n      contents: none\n      pull-requests: read"), "")
660	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
661	c.push("feat", map[string]string{"f.txt": "f\n"}, "")
662	c.openMR(c.root, "feat")
663	job := runner.await()
664	defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
665	code, _, body := c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/1/files")
666	assert.Equal(t, http.StatusOK, code, body)
667	assert.Contains(t, body, "f.txt")
668	// Not the repository itself (contents: none) and not another repository.
669	code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/proj")
670	assert.Equal(t, http.StatusNotFound, code)
671	_, err := c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("other"), Visibility: domain.VisibilityPrivate})
672	require.NoError(t, err)
673	code, _, _ = c.apiGet(job.GetGithubToken(), "/repos/owner/other/pulls/1/files")
674	assert.Equal(t, http.StatusNotFound, code)
675}
676
677// The merge requests of a public repository are read by anyone, except by the
678// holder of an archive token, which reads one archive and nothing else.
679func TestPullFilesOfAPublicRepositoryAreNotReadByAnArchiveToken(t *testing.T) {
680	c := newCIHarness(t, nil)
681	c.push("main", pullWF("", ""), "")
682	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
683	c.push("feat", map[string]string{"f.txt": "f\n"}, "")
684	c.openMR(c.root, "feat")
685	public := domain.VisibilityPublic
686	_, err := c.forge.Repos.Update(c.ctx, domain.Root(), c.name("proj"), ports.UpdateRepoParams{Visibility: &public})
687	require.NoError(t, err)
688
689	code, _, body := c.apiGet("", "/repos/owner/proj/pulls/1/files")
690	require.Equal(t, http.StatusOK, code, body)
691	assert.Contains(t, body, "f.txt", "anyone reads a public repository")
692
693	archive := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "a", Repo: c.name("proj"), Archive: true, Sha: strings.Repeat("a", 40)})
694	require.True(t, archive.ViaJobToken(), "the archive token passes ViaJobToken")
695	_, _, err = c.forge.CI.ListPullFiles(c.ctx, archive, "owner", "proj", 1, 1, 30)
696	assert.ErrorIs(t, err, domain.ErrNotFound, "but it reads no merge request")
697}
698
699func TestPullFilesNeedsPullRequestsPermission(t *testing.T) {
700	c := newCIHarness(t, nil)
701	runner := c.registerRunner("alpha", "ubuntu-latest")
702	c.push("main", pullWF("", ""), "") // the default permissions: contents read only
703	c.mustGit(c.wt, "checkout", "-q", "-b", "feat")
704	c.push("feat", map[string]string{"f.txt": "f\n"}, "")
705	c.openMR(c.root, "feat")
706	job := runner.await()
707	defer runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
708	code, _, body := c.apiGet(job.GetGithubToken(), "/repos/owner/proj/pulls/1/files")
709	assert.Equal(t, http.StatusForbidden, code, body)
710	assert.Contains(t, body, "Resource not accessible by integration")
711}

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 45

↑↓ moveenter openesc close