detached aa7340c

ci_pull_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"errors"
8	"strings"
9	"testing"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite-actions/workflow"
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18	"bnema.dev/ermite/internal/ports/mocks"
19	"bnema.dev/ermite/internal/usecase"
20)
21
22func parsePR(t *testing.T, on string) *workflow.Workflow {
23	t.Helper()
24	wf, errs := workflow.Parse("pr.yml", []byte("on:\n"+on+"\njobs:\n  j:\n    runs-on: x\n    steps:\n      - run: 'true'\n"))
25	require.Empty(t, errs)
26	return wf
27}
28
29func TestMatchPullRequestTypes(t *testing.T) {
30	def := parsePR(t, "  pull_request:\n")
31	for action, want := range map[string]bool{"opened": true, "synchronize": true, "reopened": true, "closed": false} {
32		assert.Equal(t, want, usecase.MatchPullRequest(def, action, "main", nil, false), "default types, "+action)
33	}
34	closed := parsePR(t, "  pull_request:\n    types: [closed]\n")
35	assert.True(t, usecase.MatchPullRequest(closed, "closed", "main", nil, false))
36	assert.False(t, usecase.MatchPullRequest(closed, "opened", "main", nil, false))
37	both := parsePR(t, "  pull_request:\n    types: [opened, closed]\n")
38	assert.True(t, usecase.MatchPullRequest(both, "opened", "main", nil, false))
39	assert.True(t, usecase.MatchPullRequest(both, "closed", "main", nil, false))
40	assert.False(t, usecase.MatchPullRequest(both, "synchronize", "main", nil, false))
41	assert.False(t, usecase.MatchPullRequest(parsePR(t, "  push:\n"), "opened", "main", nil, false), "no pull_request event")
42	assert.True(t, usecase.MatchPullRequest(parsePR(t, "  [push, pull_request]\n"), "opened", "main", nil, false))
43}
44
45// CI-4 D4: pull_request_target has the types and the filters of pull_request,
46// under its own name; types ermite never emits parse and never match.
47func TestMatchPullRequestTarget(t *testing.T) {
48	def := parsePR(t, "  pull_request_target:\n")
49	for action, want := range map[string]bool{"opened": true, "synchronize": true, "reopened": true, "closed": false} {
50		assert.Equal(t, want, usecase.MatchPullRequestTarget(def, action, "main", nil, false), "default types, "+action)
51	}
52	assert.False(t, usecase.MatchPullRequest(def, "opened", "main", nil, false), "not a pull_request workflow")
53	assert.False(t, usecase.MatchPullRequestTarget(parsePR(t, "  pull_request:\n"), "opened", "main", nil, false))
54	labeled := parsePR(t, "  pull_request_target:\n    types: [labeled, closed]\n")
55	assert.False(t, usecase.MatchPullRequestTarget(labeled, "opened", "main", nil, false))
56	assert.True(t, usecase.MatchPullRequestTarget(labeled, "closed", "main", nil, false))
57
58	wf := parsePR(t, "  pull_request_target:\n    branches: [main]\n    paths: ['src/**']\n")
59	assert.True(t, usecase.MatchPullRequestTarget(wf, "opened", "main", []string{"src/a.go"}, false))
60	assert.False(t, usecase.MatchPullRequestTarget(wf, "opened", "dev", []string{"src/a.go"}, false), "the base branch is filtered")
61	assert.False(t, usecase.MatchPullRequestTarget(wf, "opened", "main", []string{"README.md"}, false), "the changed files are filtered")
62	ignore := parsePR(t, "  pull_request_target:\n    branches-ignore: [wip]\n")
63	assert.False(t, usecase.MatchPullRequestTarget(ignore, "opened", "wip", nil, false))
64}
65
66func TestMatchPullRequestFilters(t *testing.T) {
67	wf := parsePR(t, "  pull_request:\n    branches: [main, 'release/**']\n    paths: ['src/**', '!src/gen/**']\n")
68	files := func(f ...string) []string { return f }
69	assert.True(t, usecase.MatchPullRequest(wf, "opened", "main", files("src/a.go"), false))
70	assert.True(t, usecase.MatchPullRequest(wf, "opened", "release/1.2", files("README.md", "src/b.go"), false))
71	assert.False(t, usecase.MatchPullRequest(wf, "opened", "dev", files("src/a.go"), false), "the base branch is filtered")
72	assert.False(t, usecase.MatchPullRequest(wf, "opened", "main", files("README.md"), false), "the changed files are filtered")
73	assert.False(t, usecase.MatchPullRequest(wf, "opened", "main", files("src/gen/x.go"), false))
74	assert.True(t, usecase.MatchPullRequest(wf, "opened", "main", nil, true), "an unavailable list passes the paths filter")
75
76	ignore := parsePR(t, "  pull_request:\n    branches-ignore: [wip]\n    paths-ignore: ['docs/**']\n")
77	assert.True(t, usecase.MatchPullRequest(ignore, "synchronize", "main", files("docs/a.md", "x.go"), false))
78	assert.False(t, usecase.MatchPullRequest(ignore, "synchronize", "main", files("docs/a.md"), false))
79	assert.False(t, usecase.MatchPullRequest(ignore, "synchronize", "wip", files("x.go"), false))
80}
81
82func TestPullFilesLink(t *testing.T) {
83	const base = "http://x/pulls/1/files"
84	assert.Empty(t, usecase.PullFilesLink(base, 1, 30, 0))
85	assert.Empty(t, usecase.PullFilesLink(base, 1, 30, 30))
86	assert.Equal(t, `<http://x/pulls/1/files?per_page=30&page=2>; rel="next", <http://x/pulls/1/files?per_page=30&page=3>; rel="last"`, usecase.PullFilesLink(base, 1, 30, 61))
87	assert.Equal(t,
88		`<http://x/pulls/1/files?per_page=30&page=3>; rel="next", <http://x/pulls/1/files?per_page=30&page=3>; rel="last", <http://x/pulls/1/files?per_page=30&page=1>; rel="first", <http://x/pulls/1/files?per_page=30&page=1>; rel="prev"`,
89		usecase.PullFilesLink(base, 2, 30, 61))
90	last := usecase.PullFilesLink(base, 3, 30, 61)
91	assert.NotContains(t, last, "next")
92	assert.Contains(t, last, `page=2>; rel="prev"`)
93	beyond := usecase.PullFilesLink(base, 9, 30, 61)
94	assert.NotContains(t, beyond, "next")
95	assert.Contains(t, beyond, `page=3>; rel="prev"`)
96}
97
98func TestFilePatch(t *testing.T) {
99	f := domain.FileDiff{Hunks: []domain.Hunk{
100		{OldStart: 1, OldLines: 2, NewStart: 1, NewLines: 2, Header: "func x()", Lines: []domain.Line{
101			{Kind: domain.LineContext, Text: "a"}, {Kind: domain.LineDeletion, Text: "b"}, {Kind: domain.LineAddition, Text: "c", NoNewlineAtEOF: true},
102		}},
103		{OldStart: 9, OldLines: 1, NewStart: 9, NewLines: 1, Lines: []domain.Line{{Kind: domain.LineAddition, Text: "z"}}},
104	}}
105	got, ok := usecase.FilePatch(f)
106	require.True(t, ok)
107	assert.Equal(t, "@@ -1,2 +1,2 @@ func x()\n a\n-b\n+c\n\\ No newline at end of file\n@@ -9 +9 @@\n+z", got)
108	_, ok = usecase.FilePatch(domain.FileDiff{Binary: true, Hunks: f.Hunks})
109	assert.False(t, ok, "binary")
110	_, ok = usecase.FilePatch(domain.FileDiff{Truncated: true})
111	assert.False(t, ok, "truncated")
112	_, ok = usecase.FilePatch(domain.FileDiff{Status: domain.FileRenamed})
113	assert.False(t, ok, "a pure rename has no hunk")
114}
115
116// CI-4 D1: every path of the trust of an activity that is not a push ends
117// untrusted unless the provenance of the code it runs is positively known.
118func TestPullTipsTrustedFailsClosed(t *testing.T) {
119	oid := func(c byte) domain.ObjectID {
120		id, err := domain.ParseObjectID(strings.Repeat(string(c), 40))
121		require.NoError(t, err)
122		return id
123	}
124	head, base, merge, merged, other := oid('1'), oid('2'), oid('3'), oid('4'), oid('9')
125	boom := errors.New("boom")
126	repo := name(t, "proj")
127	open := domain.MergeRequest{Repo: repo, Number: 1, Source: "feat", Target: "main", State: domain.MRStateOpen}
128	closed := open
129	closed.State = domain.MRStateClosed
130	mergedMR := open
131	mergedMR.State, mergedMR.HeadID, mergedMR.MergedCommitID = domain.MRStateMerged, head, merged
132	tip := func(ref string, sha domain.ObjectID, trusted bool) domain.CIRefTip {
133		return domain.CIRefTip{Repo: repo, Ref: ref, Sha: sha.String(), Trusted: trusted}
134	}
135	goodTips := []domain.CIRefTip{tip("refs/heads/feat", head, true), tip("refs/heads/main", base, true)}
136	mergeCommit := []domain.Commit{{ID: merge, Parents: []domain.ObjectID{base, head}}}
137
138	type world struct {
139		mr       domain.MergeRequest
140		mrErr    error
141		tips     []domain.CIRefTip
142		tipErr   error
143		mergeRef domain.ObjectID // zero: none
144		refErr   error
145		commits  []domain.Commit
146		listErr  error
147		mb       domain.ObjectID
148		mbErr    error
149	}
150	opened := ports.PullRequestEvent{Number: 1, Action: domain.PullOpened, Head: head, Base: base}
151	closing := ports.PullRequestEvent{Number: 1, Action: domain.PullClosed, Head: head}
152	merging := ports.PullRequestEvent{Number: 1, Action: domain.PullClosed, Head: head, Merged: true}
153	mergedTips := []domain.CIRefTip{tip("refs/heads/main", merged, true)}
154
155	cases := []struct {
156		name string
157		ev   ports.PullRequestEvent
158		w    world
159		want bool
160	}{
161		{"opened: head and base pushed by the owner", opened, world{mr: open, tips: goodTips}, true},
162		{"opened: the request cannot be read", opened, world{mrErr: boom}, false},
163		{"opened: the request is gone", opened, world{mrErr: domain.ErrNotFound}, false},
164		{"opened: a record cannot be read", opened, world{mr: open, tipErr: boom}, false},
165		{"opened: unknown head", ports.PullRequestEvent{Number: 1, Action: domain.PullOpened, Base: base}, world{mr: open, tips: goodTips}, false},
166		{"opened: unknown base", ports.PullRequestEvent{Number: 1, Action: domain.PullOpened, Head: head}, world{mr: open, tips: goodTips}, false},
167		{"opened: head without a record", opened, world{mr: open, tips: goodTips[1:]}, false},
168		{"opened: base without a record", opened, world{mr: open, tips: goodTips[:1]}, false},
169		{"opened: base pushed by an agent", opened, world{mr: open, tips: []domain.CIRefTip{goodTips[0], tip("refs/heads/main", base, false)}}, false},
170		{"opened: base record for another commit", opened, world{mr: open, tips: []domain.CIRefTip{goodTips[0], tip("refs/heads/main", other, true)}}, false},
171		{"an unknown action is checked like an opening", ports.PullRequestEvent{Number: 1, Action: "edited", Head: head, Base: base}, world{mr: open, tips: goodTips[:1]}, false},
172		{"closed: no merge commit, owner's head", closing, world{mr: closed, tips: goodTips[:1]}, true},
173		{"closed: merge commit of owner's tips", closing, world{mr: closed, tips: goodTips, mergeRef: merge, commits: mergeCommit}, true},
174		{"closed: the merge ref cannot be read", closing, world{mr: closed, tips: goodTips, refErr: boom}, false},
175		{"closed: the merge commit cannot be read", closing, world{mr: closed, tips: goodTips, mergeRef: merge, listErr: boom}, false},
176		{"closed: the merge ref is not a merge", closing, world{mr: closed, tips: goodTips, mergeRef: merge, commits: []domain.Commit{{ID: merge, Parents: []domain.ObjectID{base}}}}, false},
177		{"closed: the merge commit is of another head", closing, world{mr: closed, tips: goodTips, mergeRef: merge, commits: []domain.Commit{{ID: merge, Parents: []domain.ObjectID{base, other}}}}, false},
178		{"closed: merge commit on an agent's base", closing, world{mr: closed, tips: []domain.CIRefTip{goodTips[0], tip("refs/heads/main", base, false)}, mergeRef: merge, commits: mergeCommit}, false},
179		{"closed, not by a merge, of a merged request: checked like a closing", closing, world{mr: mergedMR, tips: mergedTips}, false},
180		{"merged: the merger's", merging, world{mr: mergedMR, tips: mergedTips, mergeRef: merge, commits: mergeCommit, mb: base}, true},
181		{"merged without a merge commit", merging, world{mr: mergedMR, tips: mergedTips}, true},
182		{"merged flag on a request that is not merged", merging, world{mr: closed, tips: append(goodTips, mergedTips...)}, false},
183		{"merged flag on an opening", ports.PullRequestEvent{Number: 1, Action: domain.PullOpened, Head: head, Merged: true}, world{mr: mergedMR, tips: mergedTips}, false},
184		{"merged: another head than the merged one", ports.PullRequestEvent{Number: 1, Action: domain.PullClosed, Head: other, Merged: true}, world{mr: mergedMR, tips: mergedTips}, false},
185		{"merged: unknown head", ports.PullRequestEvent{Number: 1, Action: domain.PullClosed, Merged: true}, world{mr: mergedMR, tips: mergedTips}, false},
186		{"merged: the target moved since", merging, world{mr: mergedMR, tips: []domain.CIRefTip{tip("refs/heads/main", other, true)}}, false},
187		{"merged: the target set by an agent", merging, world{mr: mergedMR, tips: []domain.CIRefTip{tip("refs/heads/main", merged, false)}}, false},
188		{"merged: the base is not in the merge result", merging, world{mr: mergedMR, tips: mergedTips, mergeRef: merge, commits: mergeCommit, mb: other}, false},
189		{"merged: the merge base cannot be read", merging, world{mr: mergedMR, tips: mergedTips, mergeRef: merge, commits: mergeCommit, mbErr: boom}, false},
190	}
191	for _, tc := range cases {
192		t.Run(tc.name, func(t *testing.T) {
193			w := tc.w
194			mrs := mocks.NewMergeRequestStore(t)
195			mrs.EXPECT().Get(mock.Anything, repo, int64(1)).Return(w.mr, w.mrErr).Maybe()
196			tips := mocks.NewCIRefTipStore(t)
197			tips.EXPECT().GetRefTip(mock.Anything, repo, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, ref string) (domain.CIRefTip, error) {
198				if w.tipErr != nil {
199					return domain.CIRefTip{}, w.tipErr
200				}
201				for _, rt := range w.tips {
202					if rt.Ref == ref {
203						return rt, nil
204					}
205				}
206				return domain.CIRefTip{}, domain.ErrNotFound
207			}).Maybe()
208			pull := mocks.NewPullGit(t)
209			refErr := w.refErr
210			if refErr == nil && w.mergeRef.IsZero() {
211				refErr = domain.ErrNotFound
212			}
213			pull.EXPECT().PullRef(mock.Anything, repo, domain.PullMergeRef(1)).Return(w.mergeRef, refErr).Maybe()
214			git := mocks.NewMergeGit(t)
215			git.EXPECT().ListRange(mock.Anything, repo, domain.ObjectID{}, merge, 1).Return(w.commits, false, w.listErr).Maybe()
216			git.EXPECT().MergeBase(mock.Anything, repo, mock.Anything, merged).Return(w.mb, w.mbErr).Maybe()
217
218			cfg := usecase.CIConfig{MRs: mrs, RefTips: tips, Pull: pull, Git: git}
219			got, _ := usecase.PullTipsTrusted(context.Background(), cfg, repo, tc.ev)
220			assert.Equal(t, tc.want, got)
221		})
222	}
223}

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 4

Tags 45

↑↓ moveenter openesc close