v0.35.0 e86dbd6

ci_actions_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6	"context"
7	"fmt"
8	"strings"
9	"sync"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20func TestParseActionRef(t *testing.T) {
21	t.Parallel()
22	tests := []struct {
23		uses                         string
24		owner, repo, path, ref, fail string
25	}{
26		{uses: "actions/checkout@v4", owner: "actions", repo: "checkout", ref: "v4"},
27		{uses: "actions/cache/save@v4", owner: "actions", repo: "cache", path: "save", ref: "v4"},
28		{uses: "o/r/a/b/c@feature/x", owner: "o", repo: "r", path: "a/b/c", ref: "feature/x"},
29		{uses: "o\\r\\p@v1", owner: "o", repo: "r", path: "p", ref: "v1"}, // GitHub splits on both separators
30		{uses: "o//r@v1", owner: "o", repo: "r", ref: "v1"},               // and ignores empty segments
31		{uses: "noslash@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'"},
32		{uses: "o/r", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r'"},
33		{uses: "o/r@", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@'"},
34		{uses: "@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual '@v1'"},
35		{uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"},
36		{uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"},
37		{uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"},
38		{uses: "a/...@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/...@v1'"},
39		{uses: "../..@" + strings.Repeat("a", 40), fail: "Expected format {org}/{repo}[/path]@ref. Actual '../..@" + strings.Repeat("a", 40) + "'"},
40		{uses: "a%2f/b@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a%2f/b@v1'"},
41		{uses: "a/b?x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b?x@v1'"},
42		{uses: "a/b#x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b#x@v1'"},
43		{uses: "-a/_b.c@v1", owner: "-a", repo: "_b.c", ref: "v1"},
44		{uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"},
45	}
46	for _, tt := range tests {
47		t.Run(tt.uses, func(t *testing.T) {
48			t.Parallel()
49			a := parseActionRef(tt.uses)
50			assert.Equal(t, tt.fail, a.err)
51			if tt.fail == "" {
52				assert.Equal(t, []string{tt.owner, tt.repo, tt.path, tt.ref}, []string{a.owner, a.repo, a.path, a.ref})
53			}
54		})
55	}
56	assert.Equal(t, parseActionRef("Actions/Checkout@v4").key(), parseActionRef("actions/checkout@v4").key(), "names are case-insensitive")
57	assert.NotEqual(t, parseActionRef("a/b@V4").key(), parseActionRef("a/b@v4").key(), "refs are not")
58}
59
60func TestPickRemoteRef(t *testing.T) {
61	t.Parallel()
62	refs := []ports.RemoteRef{
63		{Name: "refs/heads/v1", ID: "b1"},
64		{Name: "refs/tags/v1", ID: "t1"},
65		{Name: "refs/tags/v2", ID: "tobj", Peeled: "c2"},
66		{Name: "refs/heads/main", ID: "m"},
67	}
68	assert.Equal(t, "t1", pickRemoteRef(refs, "v1"), "a tag before a branch")
69	assert.Equal(t, "c2", pickRemoteRef(refs, "v2"), "the peeled commit of an annotated tag")
70	assert.Equal(t, "m", pickRemoteRef(refs, "main"))
71	assert.Empty(t, pickRemoteRef(refs, "refs/heads/main"), "short names only")
72	assert.Empty(t, pickRemoteRef(refs, "nope"))
73	assert.Empty(t, pickRemoteRef(nil, "v1"))
74}
75
76// countingRemote answers ListRefs from a function and counts the calls.
77type countingRemote struct {
78	calls  int
79	answer func() ([]ports.RemoteRef, error)
80}
81
82func (r *countingRemote) ListRefs(context.Context, string, string) ([]ports.RemoteRef, error) {
83	r.calls++
84	return r.answer()
85}
86
87// The refs of a github.com repository are listed once for the jobs of the next
88// minutes; when github.com does not answer, the last list is used.
89func TestRemoteRefsCache(t *testing.T) {
90	v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
91	remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
92	now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
93	c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
94	list := func() ([]ports.RemoteRef, error) {
95		r := c.newRemoteRefs(1)
96		r.now = func() time.Time { return now }
97		return r.list(context.Background(), "Actions", "checkout")
98	}
99
100	got, err := list()
101	require.NoError(t, err)
102	assert.Equal(t, v4, got)
103	now = now.Add(refsFresh - time.Second)
104	_, err = list()
105	require.NoError(t, err)
106	assert.Equal(t, 1, remote.calls, "a fresh list is not asked again, whatever the case of the name")
107	rerun := c.newRemoteRefs(2)
108	rerun.now = func() time.Time { return now }
109	_, err = rerun.list(context.Background(), "actions", "checkout")
110	require.NoError(t, err)
111	assert.Equal(t, 2, remote.calls, "a re-run asks again")
112
113	now = now.Add(refsFresh + time.Second) // the re-run listed it afresh
114	remote.answer = func() ([]ports.RemoteRef, error) { return nil, fmt.Errorf("%w: timeout", domain.ErrUnavailable) }
115	got, err = list()
116	require.NoError(t, err, "github.com did not answer: the last list is used")
117	assert.Equal(t, v4, got)
118	assert.Equal(t, 3, remote.calls, "an old list is asked again")
119
120	now = now.Add(refsStale)
121	_, err = list()
122	require.ErrorIs(t, err, domain.ErrUnavailable, "too old to stand in")
123
124	remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrNotFound }
125	c.remoteCache = newRefsCache()
126	_, err = list()
127	require.ErrorIs(t, err, domain.ErrNotFound)
128	_, err = list()
129	require.ErrorIs(t, err, domain.ErrNotFound)
130	assert.Equal(t, 6, remote.calls, "a missing repository is not cached")
131}
132
133// A stale list stands in without becoming younger, the oldest repository makes
134// room for a new one, and the cache is safe for jobs resolving at once.
135func TestRefsCacheBounds(t *testing.T) {
136	v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
137	now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
138	remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
139	c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
140	list := func(repo string) error {
141		r := c.newRemoteRefs(1)
142		r.now = func() time.Time { return now }
143		_, err := r.list(context.Background(), "actions", repo)
144		return err
145	}
146	require.NoError(t, list("checkout"))
147	remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrUnavailable }
148	now = now.Add(refsStale - time.Hour)
149	require.NoError(t, list("checkout"), "stands in")
150	now = now.Add(time.Hour)
151	require.ErrorIs(t, list("checkout"), domain.ErrUnavailable, "standing in did not make it younger")
152
153	cache := newRefsCache()
154	for i := range refsMax {
155		cache.put(fmt.Sprintf("o/r%d", i), v4, now.Add(time.Duration(i)*time.Second))
156	}
157	cache.put("o/new", v4, now.Add(time.Hour))
158	_, ok := cache.stale("o/r0", now)
159	assert.False(t, ok, "the oldest is evicted")
160	_, ok = cache.stale("o/r1", now.Add(time.Second))
161	assert.True(t, ok)
162	assert.Len(t, cache.m, refsMax)
163
164	var wg sync.WaitGroup
165	for i := range 8 {
166		wg.Go(func() {
167			key := fmt.Sprintf("o/r%d", i%3)
168			cache.put(key, v4, now)
169			cache.get(key, now)
170			cache.stale(key, now)
171		})
172	}
173	wg.Wait()
174}
175
176// An unanswered ls-remote fails the call with the action named.
177func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) {
178	remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) {
179		return nil, fmt.Errorf("%w: git ls-remote actions/checkout did not answer within 20s (2 attempts)", domain.ErrUnavailable)
180	}}
181	c := &CI{cfg: CIConfig{Remote: remote, Owner: "owner", Clock: stillClock{}}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
182	_, _, err := c.resolveAction(context.Background(), domain.CIJob{}, parseActionRef("actions/checkout@v4"), c.newRemoteRefs(1), pusherAccess{})
183	require.ErrorIs(t, err, domain.ErrUnavailable)
184	assert.Contains(t, err.Error(), "actions/checkout@v4")
185	assert.Contains(t, err.Error(), "github.com did not answer")
186}
187
188type stillClock struct{}
189
190func (stillClock) Now() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }

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