detached a358636

Merge origin/main into fix/kvstore-delete-race-test

commit
a358636c3af80a036adfd4523b1d3d6345c437c8
author
brice · · 2026-10-12 19:44 UTC
merge of
5769af5 + f3afb66 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
131 files · +20260 -454
M README.md +15 -2
@@ -27,6 +27,8 @@ ermite serve
2727 | `ERMITE_LOG_FORMAT` | `json` | `json` or `console` |
2828 | `ERMITE_COMMITTER_NAME` | `ermite` | committer of squash merges |
2929 | `ERMITE_COMMITTER_EMAIL` | `ermite@<public host>` | committer email of squash merges |
30+| `ERMITE_OWNER` | `owner` | owner login that CI shows in `github.repository` (`<owner>/<repo>`) and `github.repository_owner`; follows the rules of repository names |
31+| `ERMITE_CI_LOG_RETENTION` | `90d` | how long CI runs, jobs, checks and logs are kept: `Nd` (days) or a Go duration such as `720h`, at least one hour |
3032
3133 `ermite version` prints the version (and the server's when `ERMITE_URL` is set).
3234
@@ -37,11 +39,14 @@ The same binary drives a running server over its API. It reads `ERMITE_URL` (the
3739 ```sh
3840 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
3941 ermite repo list | show <name> | create <name> [--description …] [--visibility public] | edit <name> [--description …] [--visibility …] [--default-branch …] | delete <name> [--yes]
40-ermite token create <label> [--preset cli|agent|ci] [--scope repo:read …] [--repo name …] [--expires 30d] # prints the secret once
42+ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--expires 30d] # prints the secret once
4143 ermite token list | revoke <id>
4244 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
4345 ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
4446 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
47+ermite run list <repo> [--workflow …] [--branch …] [--status …] | view <repo> <id> [--attempt n] [--log [--job …]]
48+ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
49+ermite runner list | remove <id>
4550 ermite web login # prints a one-time sign-in link (60 s)
4651 ermite web logout --all # signs every browser out
4752 ermite signing-key show # public key and allowed_signers line of squash commits
@@ -51,7 +56,7 @@ ermite watch [repo] [--json] # follows events, reconnects and resumes
5156
5257 An agent skill describing the CLI and the forge lives in [`skills/ermite`](skills/ermite/SKILL.md); link or copy that folder into your agent's skills directory.
5358
54-`--json` on `repo list|show|create|edit`, `token create|list` and `version` prints stable field names, listed in each command's `--help`; `watch --json` prints one object per line. Errors are one line on standard error (`ermite: …`) and exit status 1. `ermite completion bash|zsh|fish` prints a shell completion script that completes repository names from the server.
59+`--json` on `repo list|show|create|edit`, `token create|list`, `run list|view|cancel|rerun`, `runner list` and `version` prints stable field names, listed in each command's `--help`; `watch --json` prints one object per line. Errors are one line on standard error (`ermite: …`) and exit status 1. `ermite completion bash|zsh|fish` prints a shell completion script that completes repository names from the server.
5560
5661 ## Git over HTTP
5762
@@ -66,6 +71,14 @@ Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; th
6671
6772 A merge request proposes a branch for its target (the default branch unless set). The merge is refused when the branch moved since the reviewed head (`--head`) or when the target is not an ancestor of it. Fast-forward moves the target; squash writes one commit signed by the server's SSH key (`ermite signing-key show` prints the `allowed_signers` line for `git verify-commit`). `ermite repo edit --merge-modes` limits the modes per repository. `--delete-branch` (a checkbox on the web) deletes the source branch after the merge, unless it is the default branch or another open request uses it. Tokens need `mr:write` to open and comment, and `mr:merge` to merge (and delete the merged branch).
6873
74+## CI
75+
76+ermite runs the `.github/workflows/*.yml` workflows of a repository on `push` (a branch or a tag, and the merge of a merge request) and shows each job as a check on the commit, the branch list and the merge request. CI is on once a runner is registered; there is no switch. Before that a push makes no run and no check.
77+
78+Runs execute on [ermite-runner](https://git.bnema.dev/bnema/ermite-runner) hosts, which call ermite over its API. Create a token for the runner host with `ermite token create <label> --preset runner` (the `runner:register` scope, which also gives access to the code and the secrets of every repository: give it only to runners you operate), then register the runner with it. `ermite runner list` shows the runners and whether they are online; `ermite runner remove <id>` stops one. A job goes to a runner that has every label of its `runs-on`; a job no runner takes within 24 hours fails.
79+
80+`ermite run list|view|watch|cancel|rerun` and the `Actions` tab of a repository read and control the runs; `cancel` and `rerun` need the `ci:write` scope. With `failing_checks_block` on (the default, `ermite repo edit --failing-checks-block=false`), a merge is refused while a check of the head failed or has not finished. A workflow that uses a feature the runner does not support yet (`uses:` steps, `container:`, `services:`, `environment:`, `concurrency`, `continue-on-error`) fails with `not supported yet: <feature>`, never silently. Logs are files under `$ERMITE_DATA_DIR/ci/logs`.
81+
6982 ## Web sign-in
7083
7184 `ermite web login`, run with the admin token or a token with every scope, prints a link that works once within 60 seconds. Opening it and confirming sets a session cookie for 12 hours (never longer than the token that asked for the link). It needs `https` in `ERMITE_PUBLIC_URL` (except on localhost). Browser writes require the session and an `Origin` equal to `ERMITE_PUBLIC_URL`. `ermite web logout --all`, or revoking a token with every scope, ends every session.
A gen/ermite/storage/ci.pb.go +1603 -0 generated file: collapsed
M go.mod +2 -1
@@ -12,7 +12,8 @@ tool (
1212 )
1313
1414 require (
15- bnema.dev/ermite-api v0.0.0-20261011182719-cb5699451db6
15+ bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
16+ bnema.dev/ermite-api v0.0.0-20261012142818-97a93650f8b7
1617 bnema.dev/ermite-git v0.1.0
1718 bnema.dev/ermite-kv v0.0.0-20261010031554-5e09b48f81c3
1819 connectrpc.com/connect v1.21.0
M go.sum +4 -2 generated file: collapsed
A internal/adapters/cilogs/cilogs.go +371 -0
@@ -0,0 +1,371 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package cilogs implements ports.CILogStore with append-only files under
4+// $ERMITE_DATA_DIR/ci/logs/<run id>/<job id>/<attempt>.log.
5+//
6+// A file holds one line per log line, in GitHub's raw log shape: an RFC 3339
7+// timestamp with nanoseconds, a space, then the text, behind a one-character
8+// marker: "-" for a line, "+" for a piece that continues the line before it
9+// (it keeps its own line number):
10+//
11+// - 2026-10-12T10:00:00.123456789Z text
12+// - 2026-10-12T10:00:00.123456789Z more text of the same line
13+//
14+// The file is the truth for bytes and line counts: the metadata store is never
15+// written per chunk. Counts are cached per open log and rebuilt from the file
16+// after a restart.
17+package cilogs
18+
19+import (
20+ "bufio"
21+ "bytes"
22+ "context"
23+ "errors"
24+ "fmt"
25+ "io"
26+ "io/fs"
27+ "os"
28+ "path/filepath"
29+ "strconv"
30+ "sync"
31+ "time"
32+
33+ "bnema.dev/ermite/internal/domain"
34+ "bnema.dev/ermite/internal/ports"
35+)
36+
37+// Dir is the directory of the logs under the data directory.
38+func Dir(dataDir string) string { return filepath.Join(dataDir, "ci", "logs") }
39+
40+// timeLayout is RFC 3339 with nanoseconds, always in UTC and with all nine
41+// digits, so that every prefix has the same length.
42+const timeLayout = "2006-01-02T15:04:05.000000000Z"
43+
44+// prefixLen is the length of "- " + timestamp + " ".
45+const prefixLen = 2 + len(timeLayout) + 1
46+
47+// Store is the file-backed log store.
48+type Store struct {
49+ root string
50+
51+ mu sync.Mutex
52+ logs map[ports.CILogRef]*logState
53+}
54+
55+var _ ports.CILogStore = (*Store)(nil)
56+
57+// logState is what is known of one log file.
58+type logState struct {
59+ mu sync.Mutex
60+ loaded bool
61+ count int64
62+ size int64
63+ offsets []int64 // byte offset of every 1024th line: index i is line i*1024
64+ changed chan struct{}
65+}
66+
67+const offsetEvery = 1024
68+
69+// New returns a store rooted at dir, which need not exist yet.
70+func New(dir string) *Store {
71+ return &Store{root: dir, logs: make(map[ports.CILogRef]*logState)}
72+}
73+
74+func (s *Store) path(ref ports.CILogRef) string {
75+ return filepath.Join(s.root, strconv.FormatInt(ref.RunID, 10), ref.JobID, strconv.Itoa(int(ref.Attempt))+".log")
76+}
77+
78+func (s *Store) state(ref ports.CILogRef) *logState {
79+ s.mu.Lock()
80+ defer s.mu.Unlock()
81+ st, ok := s.logs[ref]
82+ if !ok {
83+ st = &logState{changed: make(chan struct{})}
84+ s.logs[ref] = st
85+ }
86+ return st
87+}
88+
89+// validRef rejects a reference that could escape the directory.
90+func validRef(ref ports.CILogRef) bool {
91+ if ref.RunID < 1 || ref.Attempt < 1 {
92+ return false
93+ }
94+ _, _, _, ok := domain.ParseJobID(ref.JobID)
95+ return ok
96+}
97+
98+// load reads the file to learn its line count and size, once. The caller
99+// holds st.mu.
100+func (s *Store) load(ref ports.CILogRef, st *logState) error {
101+ if st.loaded {
102+ return nil
103+ }
104+ f, err := os.Open(s.path(ref))
105+ if errors.Is(err, fs.ErrNotExist) {
106+ st.loaded = true
107+ return nil
108+ }
109+ if err != nil {
110+ return fmt.Errorf("open log: %w", err)
111+ }
112+ defer func() { _ = f.Close() }()
113+ r := bufio.NewReaderSize(f, 1<<16)
114+ var count, pos int64
115+ for {
116+ line, err := r.ReadBytes('\n')
117+ if len(line) > 0 && line[len(line)-1] == '\n' {
118+ if count%offsetEvery == 0 {
119+ st.offsets = append(st.offsets, pos)
120+ }
121+ count++
122+ pos += int64(len(line))
123+ } else if len(line) > 0 {
124+ // A torn last line from a crash: cut it, it was never acknowledged.
125+ if terr := os.Truncate(s.path(ref), pos); terr != nil {
126+ return fmt.Errorf("repair log: %w", terr)
127+ }
128+ }
129+ if err != nil {
130+ if errors.Is(err, io.EOF) {
131+ break
132+ }
133+ return fmt.Errorf("read log: %w", err)
134+ }
135+ }
136+ st.count, st.size, st.loaded = count, pos, true
137+ return nil
138+}
139+
140+func encodeLine(l domain.CILogLine) []byte {
141+ b := make([]byte, 0, prefixLen+len(l.Text)+1)
142+ if l.Continued {
143+ b = append(b, '+', ' ')
144+ } else {
145+ b = append(b, '-', ' ')
146+ }
147+ b = l.Time.UTC().AppendFormat(b, timeLayout)
148+ b = append(b, ' ')
149+ b = append(b, l.Text...)
150+ return append(b, '\n')
151+}
152+
153+func decodeLine(b []byte) (domain.CILogLine, error) {
154+ b = bytes.TrimSuffix(b, []byte{'\n'})
155+ if len(b) < prefixLen || (b[0] != '-' && b[0] != '+') {
156+ return domain.CILogLine{}, errors.New("malformed log line")
157+ }
158+ t, err := time.Parse(timeLayout, string(b[2:2+len(timeLayout)]))
159+ if err != nil {
160+ return domain.CILogLine{}, fmt.Errorf("malformed log time: %w", err)
161+ }
162+ return domain.CILogLine{Time: t, Text: string(b[prefixLen:]), Continued: b[0] == '+'}, nil
163+}
164+
165+// Append appends lines at offset.
166+func (s *Store) Append(ctx context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64) (int64, int, error) {
167+ return s.append(ctx, ref, offset, lines, limit, false)
168+}
169+
170+// AppendForced appends lines whatever the limit.
171+func (s *Store) AppendForced(ctx context.Context, ref ports.CILogRef, lines []domain.CILogLine) (int64, error) {
172+ next, _, err := s.append(ctx, ref, -1, lines, 0, true)
173+ return next, err
174+}
175+
176+func (s *Store) append(_ context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64, force bool) (int64, int, error) {
177+ if !validRef(ref) {
178+ return 0, 0, domain.ErrNotFound
179+ }
180+ st := s.state(ref)
181+ st.mu.Lock()
182+ defer st.mu.Unlock()
183+ if err := s.load(ref, st); err != nil {
184+ return 0, 0, err
185+ }
186+ if offset < 0 {
187+ offset = st.count
188+ }
189+ if offset > st.count {
190+ return 0, 0, &ports.ErrCILogGap{Count: st.count}
191+ }
192+ skip := st.count - offset
193+ if skip >= int64(len(lines)) {
194+ return st.count, 0, nil
195+ }
196+ fresh := lines[skip:]
197+ var buf bytes.Buffer
198+ for _, l := range fresh {
199+ buf.Write(encodeLine(l))
200+ }
201+ if !force && st.size+int64(buf.Len()) > limit {
202+ return st.count, 0, ports.ErrCILogLimit
203+ }
204+ if err := os.MkdirAll(filepath.Dir(s.path(ref)), 0o700); err != nil {
205+ return 0, 0, fmt.Errorf("create log directory: %w", err)
206+ }
207+ f, err := os.OpenFile(s.path(ref), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o600)
208+ if err != nil {
209+ return 0, 0, fmt.Errorf("open log: %w", err)
210+ }
211+ n, werr := f.Write(buf.Bytes())
212+ cerr := f.Close()
213+ if werr != nil || cerr != nil {
214+ // Cut what was written so that the file keeps whole lines.
215+ _ = os.Truncate(s.path(ref), st.size)
216+ return 0, 0, fmt.Errorf("write log: %w", errors.Join(werr, cerr))
217+ }
218+ pos := st.size
219+ for i, l := range fresh {
220+ if (st.count+int64(i))%offsetEvery == 0 {
221+ st.offsets = append(st.offsets, pos)
222+ }
223+ pos += int64(len(encodeLine(l)))
224+ }
225+ st.count += int64(len(fresh))
226+ st.size += int64(n)
227+ close(st.changed)
228+ st.changed = make(chan struct{})
229+ return st.count, len(fresh), nil
230+}
231+
232+// Count returns the number of lines held.
233+func (s *Store) Count(_ context.Context, ref ports.CILogRef) (int64, error) {
234+ if !validRef(ref) {
235+ return 0, nil
236+ }
237+ st := s.state(ref)
238+ st.mu.Lock()
239+ defer st.mu.Unlock()
240+ if err := s.load(ref, st); err != nil {
241+ return 0, err
242+ }
243+ return st.count, nil
244+}
245+
246+// Read returns lines from line number from.
247+func (s *Store) Read(_ context.Context, ref ports.CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error) {
248+ if !validRef(ref) || from < 0 {
249+ return nil, domain.ErrNotFound
250+ }
251+ st := s.state(ref)
252+ st.mu.Lock()
253+ if err := s.load(ref, st); err != nil {
254+ st.mu.Unlock()
255+ return nil, err
256+ }
257+ count, size := st.count, st.size
258+ var start int64
259+ var first int64
260+ if idx := from / offsetEvery; idx < int64(len(st.offsets)) {
261+ start, first = st.offsets[idx], idx*offsetEvery
262+ }
263+ st.mu.Unlock()
264+ if from > count {
265+ return nil, domain.ErrOutOfRange
266+ }
267+ if _, err := os.Stat(s.path(ref)); errors.Is(err, fs.ErrNotExist) {
268+ if from == 0 && count == 0 {
269+ return nil, domain.ErrNotFound
270+ }
271+ return nil, domain.ErrNotFound
272+ }
273+ if from == count {
274+ return nil, nil
275+ }
276+ f, err := os.Open(s.path(ref))
277+ if err != nil {
278+ return nil, fmt.Errorf("open log: %w", err)
279+ }
280+ defer func() { _ = f.Close() }()
281+ // Never read past the size seen under the lock: a concurrent append is
282+ // for the next call.
283+ r := bufio.NewReaderSize(io.NewSectionReader(f, start, size-start), 1<<16)
284+ var out []domain.CILogLine
285+ total := 0
286+ for n := first; n < count; n++ {
287+ raw, err := r.ReadBytes('\n')
288+ if err != nil {
289+ return nil, fmt.Errorf("read log: %w", err)
290+ }
291+ if n < from {
292+ continue
293+ }
294+ l, err := decodeLine(raw)
295+ if err != nil {
296+ return nil, fmt.Errorf("log line %d: %w", n, err)
297+ }
298+ if len(out) > 0 && (len(out) >= maxLines || total+len(l.Text) > maxBytes) {
299+ break
300+ }
301+ total += len(l.Text)
302+ out = append(out, l)
303+ }
304+ return out, nil
305+}
306+
307+// Changed returns a channel closed at the next append.
308+func (s *Store) Changed(ref ports.CILogRef) <-chan struct{} {
309+ st := s.state(ref)
310+ st.mu.Lock()
311+ defer st.mu.Unlock()
312+ return st.changed
313+}
314+
315+// Reset empties the log.
316+func (s *Store) Reset(_ context.Context, ref ports.CILogRef) error {
317+ if !validRef(ref) {
318+ return nil
319+ }
320+ st := s.state(ref)
321+ st.mu.Lock()
322+ defer st.mu.Unlock()
323+ if err := os.Remove(s.path(ref)); err != nil && !errors.Is(err, fs.ErrNotExist) {
324+ return fmt.Errorf("remove log: %w", err)
325+ }
326+ st.loaded, st.count, st.size, st.offsets = true, 0, 0, nil
327+ close(st.changed)
328+ st.changed = make(chan struct{})
329+ return nil
330+}
331+
332+// RemoveRuns deletes the logs of the runs.
333+func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
334+ var errs []error
335+ for _, id := range runIDs {
336+ if id < 1 {
337+ continue
338+ }
339+ if err := os.RemoveAll(filepath.Join(s.root, strconv.FormatInt(id, 10))); err != nil {
340+ errs = append(errs, err)
341+ }
342+ }
343+ s.mu.Lock()
344+ for ref := range s.logs {
345+ for _, id := range runIDs {
346+ if ref.RunID == id {
347+ delete(s.logs, ref)
348+ }
349+ }
350+ }
351+ s.mu.Unlock()
352+ return errors.Join(errs...)
353+}
354+
355+// RunIDs lists the runs that have a log directory.
356+func (s *Store) RunIDs(_ context.Context) ([]int64, error) {
357+ entries, err := os.ReadDir(s.root)
358+ if errors.Is(err, fs.ErrNotExist) {
359+ return nil, nil
360+ }
361+ if err != nil {
362+ return nil, fmt.Errorf("list logs: %w", err)
363+ }
364+ var ids []int64
365+ for _, e := range entries {
366+ if id, err := strconv.ParseInt(e.Name(), 10, 64); err == nil && e.IsDir() {
367+ ids = append(ids, id)
368+ }
369+ }
370+ return ids, nil
371+}
A internal/adapters/cilogs/cilogs_test.go +238 -0
@@ -0,0 +1,238 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package cilogs_test
4+
5+import (
6+ "context"
7+ "os"
8+ "path/filepath"
9+ "strings"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
16+ "bnema.dev/ermite/internal/adapters/cilogs"
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+)
20+
21+var (
22+ ref0 = ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 1}
23+ t0 = time.Date(2026, 10, 12, 10, 0, 0, 123456789, time.UTC)
24+)
25+
26+func lines(texts ...string) []domain.CILogLine {
27+ out := make([]domain.CILogLine, len(texts))
28+ for i, s := range texts {
29+ out[i] = domain.CILogLine{Time: t0.Add(time.Duration(i) * time.Millisecond), Text: s}
30+ }
31+ return out
32+}
33+
34+const big = 1 << 30
35+
36+func TestAppendAndReadKeepTextTimeAndContinuation(t *testing.T) {
37+ ctx := context.Background()
38+ s := cilogs.New(t.TempDir())
39+ in := lines("##[group]Run echo hi", "hi", "##[endgroup]")
40+ in = append(in, domain.CILogLine{Time: t0, Text: "second half", Continued: true})
41+ next, added, err := s.Append(ctx, ref0, 0, in, big)
42+ require.NoError(t, err)
43+ assert.Equal(t, int64(4), next)
44+ assert.Equal(t, 4, added)
45+
46+ got, err := s.Read(ctx, ref0, 0, 100, 1<<20)
47+ require.NoError(t, err)
48+ require.Len(t, got, 4)
49+ assert.Equal(t, "##[group]Run echo hi", got[0].Text)
50+ assert.True(t, got[0].Time.Equal(t0), "nanosecond precision is kept: %v", got[0].Time)
51+ assert.True(t, got[3].Continued)
52+ assert.False(t, got[2].Continued)
53+
54+ tail, err := s.Read(ctx, ref0, 2, 100, 1<<20)
55+ require.NoError(t, err)
56+ assert.Equal(t, "##[endgroup]", tail[0].Text)
57+}
58+
59+func TestAppendIsIdempotentByOffset(t *testing.T) {
60+ ctx := context.Background()
61+ s := cilogs.New(t.TempDir())
62+ _, _, err := s.Append(ctx, ref0, 0, lines("a", "b", "c"), big)
63+ require.NoError(t, err)
64+
65+ // A retry of the same request changes nothing.
66+ next, added, err := s.Append(ctx, ref0, 0, lines("a", "b", "c"), big)
67+ require.NoError(t, err)
68+ assert.Equal(t, int64(3), next)
69+ assert.Zero(t, added)
70+
71+ // A request that overlaps appends only its new tail.
72+ next, added, err = s.Append(ctx, ref0, 1, lines("b", "c", "d"), big)
73+ require.NoError(t, err)
74+ assert.Equal(t, int64(4), next)
75+ assert.Equal(t, 1, added)
76+
77+ // A retry whose lines differ is silently dropped, as the contract says.
78+ next, added, err = s.Append(ctx, ref0, 0, lines("X", "Y"), big)
79+ require.NoError(t, err)
80+ assert.Equal(t, int64(4), next)
81+ assert.Zero(t, added)
82+ got, _ := s.Read(ctx, ref0, 0, 10, 1<<20)
83+ assert.Equal(t, "a", got[0].Text)
84+}
85+
86+func TestAppendAboveTheCountIsAGap(t *testing.T) {
87+ ctx := context.Background()
88+ s := cilogs.New(t.TempDir())
89+ _, _, err := s.Append(ctx, ref0, 0, lines("a"), big)
90+ require.NoError(t, err)
91+ _, _, err = s.Append(ctx, ref0, 5, lines("z"), big)
92+ require.ErrorIs(t, err, domain.ErrOutOfRange)
93+ var gap *ports.ErrCILogGap
94+ require.ErrorAs(t, err, &gap)
95+ assert.Equal(t, int64(1), gap.Count)
96+}
97+
98+func TestTheLimitRefusesTheWholeRequestUnlessForced(t *testing.T) {
99+ ctx := context.Background()
100+ s := cilogs.New(t.TempDir())
101+ _, _, err := s.Append(ctx, ref0, 0, lines("aaaa"), 200)
102+ require.NoError(t, err)
103+ _, _, err = s.Append(ctx, ref0, 1, lines(strings.Repeat("b", 300)), 200)
104+ require.ErrorIs(t, err, ports.ErrCILogLimit)
105+ n, _ := s.Count(ctx, ref0)
106+ assert.Equal(t, int64(1), n, "nothing of the refused request was appended")
107+
108+ next, err := s.AppendForced(ctx, ref0, lines("limit reached"))
109+ require.NoError(t, err)
110+ assert.Equal(t, int64(2), next)
111+}
112+
113+func TestReadBoundsLinesAndBytesAndReportsMissingLogs(t *testing.T) {
114+ ctx := context.Background()
115+ s := cilogs.New(t.TempDir())
116+ _, err := s.Read(ctx, ref0, 0, 10, 100)
117+ require.ErrorIs(t, err, domain.ErrNotFound)
118+
119+ _, _, err = s.Append(ctx, ref0, 0, lines("12345", "12345", "12345", "12345"), big)
120+ require.NoError(t, err)
121+ got, err := s.Read(ctx, ref0, 0, 3, 1<<20)
122+ require.NoError(t, err)
123+ assert.Len(t, got, 3)
124+ got, err = s.Read(ctx, ref0, 0, 100, 12)
125+ require.NoError(t, err)
126+ assert.Len(t, got, 2, "the text of a batch is bounded")
127+ got, err = s.Read(ctx, ref0, 4, 10, 100)
128+ require.NoError(t, err)
129+ assert.Empty(t, got, "reading at the end is empty, not an error")
130+ _, err = s.Read(ctx, ref0, 5, 10, 100)
131+ assert.ErrorIs(t, err, domain.ErrOutOfRange)
132+}
133+
134+func TestReadFindsLinesDeepInALargeLog(t *testing.T) {
135+ ctx := context.Background()
136+ s := cilogs.New(t.TempDir())
137+ var all []domain.CILogLine
138+ for i := range 3000 {
139+ all = append(all, domain.CILogLine{Time: t0, Text: "line " + itoa(i)})
140+ }
141+ for off := 0; off < len(all); off += 1000 {
142+ _, _, err := s.Append(ctx, ref0, int64(off), all[off:off+1000], big)
143+ require.NoError(t, err)
144+ }
145+ got, err := s.Read(ctx, ref0, 2500, 2, 1<<20)
146+ require.NoError(t, err)
147+ require.Len(t, got, 2)
148+ assert.Equal(t, "line 2500", got[0].Text)
149+ assert.Equal(t, "line 2501", got[1].Text)
150+}
151+
152+func itoa(n int) string {
153+ if n == 0 {
154+ return "0"
155+ }
156+ var b []byte
157+ for n > 0 {
158+ b = append([]byte{byte('0' + n%10)}, b...)
159+ n /= 10
160+ }
161+ return string(b)
162+}
163+
164+func TestCountSurvivesARestartAndAChangedChannelFiresOnAppend(t *testing.T) {
165+ ctx := context.Background()
166+ dir := t.TempDir()
167+ s := cilogs.New(dir)
168+ ch := s.Changed(ref0)
169+ _, _, err := s.Append(ctx, ref0, 0, lines("a", "b"), big)
170+ require.NoError(t, err)
171+ select {
172+ case <-ch:
173+ default:
174+ t.Fatal("Changed did not fire on append")
175+ }
176+
177+ s2 := cilogs.New(dir)
178+ n, err := s2.Count(ctx, ref0)
179+ require.NoError(t, err)
180+ assert.Equal(t, int64(2), n)
181+ _, _, err = s2.Append(ctx, ref0, 2, lines("c"), big)
182+ require.NoError(t, err)
183+ got, _ := s2.Read(ctx, ref0, 0, 10, 1<<20)
184+ assert.Len(t, got, 3)
185+}
186+
187+func TestATornLastLineIsCutOnLoad(t *testing.T) {
188+ ctx := context.Background()
189+ dir := t.TempDir()
190+ s := cilogs.New(dir)
191+ _, _, err := s.Append(ctx, ref0, 0, lines("a", "b"), big)
192+ require.NoError(t, err)
193+ path := filepath.Join(dir, "7", "7-0-0", "1.log")
194+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
195+ require.NoError(t, err)
196+ _, err = f.WriteString("- 2026-10-12T10:00:00.1234")
197+ require.NoError(t, err)
198+ require.NoError(t, f.Close())
199+
200+ s2 := cilogs.New(dir)
201+ n, err := s2.Count(ctx, ref0)
202+ require.NoError(t, err)
203+ assert.Equal(t, int64(2), n)
204+ _, _, err = s2.Append(ctx, ref0, 2, lines("c"), big)
205+ require.NoError(t, err)
206+ got, err := s2.Read(ctx, ref0, 0, 10, 1<<20)
207+ require.NoError(t, err)
208+ assert.Len(t, got, 3)
209+}
210+
211+func TestRemoveRunsAndResetAndRunIDs(t *testing.T) {
212+ ctx := context.Background()
213+ s := cilogs.New(t.TempDir())
214+ other := ports.CILogRef{RunID: 9, JobID: "9-0-0", Attempt: 1}
215+ _, _, _ = s.Append(ctx, ref0, 0, lines("a"), big)
216+ _, _, _ = s.Append(ctx, other, 0, lines("b"), big)
217+ ids, err := s.RunIDs(ctx)
218+ require.NoError(t, err)
219+ assert.ElementsMatch(t, []int64{7, 9}, ids)
220+
221+ require.NoError(t, s.Reset(ctx, ref0))
222+ n, _ := s.Count(ctx, ref0)
223+ assert.Zero(t, n)
224+
225+ require.NoError(t, s.RemoveRuns(ctx, []int64{9}))
226+ _, err = s.Read(ctx, other, 0, 10, 100)
227+ assert.ErrorIs(t, err, domain.ErrNotFound)
228+}
229+
230+func TestARefThatCouldEscapeTheDirectoryIsRefused(t *testing.T) {
231+ ctx := context.Background()
232+ dir := t.TempDir()
233+ s := cilogs.New(dir)
234+ _, _, err := s.Append(ctx, ports.CILogRef{RunID: 1, JobID: "../../etc", Attempt: 1}, 0, lines("x"), big)
235+ assert.ErrorIs(t, err, domain.ErrNotFound)
236+ _, _, err = s.Append(ctx, ports.CILogRef{RunID: 1, JobID: "1-0-0", Attempt: 0}, 0, lines("x"), big)
237+ assert.ErrorIs(t, err, domain.ErrNotFound)
238+}
M internal/adapters/client/client.go +5 -0
@@ -65,6 +65,11 @@ func (c *Client) Repos() ermiteconnect.RepoServiceClient {
6565 return ermiteconnect.NewRepoServiceClient(c.http, c.base, c.opts...)
6666 }
6767
68+// Runs returns the RunService client.
69+func (c *Client) Runs() ermiteconnect.RunServiceClient {
70+ return ermiteconnect.NewRunServiceClient(c.http, c.base, c.opts...)
71+}
72+
6873 // Tokens returns the TokenService client.
6974 func (c *Client) Tokens() ermiteconnect.TokenServiceClient {
7075 return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...)
M internal/adapters/client/client_test.go +2 -1
@@ -139,10 +139,11 @@ func TestDescribeBoundsLongMessages(t *testing.T) {
139139 func TestMissingFeatures(t *testing.T) {
140140 assert.Empty(t, client.MissingFeatures(client.UsedFeatures()))
141141 assert.Equal(t,
142- []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey},
142+ []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureCIRuns, client.FeatureCIRunners},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145145 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146+ client.FeatureCIRuns, client.FeatureCIRunners,
146147 }))
147148 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
148149 }
M internal/adapters/client/features.go +5 -1
@@ -22,11 +22,15 @@ const (
2222 FeatureSigningKey = "signing_key"
2323 // FeatureSession is SessionService (`ermite web login`, `web logout`).
2424 FeatureSession = "session"
25+ // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner
26+ // calls (`ermite runner`).
27+ FeatureCIRuns = "ci.runs"
28+ FeatureCIRunners = "ci.runners"
2529 )
2630
2731 // UsedFeatures lists the features the CLI uses, in the order they are reported.
2832 func UsedFeatures() []string {
29- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession}
33+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureCIRuns, FeatureCIRunners}
3034 }
3135
3236 // MissingFeatures returns the features the CLI uses that have is lacking.
M internal/adapters/client/scopes.go +5 -1
@@ -18,10 +18,12 @@ const (
1818 presetCLI = "cli"
1919 presetAgent = "agent"
2020 presetCI = "ci"
21+ // presetRunner is for a CI runner host: it only registers runners.
22+ presetRunner = "runner"
2123 )
2224
2325 // PresetNames lists the token presets, see PresetScopes.
24-var PresetNames = []string{presetCLI, presetAgent, presetCI}
26+var PresetNames = []string{presetCLI, presetAgent, presetCI, presetRunner}
2527
2628 // ScopeName is the name of a scope as users type it and as the docs write
2729 // it: SCOPE_REPO_READ is "repo:read".
@@ -75,6 +77,8 @@ func PresetScopes(name string) ([]ermite.Scope, error) {
7577 return []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE}, nil
7678 case presetCI:
7779 return []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, nil
80+ case presetRunner:
81+ return []ermite.Scope{ermite.Scope_SCOPE_RUNNER_REGISTER}, nil
7882 default:
7983 return nil, fmt.Errorf("unknown preset %q: use one of %s", name, strings.Join(PresetNames, ", "))
8084 }
A internal/adapters/gitcli/changed.go +60 -0
@@ -0,0 +1,60 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli
4+
5+import (
6+ "bufio"
7+ "context"
8+ "fmt"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+)
13+
14+var _ ports.CIGit = (*Git)(nil)
15+
16+// emptyTree is the id of the empty tree of a SHA-1 repository, what a diff
17+// from "nothing" starts at.
18+const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
19+
20+// ChangedPaths runs `git diff-tree -r --name-only -z` from base to head, with
21+// renames not detected (a rename lists both paths, as GitHub's push filters
22+// see it), and stops reading after max paths: truncated tells there were more.
23+// A zero base diffs against the empty tree.
24+//
25+//hexcheck:ignore suspicious-business-logic-in-adapter reads the path list git prints; the bound comes from the port
26+func (g *Git) ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, limit int) ([]string, bool, error) {
27+ if limit < 1 || head.IsZero() {
28+ return nil, false, fmt.Errorf("changed paths: %w: limit %d", domain.ErrInvalid, limit)
29+ }
30+ dir, err := g.repoDir(repo)
31+ if err != nil {
32+ return nil, false, err
33+ }
34+ from := emptyTree
35+ if !base.IsZero() {
36+ from = base.String()
37+ }
38+ args := readArgs(dir, "diff-tree", "-r", "--name-only", "-z", "--no-renames", "--no-commit-id", "--end-of-options", from, head.String())
39+ var (
40+ paths []string
41+ truncated bool
42+ )
43+ err = g.pipe(ctx, args, func(r *bufio.Reader) error {
44+ for {
45+ name, err := r.ReadString(0)
46+ if err != nil {
47+ return nil // the end of the output; a failing exit status is reported by pipe
48+ }
49+ if len(paths) == limit {
50+ truncated = true
51+ return errStop
52+ }
53+ paths = append(paths, name[:len(name)-1])
54+ }
55+ })
56+ if err != nil {
57+ return nil, false, fmt.Errorf("changed paths %s..%s in %q: %w", from, head, repo, err)
58+ }
59+ return paths, truncated, nil
60+}
A internal/adapters/gitcli/changed_test.go +69 -0
@@ -0,0 +1,69 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package gitcli_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+func TestChangedPaths(t *testing.T) {
16+ g, data := newGit(t)
17+ ctx := context.Background()
18+ m := newMergeFixture(t, data)
19+ t.Cleanup(func() { _ = g.Close(ctx) })
20+
21+ // b..d changed a.txt and added c.txt.
22+ got, truncated, err := g.ChangedPaths(ctx, m.f.Name, m.b, m.d, 300)
23+ require.NoError(t, err)
24+ assert.False(t, truncated)
25+ assert.ElementsMatch(t, []string{"a.txt", "c.txt"}, got)
26+
27+ // A zero base lists every file of the commit, as for a new repository.
28+ got, _, err = g.ChangedPaths(ctx, m.f.Name, domain.ObjectID{}, m.b, 300)
29+ require.NoError(t, err)
30+ assert.ElementsMatch(t, []string{"a.txt", "b.txt"}, got)
31+
32+ // No change is an empty list, not an error.
33+ got, truncated, err = g.ChangedPaths(ctx, m.f.Name, m.d, m.d, 300)
34+ require.NoError(t, err)
35+ assert.Empty(t, got)
36+ assert.False(t, truncated)
37+
38+ // A bound cuts the list and says so.
39+ got, truncated, err = g.ChangedPaths(ctx, m.f.Name, domain.ObjectID{}, m.d, 2)
40+ require.NoError(t, err)
41+ assert.Len(t, got, 2)
42+ assert.True(t, truncated)
43+ got, truncated, err = g.ChangedPaths(ctx, m.f.Name, domain.ObjectID{}, m.d, 3)
44+ require.NoError(t, err)
45+ assert.Len(t, got, 3)
46+ assert.False(t, truncated, "a list of exactly the bound is complete")
47+
48+ _, _, err = g.ChangedPaths(ctx, m.f.Name, m.b, m.d, 0)
49+ assert.ErrorIs(t, err, domain.ErrInvalid)
50+ _, _, err = g.ChangedPaths(ctx, name(t, "missing"), m.b, m.d, 5)
51+ assert.ErrorIs(t, err, domain.ErrNotFound)
52+ _, _, err = g.ChangedPaths(ctx, m.f.Name, m.b, oidOf(t, "1111111111111111111111111111111111111111"), 5)
53+ assert.Error(t, err, "an unknown commit is an error")
54+}
55+
56+func TestChangedPathsKeepsAWholeRenameAndOddNames(t *testing.T) {
57+ g, data := newGit(t)
58+ ctx := context.Background()
59+ m := newMergeFixture(t, data)
60+ t.Cleanup(func() { _ = g.Close(ctx) })
61+
62+ m.f.Git("checkout", "-q", "feat")
63+ m.f.Write("dir with space/é.txt", "see\n")
64+ m.f.Git("rm", "-q", "c.txt")
65+ d2 := oidOf(t, m.f.Commit("rename"))
66+ got, _, err := g.ChangedPaths(ctx, m.f.Name, m.featTip, d2, 10)
67+ require.NoError(t, err)
68+ assert.ElementsMatch(t, []string{"c.txt", "dir with space/é.txt"}, got, "both sides of a rename, verbatim")
69+}
A internal/adapters/kvstore/ci.go +1028 -0
@@ -0,0 +1,1028 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ kv "bnema.dev/ermite-kv"
15+ "google.golang.org/protobuf/proto"
16+
17+ storagepb "bnema.dev/ermite/gen/ermite/storage"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Key layout of CI. Repository names hold no '/', so a prefix ending in "/"
23+// never matches another repository; ids are zero-padded so that keys sort
24+// numerically. Job records are keyed by run, attempt of the run and job id.
25+//
26+// run/<repo>/<id:020d> CIRun
27+// runsrc/<id:020d> CIRunSource
28+// job/<run:020d>/<attempt:010d>/<id> CIJob
29+// check/<repo>/<sha>/<check id> CICheck
30+// runseq Sequence: last run id
31+// runnum/<repo>/<workflow path> Sequence: last run number
32+// runner/<id>, runnername/<name>, runnertoken/<hash>
33+//
34+// Indexes, derived from the job and run records by every write:
35+//
36+// ciopen/<id:020d> run not completed (value: repo)
37+// ciready/<ready_at:020d>/<job key> job waiting for a runner
38+// ciactive/<job key> job assigned or running
39+// jobtoken/<hash> job token (value: job key)
40+const (
41+ runPrefix = "run/"
42+ runSrcPrefix = "runsrc/"
43+ jobPrefix = "job/"
44+ checkPrefix = "check/"
45+ runSeqKey = "runseq"
46+ runNumPrefix = "runnum/"
47+ runnerPrefix = "runner/"
48+ runnerName = "runnername/"
49+ runnerTokPref = "runnertoken/"
50+ openPrefix = "ciopen/"
51+ readyPrefix = "ciready/"
52+ activePrefix = "ciactive/"
53+ jobTokPrefix = "jobtoken/"
54+ idFormat = "%020d"
55+)
56+
57+// CI is the ermite-kv backed ports.CIStore. It shares the database of the
58+// Store it comes from.
59+type CI struct {
60+ db *kv.DB
61+}
62+
63+var _ ports.CIStore = (*CI)(nil)
64+
65+// CI returns the CI store over the same database. It is valid until the Store
66+// is closed.
67+func (s *Store) CI() *CI { return &CI{db: s.db} }
68+
69+func runKey(repo domain.RepoName, id int64) []byte {
70+ return []byte(runPrefix + repo.String() + "/" + fmt.Sprintf(idFormat, id))
71+}
72+func runsPrefix(repo domain.RepoName) string { return runPrefix + repo.String() + "/" }
73+func runSrcKey(id int64) []byte { return []byte(runSrcPrefix + fmt.Sprintf(idFormat, id)) }
74+func openKey(id int64) []byte { return []byte(openPrefix + fmt.Sprintf(idFormat, id)) }
75+func jobsPrefix(runID int64) string { return jobPrefix + fmt.Sprintf(idFormat, runID) + "/" }
76+func jobAttemptPrefix(runID int64, attempt int32) string {
77+ return jobsPrefix(runID) + fmt.Sprintf("%010d", attempt) + "/"
78+}
79+func jobRef(runID int64, attempt int32, id string) string {
80+ return jobAttemptPrefix(runID, attempt) + id
81+}
82+func jobKey(runID int64, attempt int32, id string) []byte { return []byte(jobRef(runID, attempt, id)) }
83+func checksPrefix(repo domain.RepoName, sha string) string {
84+ return checkPrefix + repo.String() + "/" + sha + "/"
85+}
86+func checkKey(repo domain.RepoName, sha, id string) []byte {
87+ return []byte(checksPrefix(repo, sha) + id)
88+}
89+func runNumKey(repo domain.RepoName, workflow string) []byte {
90+ return []byte(runNumPrefix + repo.String() + "/" + workflow)
91+}
92+func readyKey(at time.Time, ref string) []byte {
93+ return []byte(readyPrefix + fmt.Sprintf(idFormat, at.UnixNano()) + "/" + ref)
94+}
95+func activeKey(ref string) []byte { return []byte(activePrefix + ref) }
96+func jobTokenKey(h domain.TokenHash) []byte {
97+ return []byte(jobTokPrefix + h.String())
98+}
99+
100+// --- encoding ---
101+
102+func encodeStatus(s domain.CIStatus) storagepb.CIStatus {
103+ switch s {
104+ case domain.CIQueued:
105+ return storagepb.CIStatus_CI_STATUS_QUEUED
106+ case domain.CIInProgress:
107+ return storagepb.CIStatus_CI_STATUS_IN_PROGRESS
108+ case domain.CICompleted:
109+ return storagepb.CIStatus_CI_STATUS_COMPLETED
110+ }
111+ return storagepb.CIStatus_CI_STATUS_UNSPECIFIED
112+}
113+
114+func decodeStatus(s storagepb.CIStatus) domain.CIStatus {
115+ switch s {
116+ case storagepb.CIStatus_CI_STATUS_IN_PROGRESS:
117+ return domain.CIInProgress
118+ case storagepb.CIStatus_CI_STATUS_COMPLETED:
119+ return domain.CICompleted
120+ }
121+ return domain.CIQueued
122+}
123+
124+var conclusions = []struct {
125+ d domain.CIConclusion
126+ p storagepb.CIConclusion
127+}{
128+ {domain.CISuccess, storagepb.CIConclusion_CI_CONCLUSION_SUCCESS},
129+ {domain.CIFailure, storagepb.CIConclusion_CI_CONCLUSION_FAILURE},
130+ {domain.CICancelled, storagepb.CIConclusion_CI_CONCLUSION_CANCELLED},
131+ {domain.CISkipped, storagepb.CIConclusion_CI_CONCLUSION_SKIPPED},
132+ {domain.CITimedOut, storagepb.CIConclusion_CI_CONCLUSION_TIMED_OUT},
133+ {domain.CINeutral, storagepb.CIConclusion_CI_CONCLUSION_NEUTRAL},
134+ {domain.CIActionRequired, storagepb.CIConclusion_CI_CONCLUSION_ACTION_REQUIRED},
135+}
136+
137+func encodeConclusion(c domain.CIConclusion) storagepb.CIConclusion {
138+ for _, e := range conclusions {
139+ if e.d == c {
140+ return e.p
141+ }
142+ }
143+ return storagepb.CIConclusion_CI_CONCLUSION_UNSPECIFIED
144+}
145+
146+func decodeConclusion(c storagepb.CIConclusion) domain.CIConclusion {
147+ for _, e := range conclusions {
148+ if e.p == c {
149+ return e.d
150+ }
151+ }
152+ return domain.CINoConclusion
153+}
154+
155+var phases = []struct {
156+ d domain.CIJobPhase
157+ p storagepb.CIJobPhase
158+}{
159+ {domain.CIPhaseWaiting, storagepb.CIJobPhase_CI_JOB_PHASE_WAITING},
160+ {domain.CIPhaseReady, storagepb.CIJobPhase_CI_JOB_PHASE_READY},
161+ {domain.CIPhaseAssigned, storagepb.CIJobPhase_CI_JOB_PHASE_ASSIGNED},
162+ {domain.CIPhaseRunning, storagepb.CIJobPhase_CI_JOB_PHASE_RUNNING},
163+ {domain.CIPhaseEnded, storagepb.CIJobPhase_CI_JOB_PHASE_ENDED},
164+}
165+
166+func encodePhase(p domain.CIJobPhase) (storagepb.CIJobPhase, error) {
167+ for _, e := range phases {
168+ if e.d == p {
169+ return e.p, nil
170+ }
171+ }
172+ return 0, fmt.Errorf("invalid job phase %q", p)
173+}
174+
175+func decodePhase(p storagepb.CIJobPhase) domain.CIJobPhase {
176+ for _, e := range phases {
177+ if e.p == p {
178+ return e.d
179+ }
180+ }
181+ return domain.CIPhaseWaiting
182+}
183+
184+var isolations = []struct {
185+ d domain.CIIsolation
186+ p storagepb.CIIsolation
187+}{
188+ {domain.CIIsolationVM, storagepb.CIIsolation_CI_ISOLATION_VM},
189+ {domain.CIIsolationContainerSysbox, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_SYSBOX},
190+ {domain.CIIsolationContainerPrivileged, storagepb.CIIsolation_CI_ISOLATION_CONTAINER_PRIVILEGED},
191+}
192+
193+func encodeIsolation(i domain.CIIsolation) storagepb.CIIsolation {
194+ for _, e := range isolations {
195+ if e.d == i {
196+ return e.p
197+ }
198+ }
199+ return storagepb.CIIsolation_CI_ISOLATION_UNSPECIFIED
200+}
201+
202+func decodeIsolation(i storagepb.CIIsolation) domain.CIIsolation {
203+ for _, e := range isolations {
204+ if e.p == i {
205+ return e.d
206+ }
207+ }
208+ return domain.CIIsolationNone
209+}
210+
211+var stepStates = []struct {
212+ d domain.CIStepState
213+ p storagepb.CIStepState
214+}{
215+ {domain.CIStepPending, storagepb.CIStepState_CI_STEP_STATE_PENDING},
216+ {domain.CIStepInProgress, storagepb.CIStepState_CI_STEP_STATE_IN_PROGRESS},
217+ {domain.CIStepCompleted, storagepb.CIStepState_CI_STEP_STATE_COMPLETED},
218+}
219+
220+func encodeStepState(s domain.CIStepState) storagepb.CIStepState {
221+ for _, e := range stepStates {
222+ if e.d == s {
223+ return e.p
224+ }
225+ }
226+ return storagepb.CIStepState_CI_STEP_STATE_UNSPECIFIED
227+}
228+
229+func decodeStepState(s storagepb.CIStepState) domain.CIStepState {
230+ for _, e := range stepStates {
231+ if e.p == s {
232+ return e.d
233+ }
234+ }
235+ return domain.CIStepPending
236+}
237+
238+var cancelReasons = []struct {
239+ d domain.CICancelReason
240+ p storagepb.CICancelReason
241+}{
242+ {domain.CICancelUser, storagepb.CICancelReason_CI_CANCEL_REASON_USER},
243+ {domain.CICancelFailFast, storagepb.CICancelReason_CI_CANCEL_REASON_FAIL_FAST},
244+ {domain.CICancelTimeout, storagepb.CICancelReason_CI_CANCEL_REASON_TIMEOUT},
245+}
246+
247+func encodeCancelReason(r domain.CICancelReason) storagepb.CICancelReason {
248+ for _, e := range cancelReasons {
249+ if e.d == r {
250+ return e.p
251+ }
252+ }
253+ return storagepb.CICancelReason_CI_CANCEL_REASON_UNSPECIFIED
254+}
255+
256+func decodeCancelReason(r storagepb.CICancelReason) domain.CICancelReason {
257+ for _, e := range cancelReasons {
258+ if e.p == r {
259+ return e.d
260+ }
261+ }
262+ return domain.CICancelNone
263+}
264+
265+func encodeRun(r domain.CIRun) ([]byte, error) {
266+ if r.Repo.IsZero() {
267+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
268+ }
269+ return mustMarshal(&storagepb.CIRun{
270+ Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
271+ WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title,
272+ Event: r.Event, Ref: r.Ref, Sha: r.Sha, Actor: r.Actor,
273+ Status: encodeStatus(r.Status), Conclusion: encodeConclusion(r.Conclusion), Error: r.Error,
274+ CreatedAt: encodeTime(r.CreatedAt), StartedAt: encodeTime(r.StartedAt), CompletedAt: encodeTime(r.CompletedAt),
275+ CancelRequested: r.CancelRequested, CancelRequestedAt: encodeTime(r.CancelRequestedAt),
276+ }), nil
277+}
278+
279+func decodeRun(item kv.Item) (domain.CIRun, error) {
280+ var pb storagepb.CIRun
281+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
282+ return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
283+ }
284+ repo, err := domain.ParseRepoName(pb.GetRepo())
285+ if err != nil {
286+ return domain.CIRun{}, fmt.Errorf("decode run: %w", err)
287+ }
288+ return domain.CIRun{
289+ Repo: repo, ID: pb.GetId(), Number: pb.GetNumber(), Attempt: pb.GetAttempt(),
290+ WorkflowPath: pb.GetWorkflowPath(), WorkflowName: pb.GetWorkflowName(), Title: pb.GetTitle(),
291+ Event: pb.GetEvent(), Ref: pb.GetRef(), Sha: pb.GetSha(), Actor: pb.GetActor(),
292+ Status: decodeStatus(pb.GetStatus()), Conclusion: decodeConclusion(pb.GetConclusion()), Error: pb.GetError(),
293+ CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
294+ CancelRequested: pb.GetCancelRequested(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
295+ Version: item.Version,
296+ }, nil
297+}
298+
299+func encodeJob(j domain.CIJob) ([]byte, error) {
300+ if j.Repo.IsZero() {
301+ return nil, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
302+ }
303+ phase, err := encodePhase(j.Phase)
304+ if err != nil {
305+ return nil, err
306+ }
307+ steps := make([]*storagepb.CIStep, 0, len(j.Steps))
308+ for _, s := range j.Steps {
309+ steps = append(steps, &storagepb.CIStep{
310+ Number: s.Number, Name: s.Name, State: encodeStepState(s.State), Conclusion: encodeConclusion(s.Conclusion),
311+ StartedAt: encodeTime(s.StartedAt), CompletedAt: encodeTime(s.CompletedAt),
312+ LogStart: s.LogStart, HasLines: s.HasLines,
313+ })
314+ }
315+ pb := &storagepb.CIJob{
316+ Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, RunAttempt: j.RunAttempt, Repo: j.Repo.String(),
317+ Key: j.Key, Name: j.Name, Phase: phase, Conclusion: encodeConclusion(j.Conclusion), Error: j.Error,
318+ Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName,
319+ Isolation: encodeIsolation(j.Isolation), Steps: steps,
320+ CreatedAt: encodeTime(j.CreatedAt), StartedAt: encodeTime(j.StartedAt), CompletedAt: encodeTime(j.CompletedAt),
321+ Index: int32(j.Index), Combo: int32(j.Combo), MatrixJson: j.MatrixJSON, ContextsJson: j.ContextsJSON, ComboTotal: int32(j.ComboTotal), //nolint:gosec // bounded by the 256 job limit
322+ TimeoutMinutes: j.TimeoutMinutes, FailFast: j.FailFast, MaxParallel: int32(j.MaxParallel), //nolint:gosec // validated at planning
323+ Outputs: j.Outputs, ReadyAt: encodeTime(j.ReadyAt), AssignedAt: encodeTime(j.AssignedAt),
324+ AckedAt: encodeTime(j.AckedAt), AssignCancels: int32(j.AssignCancels), //nolint:gosec // at most 3
325+ TokenValidUntil: encodeTime(j.TokenValidUntil), CancelReason: encodeCancelReason(j.CancelReason), CancelMessage: j.CancelMessage,
326+ CancelRequestedAt: encodeTime(j.CancelRequestedAt), Planned: j.Planned,
327+ }
328+ if j.HasToken() {
329+ pb.TokenHash = j.TokenHash[:]
330+ }
331+ return mustMarshal(pb), nil
332+}
333+
334+//hexcheck:ignore suspicious-business-logic-in-adapter maps the stored record to the domain job field by field: no rule is decided here
335+func decodeJob(item kv.Item) (domain.CIJob, error) {
336+ var pb storagepb.CIJob
337+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
338+ return domain.CIJob{}, fmt.Errorf("decode job: %w", err)
339+ }
340+ repo, err := domain.ParseRepoName(pb.GetRepo())
341+ if err != nil {
342+ return domain.CIJob{}, fmt.Errorf("decode job: %w", err)
343+ }
344+ j := domain.CIJob{
345+ ID: pb.GetId(), RunID: pb.GetRunId(), Attempt: pb.GetAttempt(), RunAttempt: pb.GetRunAttempt(), Repo: repo,
346+ Key: pb.GetKey(), Name: pb.GetName(), Phase: decodePhase(pb.GetPhase()), Conclusion: decodeConclusion(pb.GetConclusion()),
347+ Error: pb.GetError(), Labels: pb.GetLabels(), Needs: pb.GetNeeds(), RunnerID: pb.GetRunnerId(), RunnerName: pb.GetRunnerName(),
348+ Isolation: decodeIsolation(pb.GetIsolation()),
349+ CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()), CompletedAt: decodeTime(pb.GetCompletedAt()),
350+ Index: int(pb.GetIndex()), Combo: int(pb.GetCombo()), Planned: pb.GetPlanned(), MatrixJSON: pb.GetMatrixJson(), ContextsJSON: pb.GetContextsJson(),
351+ ComboTotal: int(pb.GetComboTotal()), TimeoutMinutes: pb.GetTimeoutMinutes(), FailFast: pb.GetFailFast(),
352+ MaxParallel: int(pb.GetMaxParallel()), Outputs: pb.GetOutputs(),
353+ TokenValidUntil: decodeTime(pb.GetTokenValidUntil()), ReadyAt: decodeTime(pb.GetReadyAt()),
354+ AssignedAt: decodeTime(pb.GetAssignedAt()), AckedAt: decodeTime(pb.GetAckedAt()), AssignCancels: int(pb.GetAssignCancels()),
355+ CancelMessage: pb.GetCancelMessage(), CancelRequestedAt: decodeTime(pb.GetCancelRequestedAt()),
356+ Version: item.Version,
357+ }
358+ j.CancelReason = decodeCancelReason(pb.GetCancelReason())
359+ copy(j.TokenHash[:], pb.GetTokenHash())
360+ for _, s := range pb.GetSteps() {
361+ j.Steps = append(j.Steps, domain.CIStep{
362+ Number: s.GetNumber(), Name: s.GetName(), State: decodeStepState(s.GetState()), Conclusion: decodeConclusion(s.GetConclusion()),
363+ StartedAt: decodeTime(s.GetStartedAt()), CompletedAt: decodeTime(s.GetCompletedAt()),
364+ LogStart: s.GetLogStart(), HasLines: s.GetHasLines(),
365+ })
366+ }
367+ return j, nil
368+}
369+
370+func encodeCheck(c domain.CICheck) []byte {
371+ return mustMarshal(&storagepb.CICheck{
372+ Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: encodeStatus(c.Status),
373+ Conclusion: encodeConclusion(c.Conclusion), RunId: c.RunID, JobId: c.JobID, Attempt: c.Attempt,
374+ WorkflowName: c.WorkflowName, CreatedAt: encodeTime(c.CreatedAt), StartedAt: encodeTime(c.StartedAt),
375+ CompletedAt: encodeTime(c.CompletedAt),
376+ })
377+}
378+
379+func decodeCheck(item kv.Item) (domain.CICheck, error) {
380+ var pb storagepb.CICheck
381+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
382+ return domain.CICheck{}, fmt.Errorf("decode check: %w", err)
383+ }
384+ repo, err := domain.ParseRepoName(pb.GetRepo())
385+ if err != nil {
386+ return domain.CICheck{}, fmt.Errorf("decode check: %w", err)
387+ }
388+ return domain.CICheck{
389+ ID: pb.GetId(), Repo: repo, Sha: pb.GetSha(), Name: pb.GetName(), Status: decodeStatus(pb.GetStatus()),
390+ Conclusion: decodeConclusion(pb.GetConclusion()), RunID: pb.GetRunId(), JobID: pb.GetJobId(), Attempt: pb.GetAttempt(),
391+ WorkflowName: pb.GetWorkflowName(), CreatedAt: decodeTime(pb.GetCreatedAt()), StartedAt: decodeTime(pb.GetStartedAt()),
392+ CompletedAt: decodeTime(pb.GetCompletedAt()), Version: item.Version,
393+ }, nil
394+}
395+
396+func encodeRunner(r domain.CIRunner) []byte {
397+ return mustMarshal(&storagepb.CIRunner{
398+ Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: encodeIsolation(r.Isolation), Version: r.Version,
399+ Arch: r.Arch, Os: r.OS, RegisteredAt: encodeTime(r.RegisteredAt), TokenHash: r.TokenHash[:],
400+ })
401+}
402+
403+func decodeRunner(val []byte) (domain.CIRunner, error) {
404+ var pb storagepb.CIRunner
405+ if err := proto.Unmarshal(val, &pb); err != nil {
406+ return domain.CIRunner{}, fmt.Errorf("decode runner: %w", err)
407+ }
408+ r := domain.CIRunner{
409+ ID: pb.GetId(), Name: pb.GetName(), Labels: pb.GetLabels(), Isolation: decodeIsolation(pb.GetIsolation()),
410+ Version: pb.GetVersion(), Arch: pb.GetArch(), OS: pb.GetOs(), RegisteredAt: decodeTime(pb.GetRegisteredAt()),
411+ }
412+ copy(r.TokenHash[:], pb.GetTokenHash())
413+ return r, nil
414+}
415+
416+// --- writes ---
417+
418+// stage adds the preconditions and writes of one record to the batch.
419+func stage(b *kv.Batch, key, val []byte, version uint64) {
420+ if version == 0 {
421+ b.RequireAbsent(key)
422+ } else {
423+ b.RequireVersion(key, version)
424+ }
425+ b.Put(key, val)
426+}
427+
428+// runIndexes stages the open-run index of the run.
429+func runIndexes(b *kv.Batch, r domain.CIRun) {
430+ if r.Status == domain.CICompleted {
431+ b.Delete(openKey(r.ID))
432+ return
433+ }
434+ b.Put(openKey(r.ID), []byte(r.Repo.String()))
435+}
436+
437+// jobIndexes stages the ready, active and token indexes of the job. old is
438+// the job as stored before, if any: its token index goes when the token
439+// changed.
440+func (c *CI) jobIndexes(b *kv.Batch, j domain.CIJob, old *domain.CIJob) {
441+ ref := jobRef(j.RunID, j.RunAttempt, j.ID)
442+ if j.Phase == domain.CIPhaseReady {
443+ b.Put(readyKey(j.ReadyAt, ref), nil)
444+ } else {
445+ b.Delete(readyKey(j.ReadyAt, ref))
446+ }
447+ if old != nil && old.Phase == domain.CIPhaseReady && (j.Phase != domain.CIPhaseReady || !old.ReadyAt.Equal(j.ReadyAt)) {
448+ b.Delete(readyKey(old.ReadyAt, ref))
449+ }
450+ if j.Active() {
451+ b.Put(activeKey(ref), nil)
452+ } else {
453+ b.Delete(activeKey(ref))
454+ }
455+ if old != nil && old.HasToken() && old.TokenHash != j.TokenHash {
456+ b.Delete(jobTokenKey(old.TokenHash))
457+ }
458+ if j.HasToken() {
459+ b.Put(jobTokenKey(j.TokenHash), []byte(ref))
460+ }
461+}
462+
463+// guardRepo is guardRepoKey on the database of the CI store, for the writes that
464+// create keys under a repository.
465+func (c *CI) guardRepo(ctx context.Context, b *kv.Batch, repo domain.RepoName) error {
466+ return guardRepoKey(ctx, c.db, b, repo)
467+}
468+
469+// stageRecords stages the writes of w. guarded collects the repositories that
470+// need guarding.
471+func (c *CI) stageRecords(ctx context.Context, b *kv.Batch, w ports.CIWrite) (guarded map[domain.RepoName]bool, err error) {
472+ guarded = map[domain.RepoName]bool{}
473+ for _, r := range w.Runs {
474+ val, err := encodeRun(r)
475+ if err != nil {
476+ return nil, err
477+ }
478+ stage(b, runKey(r.Repo, r.ID), val, r.Version)
479+ runIndexes(b, r)
480+ if r.Version == 0 {
481+ guarded[r.Repo] = true
482+ }
483+ }
484+ for _, j := range w.Jobs {
485+ val, err := encodeJob(j)
486+ if err != nil {
487+ return nil, err
488+ }
489+ key := jobKey(j.RunID, j.RunAttempt, j.ID)
490+ var old *domain.CIJob
491+ if j.Version != 0 {
492+ item, ok := c.db.Get(ctx, key)
493+ if !ok {
494+ return nil, domain.ErrConflict
495+ }
496+ if o, err := decodeJob(item); err == nil {
497+ old = &o
498+ }
499+ }
500+ stage(b, key, val, j.Version)
501+ c.jobIndexes(b, j, old)
502+ if j.Version == 0 {
503+ guarded[j.Repo] = true
504+ }
505+ }
506+ // Checks are derived from the jobs, written in the same batch and under
507+ // the lock of their run: they are upserted without a precondition.
508+ for _, ck := range w.Checks {
509+ b.Put(checkKey(ck.Repo, ck.Sha, ck.ID), encodeCheck(ck))
510+ }
511+ return guarded, nil
512+}
513+
514+// Apply writes the records in one batch.
515+//
516+//hexcheck:ignore suspicious-business-logic-in-adapter stages records and their derived indexes under version preconditions: a storage concern, no policy
517+func (c *CI) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) {
518+ var b kv.Batch
519+ guarded, err := c.stageRecords(ctx, &b, w)
520+ if err != nil {
521+ return 0, err
522+ }
523+ for repo := range guarded {
524+ if err := c.guardRepo(ctx, &b, repo); err != nil {
525+ return 0, err
526+ }
527+ }
528+ v, err := c.db.Apply(ctx, &b)
529+ if errors.Is(err, kv.ErrConflict) {
530+ return 0, domain.ErrConflict
531+ }
532+ if err != nil {
533+ return 0, fmt.Errorf("write CI records: %w", err)
534+ }
535+ return v, nil
536+}
537+
538+func (c *CI) readSeq(ctx context.Context, key []byte) (last int64, item kv.Item, ok bool, err error) {
539+ item, ok = c.db.Get(ctx, key)
540+ if !ok {
541+ return 0, kv.Item{}, false, nil
542+ }
543+ var seq storagepb.Sequence
544+ if err := proto.Unmarshal(item.Value, &seq); err != nil {
545+ return 0, kv.Item{}, false, fmt.Errorf("decode sequence %s: %w", key, err)
546+ }
547+ return seq.GetLast(), item, true, nil
548+}
549+
550+func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) {
551+ if ok {
552+ b.RequireVersion(key, item.Version)
553+ } else {
554+ b.RequireAbsent(key)
555+ }
556+ b.Put(key, mustMarshal(&storagepb.Sequence{Last: last}))
557+}
558+
559+const createRunTries = 20
560+
561+// CreateRun takes the next run id and number and stores the draft.
562+//
563+//hexcheck:ignore suspicious-business-logic-in-adapter takes the next numbers under version preconditions: a storage concern, no policy
564+func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
565+ for range createRunTries {
566+ lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))
567+ if err != nil {
568+ return ports.CIRunDraft{}, err
569+ }
570+ numKey := runNumKey(repo, workflowPath)
571+ lastNum, numItem, numOK, err := c.readSeq(ctx, numKey)
572+ if err != nil {
573+ return ports.CIRunDraft{}, err
574+ }
575+ draft := build(lastID+1, lastNum+1)
576+ var b kv.Batch
577+ stageSeq(&b, []byte(runSeqKey), idItem, idOK, lastID+1)
578+ stageSeq(&b, numKey, numItem, numOK, lastNum+1)
579+ w := ports.CIWrite{Runs: []domain.CIRun{draft.Run}, Jobs: draft.Jobs, Checks: draft.Checks}
580+ guarded, err := c.stageRecords(ctx, &b, w)
581+ if err != nil {
582+ return ports.CIRunDraft{}, err
583+ }
584+ src := mustMarshal(&storagepb.CIRunSource{
585+ RunId: draft.Run.ID, Repo: repo.String(), WorkflowYaml: draft.Source.WorkflowYAML, GithubJson: draft.Source.GithubJSON,
586+ })
587+ b.RequireAbsent(runSrcKey(draft.Run.ID))
588+ b.Put(runSrcKey(draft.Run.ID), src)
589+ for r := range guarded {
590+ if err := c.guardRepo(ctx, &b, r); err != nil {
591+ return ports.CIRunDraft{}, err
592+ }
593+ }
594+ v, err := c.db.Apply(ctx, &b)
595+ if errors.Is(err, kv.ErrConflict) {
596+ continue
597+ }
598+ if err != nil {
599+ return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, err)
600+ }
601+ draft.Run.Version = v
602+ for i := range draft.Jobs {
603+ draft.Jobs[i].Version = v
604+ }
605+ for i := range draft.Checks {
606+ draft.Checks[i].Version = v
607+ }
608+ return draft, nil
609+ }
610+ return ports.CIRunDraft{}, fmt.Errorf("create run of %q: %w", repo, domain.ErrConflict)
611+}
612+
613+// --- reads ---
614+
615+// GetRun reads run/<repo>/<id>.
616+func (c *CI) GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) {
617+ item, ok := c.db.Get(ctx, runKey(repo, id))
618+ if !ok || id < 1 {
619+ return domain.CIRun{}, domain.ErrNotFound
620+ }
621+ return decodeRun(item)
622+}
623+
624+// GetRunSource reads runsrc/<id>.
625+func (c *CI) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) {
626+ item, ok := c.db.Get(ctx, runSrcKey(runID))
627+ if !ok || runID < 1 {
628+ return domain.CIRunSource{}, domain.ErrNotFound
629+ }
630+ var pb storagepb.CIRunSource
631+ if err := proto.Unmarshal(item.Value, &pb); err != nil {
632+ return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
633+ }
634+ repo, err := domain.ParseRepoName(pb.GetRepo())
635+ if err != nil {
636+ return domain.CIRunSource{}, fmt.Errorf("decode run source: %w", err)
637+ }
638+ return domain.CIRunSource{RunID: pb.GetRunId(), Repo: repo, WorkflowYAML: pb.GetWorkflowYaml(), GithubJSON: pb.GetGithubJson()}, nil
639+}
640+
641+// ListRuns returns the runs of the repository, newest first.
642+//
643+//hexcheck:ignore suspicious-business-logic-in-adapter port method name only: reads a key range and reverses it
644+func (c *CI) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) {
645+ start := []byte(runsPrefix(repo))
646+ end := []byte(runPrefix + repo.String() + "0") // '/' + 1
647+ if before > 0 {
648+ end = runKey(repo, before)
649+ }
650+ // Collect then reverse: the snapshot iterates in key order only.
651+ var all []domain.CIRun
652+ for key, item := range c.db.Snapshot(ctx).Range(ctx, start, end) {
653+ r, err := decodeRun(item)
654+ if err != nil {
655+ return nil, fmt.Errorf("decode %s: %w", key, err)
656+ }
657+ all = append(all, r)
658+ }
659+ slices.Reverse(all)
660+ if limit > 0 && len(all) > limit {
661+ all = all[:limit]
662+ }
663+ return all, nil
664+}
665+
666+// RunIDs returns the ids of the runs of the repository.
667+func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) {
668+ var ids []int64
669+ prefix := runsPrefix(repo)
670+ for key := range c.db.Snapshot(ctx).Prefix(ctx, []byte(prefix)) {
671+ id, err := strconv.ParseInt(strings.TrimPrefix(string(key), prefix), 10, 64)
672+ if err != nil {
673+ return nil, fmt.Errorf("decode %s: %w", key, err)
674+ }
675+ ids = append(ids, id)
676+ }
677+ return ids, nil
678+}
679+
680+// ListOpenRuns returns the runs that have not completed.
681+func (c *CI) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) {
682+ snap := c.db.Snapshot(ctx)
683+ var out []domain.CIRun
684+ for key, item := range snap.Prefix(ctx, []byte(openPrefix)) {
685+ id, err := strconv.ParseInt(strings.TrimPrefix(string(key), openPrefix), 10, 64)
686+ if err != nil {
687+ return nil, fmt.Errorf("decode %s: %w", key, err)
688+ }
689+ repo, err := domain.ParseRepoName(string(item.Value))
690+ if err != nil {
691+ return nil, err
692+ }
693+ it, ok := snap.Get(ctx, runKey(repo, id))
694+ if !ok {
695+ continue
696+ }
697+ r, err := decodeRun(it)
698+ if err != nil {
699+ return nil, err
700+ }
701+ out = append(out, r)
702+ }
703+ return out, nil
704+}
705+
706+// GetJob reads one job record.
707+func (c *CI) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
708+ item, ok := c.db.Get(ctx, jobKey(runID, runAttempt, jobID))
709+ if !ok {
710+ return domain.CIJob{}, domain.ErrNotFound
711+ }
712+ return decodeJob(item)
713+}
714+
715+// ListJobs returns the jobs of a run at an attempt, ordered by index then
716+// combination.
717+func (c *CI) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) {
718+ var out []domain.CIJob
719+ for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(jobAttemptPrefix(runID, runAttempt))) {
720+ j, err := decodeJob(item)
721+ if err != nil {
722+ return nil, fmt.Errorf("decode %s: %w", key, err)
723+ }
724+ out = append(out, j)
725+ }
726+ slices.SortFunc(out, func(a, b domain.CIJob) int {
727+ if a.Index != b.Index {
728+ return a.Index - b.Index
729+ }
730+ return a.Combo - b.Combo
731+ })
732+ return out, nil
733+}
734+
735+func (c *CI) jobByRef(snap *kv.Snapshot, ctx context.Context, ref string) (domain.CIJob, error) {
736+ item, ok := snap.Get(ctx, []byte(ref))
737+ if !ok {
738+ return domain.CIJob{}, domain.ErrNotFound
739+ }
740+ return decodeJob(item)
741+}
742+
743+// FindJobByToken resolves the token index, then reads the job.
744+func (c *CI) FindJobByToken(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error) {
745+ snap := c.db.Snapshot(ctx)
746+ idx, ok := snap.Get(ctx, jobTokenKey(hash))
747+ if !ok {
748+ return domain.CIJob{}, domain.ErrNotFound
749+ }
750+ j, err := c.jobByRef(snap, ctx, string(idx.Value))
751+ if err != nil {
752+ return domain.CIJob{}, err
753+ }
754+ if j.TokenHash != hash || (!j.TokenValidUntil.IsZero() && !now.Before(j.TokenValidUntil)) {
755+ return domain.CIJob{}, domain.ErrNotFound
756+ }
757+ return j, nil
758+}
759+
760+// ListReadyJobs returns the jobs waiting for a runner, oldest first.
761+func (c *CI) ListReadyJobs(ctx context.Context, limit int) ([]domain.CIJob, error) {
762+ snap := c.db.Snapshot(ctx)
763+ var out []domain.CIJob
764+ for key := range snap.Prefix(ctx, []byte(readyPrefix)) {
765+ ref := string(key)[len(readyPrefix)+len(fmt.Sprintf(idFormat, 0))+1:]
766+ j, err := c.jobByRef(snap, ctx, ref)
767+ if errors.Is(err, domain.ErrNotFound) {
768+ continue
769+ }
770+ if err != nil {
771+ return nil, err
772+ }
773+ out = append(out, j)
774+ if limit > 0 && len(out) >= limit {
775+ break
776+ }
777+ }
778+ return out, nil
779+}
780+
781+// ListActiveJobs returns the jobs given to a runner and not over.
782+func (c *CI) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) {
783+ snap := c.db.Snapshot(ctx)
784+ var out []domain.CIJob
785+ for key := range snap.Prefix(ctx, []byte(activePrefix)) {
786+ j, err := c.jobByRef(snap, ctx, strings.TrimPrefix(string(key), activePrefix))
787+ if errors.Is(err, domain.ErrNotFound) {
788+ continue
789+ }
790+ if err != nil {
791+ return nil, err
792+ }
793+ out = append(out, j)
794+ }
795+ return out, nil
796+}
797+
798+// ListChecks returns the checks of a commit ordered by creation time then
799+// name.
800+func (c *CI) ListChecks(ctx context.Context, repo domain.RepoName, sha string, limit int) ([]domain.CICheck, bool, error) {
801+ var out []domain.CICheck
802+ for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(checksPrefix(repo, sha))) {
803+ ck, err := decodeCheck(item)
804+ if err != nil {
805+ return nil, false, fmt.Errorf("decode %s: %w", key, err)
806+ }
807+ out = append(out, ck)
808+ }
809+ slices.SortStableFunc(out, func(a, b domain.CICheck) int {
810+ if d := a.CreatedAt.Compare(b.CreatedAt); d != 0 {
811+ return d
812+ }
813+ return strings.Compare(a.Name, b.Name)
814+ })
815+ more := limit > 0 && len(out) > limit
816+ if more {
817+ out = out[:limit]
818+ }
819+ return out, more, nil
820+}
821+
822+// --- retention and deletion ---
823+
824+// runKeys lists every key of a run except its record: source, jobs, indexes.
825+func (c *CI) runKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName, runID int64) [][]byte {
826+ keys := [][]byte{runKey(repo, runID), runSrcKey(runID), openKey(runID)}
827+ for key, item := range snap.Prefix(ctx, []byte(jobsPrefix(runID))) {
828+ keys = append(keys, slices.Clone(key))
829+ if j, err := decodeJob(item); err == nil {
830+ ref := jobRef(j.RunID, j.RunAttempt, j.ID)
831+ keys = append(keys, readyKey(j.ReadyAt, ref), activeKey(ref))
832+ if j.HasToken() {
833+ keys = append(keys, jobTokenKey(j.TokenHash))
834+ }
835+ }
836+ }
837+ return keys
838+}
839+
840+// ciKeys lists every key of the snapshot that belongs to the repository's CI
841+// runs, checks and numbers.
842+func ciKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoName) [][]byte {
843+ c := &CI{}
844+ var keys [][]byte
845+ for key, item := range snap.Prefix(ctx, []byte(runsPrefix(repo))) {
846+ if r, err := decodeRun(item); err == nil {
847+ keys = append(keys, c.runKeys(ctx, snap, repo, r.ID)...)
848+ }
849+ keys = append(keys, slices.Clone(key))
850+ }
851+ for _, p := range []string{checkPrefix + repo.String() + "/", runNumPrefix + repo.String() + "/"} {
852+ for k := range snap.Prefix(ctx, []byte(p)) {
853+ keys = append(keys, slices.Clone(k))
854+ }
855+ }
856+ return keys
857+}
858+
859+const sweepChunk = 500
860+
861+// Sweep removes the runs completed before the time.
862+//
863+//hexcheck:ignore suspicious-business-logic-in-adapter chunked delete of expired records: a storage concern, the retention is decided by the caller
864+func (c *CI) Sweep(ctx context.Context, before time.Time) ([]int64, error) {
865+ snap := c.db.Snapshot(ctx)
866+ var ids []int64
867+ var keys [][]byte
868+ for _, rp := range []string{runPrefix} {
869+ for _, item := range snap.Prefix(ctx, []byte(rp)) {
870+ r, err := decodeRun(item)
871+ if err != nil {
872+ return nil, err
873+ }
874+ if r.Status != domain.CICompleted || r.CompletedAt.IsZero() || !r.CompletedAt.Before(before) {
875+ continue
876+ }
877+ ids = append(ids, r.ID)
878+ keys = append(keys, c.runKeys(ctx, snap, r.Repo, r.ID)...)
879+ for ck := range snap.Prefix(ctx, []byte(checkPrefix+r.Repo.String()+"/"+r.Sha+"/")) {
880+ // A check belongs to the run only while it is the latest one
881+ // for its job: leave it when another run replaced it.
882+ if it, ok := snap.Get(ctx, ck); ok {
883+ if chk, err := decodeCheck(it); err == nil && chk.RunID == r.ID {
884+ keys = append(keys, slices.Clone(ck))
885+ }
886+ }
887+ }
888+ }
889+ }
890+ for len(keys) > 0 {
891+ n := min(sweepChunk, len(keys))
892+ var b kv.Batch
893+ for _, k := range keys[:n] {
894+ b.Delete(k)
895+ }
896+ if _, err := c.db.Apply(ctx, &b); err != nil {
897+ return nil, fmt.Errorf("sweep CI runs: %w", err)
898+ }
899+ keys = keys[n:]
900+ }
901+ return ids, nil
902+}
903+
904+// --- runners ---
905+
906+func runnerKey(id string) []byte { return []byte(runnerPrefix + id) }
907+func runnerNameKey(n string) []byte { return []byte(runnerName + strings.ToLower(n)) }
908+func runnerTokenKey(h domain.TokenHash) []byte { return []byte(runnerTokPref + h.String()) }
909+
910+// CreateRunner stores the runner, its name index and its token index.
911+//
912+//hexcheck:ignore suspicious-business-logic-in-adapter replaces a runner of the same name in one batch: a storage concern, the rule is decided by the caller
913+func (c *CI) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) {
914+ for range createRunTries {
915+ var b kv.Batch
916+ var removed []domain.CIRunner
917+ if idx, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok {
918+ if !replace {
919+ return nil, domain.ErrAlreadyExists
920+ }
921+ old, err := c.GetRunner(ctx, string(idx.Value))
922+ if err != nil && !errors.Is(err, domain.ErrNotFound) {
923+ return nil, err
924+ }
925+ b.RequireVersion(runnerNameKey(r.Name), idx.Version)
926+ if err == nil {
927+ removed = append(removed, old)
928+ b.Delete(runnerKey(old.ID))
929+ b.Delete(runnerTokenKey(old.TokenHash))
930+ }
931+ } else {
932+ b.RequireAbsent(runnerNameKey(r.Name))
933+ }
934+ b.RequireAbsent(runnerKey(r.ID))
935+ b.RequireAbsent(runnerTokenKey(r.TokenHash))
936+ b.Put(runnerKey(r.ID), encodeRunner(r))
937+ b.Put(runnerNameKey(r.Name), []byte(r.ID))
938+ b.Put(runnerTokenKey(r.TokenHash), []byte(r.ID))
939+ _, err := c.db.Apply(ctx, &b)
940+ if errors.Is(err, kv.ErrConflict) {
941+ if !replace {
942+ if _, ok := c.db.Get(ctx, runnerNameKey(r.Name)); ok {
943+ return nil, domain.ErrAlreadyExists
944+ }
945+ }
946+ continue
947+ }
948+ if err != nil {
949+ return nil, fmt.Errorf("create runner %q: %w", r.Name, err)
950+ }
951+ return removed, nil
952+ }
953+ return nil, fmt.Errorf("create runner %q: %w", r.Name, domain.ErrConflict)
954+}
955+
956+// GetRunner reads runner/<id>.
957+func (c *CI) GetRunner(ctx context.Context, id string) (domain.CIRunner, error) {
958+ item, ok := c.db.Get(ctx, runnerKey(id))
959+ if !ok || id == "" || strings.Contains(id, "/") {
960+ return domain.CIRunner{}, domain.ErrNotFound
961+ }
962+ return decodeRunner(item.Value)
963+}
964+
965+// FindRunnerByToken resolves the token index, then reads the runner.
966+func (c *CI) FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) {
967+ snap := c.db.Snapshot(ctx)
968+ idx, ok := snap.Get(ctx, runnerTokenKey(hash))
969+ if !ok {
970+ return domain.CIRunner{}, domain.ErrNotFound
971+ }
972+ item, ok := snap.Get(ctx, runnerKey(string(idx.Value)))
973+ if !ok {
974+ return domain.CIRunner{}, domain.ErrNotFound
975+ }
976+ r, err := decodeRunner(item.Value)
977+ if err != nil {
978+ return domain.CIRunner{}, err
979+ }
980+ if r.TokenHash != hash {
981+ return domain.CIRunner{}, fmt.Errorf("runner %q: token index does not match the stored hash", r.ID)
982+ }
983+ return r, nil
984+}
985+
986+// ListRunners returns every runner ordered by name.
987+func (c *CI) ListRunners(ctx context.Context) ([]domain.CIRunner, error) {
988+ var out []domain.CIRunner
989+ for key, item := range c.db.Snapshot(ctx).Prefix(ctx, []byte(runnerPrefix)) {
990+ r, err := decodeRunner(item.Value)
991+ if err != nil {
992+ return nil, fmt.Errorf("decode %s: %w", key, err)
993+ }
994+ out = append(out, r)
995+ }
996+ slices.SortFunc(out, func(a, b domain.CIRunner) int {
997+ return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
998+ })
999+ return out, nil
1000+}
1001+
1002+// RemoveRunner removes the runner, its name and its token.
1003+func (c *CI) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) {
1004+ for range createRunTries {
1005+ item, ok := c.db.Get(ctx, runnerKey(id))
1006+ if !ok || id == "" || strings.Contains(id, "/") {
1007+ return domain.CIRunner{}, domain.ErrNotFound
1008+ }
1009+ r, err := decodeRunner(item.Value)
1010+ if err != nil {
1011+ return domain.CIRunner{}, err
1012+ }
1013+ var b kv.Batch
1014+ b.RequireVersion(runnerKey(id), item.Version)
1015+ b.Delete(runnerKey(id))
1016+ b.Delete(runnerNameKey(r.Name))
1017+ b.Delete(runnerTokenKey(r.TokenHash))
1018+ _, err = c.db.Apply(ctx, &b)
1019+ if errors.Is(err, kv.ErrConflict) {
1020+ continue
1021+ }
1022+ if err != nil {
1023+ return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, err)
1024+ }
1025+ return r, nil
1026+ }
1027+ return domain.CIRunner{}, fmt.Errorf("remove runner %q: %w", id, domain.ErrConflict)
1028+}
A internal/adapters/kvstore/ci_test.go +375 -0
@@ -0,0 +1,375 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package kvstore_test
4+
5+import (
6+ "context"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+
14+ "bnema.dev/ermite/internal/adapters/kvstore"
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// ciRepo opens a store holding repository "proj".
20+func ciRepo(t *testing.T) (*kvstore.Store, *kvstore.CI, domain.RepoName) {
21+ t.Helper()
22+ s := openStore(t, t.TempDir())
23+ n := name(t, "proj")
24+ require.NoError(t, s.Create(context.Background(), domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
25+ return s, s.CI(), n
26+}
27+
28+func draftOf(repo domain.RepoName, workflow string, jobs int) func(id, number int64) ports.CIRunDraft {
29+ return func(id, number int64) ports.CIRunDraft {
30+ d := ports.CIRunDraft{
31+ Run: domain.CIRun{
32+ Repo: repo, ID: id, Number: number, Attempt: 1, WorkflowPath: workflow, WorkflowName: "ci", Event: "push",
33+ Ref: "refs/heads/main", Sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Status: domain.CIQueued, CreatedAt: created,
34+ },
35+ Source: domain.CIRunSource{WorkflowYAML: "on: push", GithubJSON: "{}"},
36+ }
37+ for i := range jobs {
38+ j := domain.CIJob{
39+ ID: domain.NewJobID(id, i, 0), RunID: id, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "build", Name: "build",
40+ Phase: domain.CIPhaseReady, ReadyAt: created.Add(time.Duration(i) * time.Second), CreatedAt: created,
41+ Labels: []string{"self-hosted"}, Index: i, TimeoutMinutes: 360, Planned: true,
42+ }
43+ d.Jobs = append(d.Jobs, j)
44+ d.Checks = append(d.Checks, domain.CICheck{
45+ ID: j.ID, Repo: repo, Sha: d.Run.Sha, Name: j.Name, Status: domain.CIQueued, RunID: id, JobID: j.ID, Attempt: 1,
46+ WorkflowName: "ci", CreatedAt: created,
47+ })
48+ }
49+ return d
50+ }
51+}
52+
53+func TestCICreateRunTakesIncreasingIdsAndPerWorkflowNumbers(t *testing.T) {
54+ ctx := context.Background()
55+ _, ci, n := ciRepo(t)
56+
57+ a, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", draftOf(n, ".github/workflows/ci.yml", 1))
58+ require.NoError(t, err)
59+ b, err := ci.CreateRun(ctx, n, ".github/workflows/ci.yml", draftOf(n, ".github/workflows/ci.yml", 1))
60+ require.NoError(t, err)
61+ c, err := ci.CreateRun(ctx, n, ".github/workflows/lint.yml", draftOf(n, ".github/workflows/lint.yml", 0))
62+ require.NoError(t, err)
63+
64+ assert.Equal(t, []int64{1, 2, 3}, []int64{a.Run.ID, b.Run.ID, c.Run.ID})
65+ assert.Equal(t, []int64{1, 2, 1}, []int64{a.Run.Number, b.Run.Number, c.Run.Number})
66+ assert.NotZero(t, a.Run.Version)
67+}
68+
69+func TestCIConcurrentCreateRunNeverSharesAnId(t *testing.T) {
70+ ctx := context.Background()
71+ _, ci, n := ciRepo(t)
72+ var mu sync.Mutex
73+ ids := map[int64]bool{}
74+ var wg sync.WaitGroup
75+ for range 12 {
76+ wg.Add(1)
77+ go func() {
78+ defer wg.Done()
79+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
80+ if assert.NoError(t, err) {
81+ mu.Lock()
82+ defer mu.Unlock()
83+ assert.False(t, ids[d.Run.ID], "id %d handed out twice", d.Run.ID)
84+ ids[d.Run.ID] = true
85+ }
86+ }()
87+ }
88+ wg.Wait()
89+ assert.Len(t, ids, 12)
90+}
91+
92+func TestCICreateRunFailsOnAMissingRepository(t *testing.T) {
93+ _, ci, _ := ciRepo(t)
94+ ghost := name(t, "ghost")
95+ _, err := ci.CreateRun(context.Background(), ghost, "w.yml", draftOf(ghost, "w.yml", 1))
96+ assert.ErrorIs(t, err, domain.ErrNotFound)
97+}
98+
99+func TestCIJobIndexesFollowThePhase(t *testing.T) {
100+ ctx := context.Background()
101+ _, ci, n := ciRepo(t)
102+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2))
103+ require.NoError(t, err)
104+
105+ ready, err := ci.ListReadyJobs(ctx, 10)
106+ require.NoError(t, err)
107+ require.Len(t, ready, 2)
108+ assert.Equal(t, d.Jobs[0].ID, ready[0].ID, "oldest first")
109+
110+ open, err := ci.ListOpenRuns(ctx)
111+ require.NoError(t, err)
112+ assert.Len(t, open, 1)
113+
114+ // Assign the first job: it leaves the queue, enters the active set and its
115+ // token becomes findable.
116+ j := ready[0]
117+ hash := domain.HashTokenSecret("ermitejob_x")
118+ j.Phase, j.TokenHash, j.AssignedAt = domain.CIPhaseAssigned, hash, created.Add(time.Minute)
119+ j.TokenValidUntil = created.Add(48 * time.Hour)
120+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}})
121+ require.NoError(t, err)
122+
123+ ready, _ = ci.ListReadyJobs(ctx, 10)
124+ assert.Len(t, ready, 1)
125+ active, err := ci.ListActiveJobs(ctx)
126+ require.NoError(t, err)
127+ require.Len(t, active, 1)
128+ assert.Equal(t, j.ID, active[0].ID)
129+ found, err := ci.FindJobByToken(ctx, hash, created.Add(2*time.Minute))
130+ require.NoError(t, err)
131+ assert.Equal(t, j.ID, found.ID)
132+ _, err = ci.FindJobByToken(ctx, hash, created.Add(72*time.Hour))
133+ assert.ErrorIs(t, err, domain.ErrNotFound, "an expired token is not found")
134+
135+ // A new token replaces the old one.
136+ j2, err := ci.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)
137+ require.NoError(t, err)
138+ j2.TokenHash = domain.HashTokenSecret("ermitejob_y")
139+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j2}})
140+ require.NoError(t, err)
141+ _, err = ci.FindJobByToken(ctx, hash, created.Add(2*time.Minute))
142+ assert.ErrorIs(t, err, domain.ErrNotFound, "the replaced token is gone")
143+
144+ // End it.
145+ j3, _ := ci.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)
146+ j3.Phase, j3.Conclusion = domain.CIPhaseEnded, domain.CISuccess
147+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j3}})
148+ require.NoError(t, err)
149+ active, _ = ci.ListActiveJobs(ctx)
150+ assert.Empty(t, active)
151+}
152+
153+func TestCIApplyChecksVersions(t *testing.T) {
154+ ctx := context.Background()
155+ _, ci, n := ciRepo(t)
156+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
157+ require.NoError(t, err)
158+
159+ a, err := ci.GetRun(ctx, n, d.Run.ID)
160+ require.NoError(t, err)
161+ b := a
162+ a.Status = domain.CIInProgress
163+ _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{a}})
164+ require.NoError(t, err)
165+ b.Status = domain.CICompleted
166+ _, err = ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{b}})
167+ assert.ErrorIs(t, err, domain.ErrConflict, "a write on a stale copy loses")
168+}
169+
170+func TestCIChecksAreOrderedAndLimited(t *testing.T) {
171+ ctx := context.Background()
172+ _, ci, n := ciRepo(t)
173+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 3))
174+ require.NoError(t, err)
175+
176+ checks, more, err := ci.ListChecks(ctx, n, d.Run.Sha, 10)
177+ require.NoError(t, err)
178+ assert.Len(t, checks, 3)
179+ assert.False(t, more)
180+ checks, more, err = ci.ListChecks(ctx, n, d.Run.Sha, 2)
181+ require.NoError(t, err)
182+ assert.Len(t, checks, 2)
183+ assert.True(t, more)
184+ none, _, err := ci.ListChecks(ctx, n, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", 10)
185+ require.NoError(t, err)
186+ assert.Empty(t, none)
187+}
188+
189+func TestCIListRunsNewestFirstAndBounded(t *testing.T) {
190+ ctx := context.Background()
191+ s, ci, n := ciRepo(t)
192+ other := name(t, "other")
193+ require.NoError(t, s.Create(ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPublic, CreatedAt: created}))
194+ _, err := ci.CreateRun(ctx, other, "w.yml", draftOf(other, "w.yml", 0))
195+ require.NoError(t, err)
196+ for range 5 {
197+ _, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 0))
198+ require.NoError(t, err)
199+ }
200+ runs, err := ci.ListRuns(ctx, n, 0, 3)
201+ require.NoError(t, err)
202+ require.Len(t, runs, 3)
203+ assert.Equal(t, []int64{6, 5, 4}, []int64{runs[0].ID, runs[1].ID, runs[2].ID})
204+ runs, err = ci.ListRuns(ctx, n, 4, 10)
205+ require.NoError(t, err)
206+ assert.Equal(t, []int64{3, 2}, []int64{runs[0].ID, runs[1].ID})
207+ ids, err := ci.RunIDs(ctx, n)
208+ require.NoError(t, err)
209+ assert.Len(t, ids, 5)
210+}
211+
212+func TestCIRunnersCreateReplaceFindRemove(t *testing.T) {
213+ ctx := context.Background()
214+ _, ci, _ := ciRepo(t)
215+ h1, h2 := domain.HashTokenSecret("ermiterun_1"), domain.HashTokenSecret("ermiterun_2")
216+ r1 := domain.CIRunner{ID: "r1", Name: "Alpha", Labels: []string{"linux"}, Isolation: domain.CIIsolationVM, TokenHash: h1, RegisteredAt: created}
217+ _, err := ci.CreateRunner(ctx, r1, false)
218+ require.NoError(t, err)
219+
220+ r2 := domain.CIRunner{ID: "r2", Name: "alpha", TokenHash: h2, RegisteredAt: created}
221+ _, err = ci.CreateRunner(ctx, r2, false)
222+ assert.ErrorIs(t, err, domain.ErrAlreadyExists, "names are case-insensitive")
223+
224+ removed, err := ci.CreateRunner(ctx, r2, true)
225+ require.NoError(t, err)
226+ require.Len(t, removed, 1)
227+ assert.Equal(t, "r1", removed[0].ID)
228+ _, err = ci.FindRunnerByToken(ctx, h1)
229+ assert.ErrorIs(t, err, domain.ErrNotFound, "the replaced runner's token stops working")
230+ got, err := ci.FindRunnerByToken(ctx, h2)
231+ require.NoError(t, err)
232+ assert.Equal(t, "r2", got.ID)
233+
234+ list, err := ci.ListRunners(ctx)
235+ require.NoError(t, err)
236+ assert.Len(t, list, 1)
237+
238+ _, err = ci.RemoveRunner(ctx, "r2")
239+ require.NoError(t, err)
240+ _, err = ci.RemoveRunner(ctx, "r2")
241+ assert.ErrorIs(t, err, domain.ErrNotFound)
242+ _, err = ci.FindRunnerByToken(ctx, h2)
243+ assert.ErrorIs(t, err, domain.ErrNotFound)
244+ // The name is free again.
245+ _, err = ci.CreateRunner(ctx, domain.CIRunner{ID: "r3", Name: "alpha", TokenHash: domain.HashTokenSecret("ermiterun_3")}, false)
246+ assert.NoError(t, err)
247+}
248+
249+func TestCISweepRemovesOldCompletedRunsOnly(t *testing.T) {
250+ ctx := context.Background()
251+ _, ci, n := ciRepo(t)
252+ oldRun, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
253+ require.NoError(t, err)
254+ newRun, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
255+ require.NoError(t, err)
256+ openRun, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
257+ require.NoError(t, err)
258+
259+ for _, tc := range []struct {
260+ d ports.CIRunDraft
261+ at time.Time
262+ }{{oldRun, created}, {newRun, created.Add(100 * 24 * time.Hour)}} {
263+ run, _ := ci.GetRun(ctx, n, tc.d.Run.ID)
264+ run.Status, run.Conclusion, run.CompletedAt = domain.CICompleted, domain.CISuccess, tc.at
265+ job, _ := ci.GetJob(ctx, run.ID, 1, tc.d.Jobs[0].ID)
266+ job.Phase, job.Conclusion = domain.CIPhaseEnded, domain.CISuccess
267+ _, err := ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{run}, Jobs: []domain.CIJob{job}})
268+ require.NoError(t, err)
269+ }
270+
271+ ids, err := ci.Sweep(ctx, created.Add(90*24*time.Hour))
272+ require.NoError(t, err)
273+ assert.Equal(t, []int64{oldRun.Run.ID}, ids)
274+
275+ _, err = ci.GetRun(ctx, n, oldRun.Run.ID)
276+ assert.ErrorIs(t, err, domain.ErrNotFound)
277+ _, err = ci.GetRunSource(ctx, oldRun.Run.ID)
278+ assert.ErrorIs(t, err, domain.ErrNotFound)
279+ jobs, _ := ci.ListJobs(ctx, oldRun.Run.ID, 1)
280+ assert.Empty(t, jobs)
281+ _, err = ci.GetRun(ctx, n, newRun.Run.ID)
282+ assert.NoError(t, err)
283+ _, err = ci.GetRun(ctx, n, openRun.Run.ID)
284+ assert.NoError(t, err, "an open run is never swept")
285+
286+ // Numbers are never reused.
287+ next, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 0))
288+ require.NoError(t, err)
289+ assert.Equal(t, int64(4), next.Run.Number)
290+}
291+
292+func TestCIRepositoryDeletionRemovesEverythingAndRestartsNumbers(t *testing.T) {
293+ ctx := context.Background()
294+ s, ci, n := ciRepo(t)
295+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2))
296+ require.NoError(t, err)
297+ _, err = ci.CreateRunner(ctx, domain.CIRunner{ID: "r1", Name: "a", TokenHash: domain.HashTokenSecret("x")}, false)
298+ require.NoError(t, err)
299+
300+ require.NoError(t, s.Delete(ctx, n))
301+
302+ _, err = ci.GetRun(ctx, n, d.Run.ID)
303+ assert.ErrorIs(t, err, domain.ErrNotFound)
304+ _, err = ci.GetRunSource(ctx, d.Run.ID)
305+ assert.ErrorIs(t, err, domain.ErrNotFound)
306+ jobs, _ := ci.ListJobs(ctx, d.Run.ID, 1)
307+ assert.Empty(t, jobs)
308+ checks, _, _ := ci.ListChecks(ctx, n, d.Run.Sha, 10)
309+ assert.Empty(t, checks)
310+ ready, _ := ci.ListReadyJobs(ctx, 10)
311+ assert.Empty(t, ready)
312+ open, _ := ci.ListOpenRuns(ctx)
313+ assert.Empty(t, open)
314+ runners, _ := ci.ListRunners(ctx)
315+ assert.Len(t, runners, 1, "runners do not belong to a repository")
316+
317+ // A repository created again under the name starts its run numbers at 1,
318+ // while ids are never reused.
319+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
320+ again, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 0))
321+ require.NoError(t, err)
322+ assert.Equal(t, int64(1), again.Run.Number)
323+ assert.Equal(t, int64(2), again.Run.ID)
324+}
325+
326+func TestCIStateSurvivesARestart(t *testing.T) {
327+ ctx := context.Background()
328+ dir := t.TempDir()
329+ s, err := kvstore.Open(ctx, dir)
330+ require.NoError(t, err)
331+ n := name(t, "proj")
332+ require.NoError(t, s.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
333+ ci := s.CI()
334+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
335+ require.NoError(t, err)
336+ j := d.Jobs[0]
337+ hash := domain.HashTokenSecret("ermitejob_z")
338+ j.Phase, j.TokenHash, j.RunnerID, j.RunnerName = domain.CIPhaseRunning, hash, "r1", "alpha"
339+ j.Steps = []domain.CIStep{{Number: 1, Name: "Set up job", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, HasLines: true, LogStart: 0}}
340+ j.Outputs = map[string]string{"a": "b"}
341+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}})
342+ require.NoError(t, err)
343+ require.NoError(t, s.Close(ctx))
344+
345+ s2, err := kvstore.Open(ctx, dir)
346+ require.NoError(t, err)
347+ defer func() { _ = s2.Close(ctx) }()
348+ ci2 := s2.CI()
349+ got, err := ci2.FindJobByToken(ctx, hash, time.Time{})
350+ require.NoError(t, err)
351+ assert.Equal(t, domain.CIPhaseRunning, got.Phase)
352+ assert.Equal(t, "alpha", got.RunnerName)
353+ assert.Equal(t, map[string]string{"a": "b"}, got.Outputs)
354+ require.Len(t, got.Steps, 1)
355+ assert.True(t, got.Steps[0].HasLines)
356+ active, _ := ci2.ListActiveJobs(ctx)
357+ assert.Len(t, active, 1)
358+}
359+
360+func TestCICancelReasonsRoundTrip(t *testing.T) {
361+ ctx := context.Background()
362+ _, ci, n := ciRepo(t)
363+ d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
364+ require.NoError(t, err)
365+ for _, reason := range []domain.CICancelReason{domain.CICancelUser, domain.CICancelFailFast, domain.CICancelTimeout, domain.CICancelNone} {
366+ j, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
367+ require.NoError(t, err)
368+ j.CancelReason, j.CancelMessage, j.CancelRequestedAt = reason, "why", created
369+ _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{j}})
370+ require.NoError(t, err)
371+ got, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
372+ require.NoError(t, err)
373+ assert.Equal(t, reason, got.CancelReason)
374+ }
375+}
M internal/adapters/kvstore/mergerequests.go +2 -19
@@ -71,26 +71,9 @@ func mergeRequestKeys(ctx context.Context, snap *kv.Snapshot, repo domain.RepoNa
7171 return keys
7272 }
7373
74-// guardRepo makes the batch depend on the repository: it requires the
75-// repository key to be at the version just read and rewrites it, which gives it
76-// a new version. Every write that adds a key under the repository's merge
77-// requests (a request, a comment, a read position) goes through it, so that
78-//
79-// - such a write fails with domain.ErrNotFound once the repository is deleted,
80-// and leaves no key that a repository created again under the name would
81-// inherit;
82-// - Store.Delete, which requires the version of the repository key it listed
83-// the keys at, notices a write that got in after the listing and lists again.
84-//
85-// It returns domain.ErrNotFound when the repository does not exist.
74+// guardRepo is guardRepoKey on the database of the merge requests; see there.
8675 func (m *MergeRequests) guardRepo(ctx context.Context, b *kv.Batch, repo domain.RepoName) error {
87- item, ok := m.db.Get(ctx, repoKey(repo))
88- if !ok {
89- return domain.ErrNotFound
90- }
91- b.RequireVersion(repoKey(repo), item.Version)
92- b.Put(repoKey(repo), item.Value)
93- return nil
76+ return guardRepoKey(ctx, m.db, b, repo)
9477 }
9578
9679 // Create takes the next number of the repository from mrseq/<repo> and stores
M internal/adapters/kvstore/repos.go +27 -3
@@ -90,6 +90,29 @@ func (s *Store) Update(ctx context.Context, repo domain.Repo) error {
9090 return nil
9191 }
9292
93+// guardRepoKey makes the batch depend on the repository: it requires the
94+// repository key to be at the version just read and rewrites it, which gives it
95+// a new version. Every write that adds a key under the repository (a merge
96+// request, a comment, a read position, a CI run, job or check) goes through it,
97+// so that
98+//
99+// - such a write fails with domain.ErrNotFound once the repository is deleted,
100+// and leaves no key that a repository created again under the name would
101+// inherit;
102+// - Store.Delete, which requires the version of the repository key it listed
103+// the keys at, notices a write that got in after the listing and lists again.
104+//
105+// It returns domain.ErrNotFound when the repository does not exist.
106+func guardRepoKey(ctx context.Context, db *kv.DB, b *kv.Batch, repo domain.RepoName) error {
107+ item, ok := db.Get(ctx, repoKey(repo))
108+ if !ok {
109+ return domain.ErrNotFound
110+ }
111+ b.RequireVersion(repoKey(repo), item.Version)
112+ b.Put(repoKey(repo), item.Value)
113+ return nil
114+}
115+
93116 // deleteChunk is how many merge request keys one batch deletes before the
94117 // last one, which also removes the repository.
95118 const deleteChunk = 1000
@@ -99,7 +122,8 @@ const deleteChunk = 1000
99122 const deleteTries = 32
100123
101124 // Delete removes the repository and everything keyed to it: its merge
102-// requests, their comments and read positions, and its number sequence. The
125+// requests, their comments and read positions, and its number sequence, and
126+// its CI runs, jobs, checks and run numbers. The
103127 // Git data is moved to the trash by the caller; the merge requests are not
104128 // kept anywhere, like the repository metadata, so a repository created again
105129 // under the same name starts clean, with its numbers starting at 1. A missing
@@ -109,7 +133,7 @@ const deleteTries = 32
109133 // the last chunk: a delete that fails half way leaves the repository, which
110134 // the caller can delete again. The last batch requires the repository key to
111135 // be at the version the keys were listed at. Every write of a merge request key
112-// rewrites the repository key (see MergeRequests.guardRepo), so a write that
136+// rewrites the repository key (see guardRepoKey), so a write that
113137 // got in after the listing makes the batch fail and the keys are listed again,
114138 // and a write after the batch finds no repository: no key outlives the
115139 // repository to attach itself to a repository created again.
@@ -119,7 +143,7 @@ func (s *Store) Delete(ctx context.Context, name domain.RepoName) error {
119143 for range deleteTries {
120144 snap := s.db.Snapshot(ctx)
121145 repoItem, repoOK := snap.Get(ctx, repoKey(name))
122- keys := mergeRequestKeys(ctx, snap, name)
146+ keys := append(mergeRequestKeys(ctx, snap, name), ciKeys(ctx, snap, name)...)
123147 for len(keys) > deleteChunk {
124148 var b kv.Batch
125149 for _, k := range keys[:deleteChunk] {
M internal/adapters/rpc/api.go +12 -1
@@ -26,7 +26,7 @@ const MaxRequestBytes = 1 << 20
2626
2727 // Features are the optional capabilities of the contract that this server
2828 // implements, as GetVersion lists them.
29-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}
29+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}
3030
3131 // Config configures the API.
3232 type Config struct {
@@ -46,6 +46,11 @@ type Config struct {
4646 // budget of failures. Leave it unset rather than assigning a typed nil
4747 // pointer (a nil *T in the interface is not nil, and would be called).
4848 Limiter ports.FailureLimiter
49+ // Runner serves RunnerService and Runs serves RunService; without them the
50+ // CI services are not served. Leave them unset rather than assigning a
51+ // typed nil pointer.
52+ Runner ports.RunnerService
53+ Runs ports.RunService
4954 // Sessions are the owner's web sessions; nil serves no SessionService.
5055 // Leave it unset rather than assigning a typed nil pointer (a nil *T in
5156 // the interface is not nil, and the service would be served and called).
@@ -81,6 +86,12 @@ func New(cfg Config) *API {
8186 if cfg.Sessions != nil {
8287 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...))
8388 }
89+ if cfg.Runner != nil {
90+ mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...))
91+ }
92+ if cfg.Runs != nil {
93+ mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...))
94+ }
8495 return &API{handler: mux}
8596 }
8697
A internal/adapters/rpc/ci.go +308 -0
@@ -0,0 +1,308 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "errors"
8+
9+ "connectrpc.com/connect"
10+ "google.golang.org/protobuf/types/known/durationpb"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports"
16+)
17+
18+// secretKey carries the bearer secret of a runner or job call to the handler:
19+// those secrets are not stored tokens, the use case checks them.
20+type secretKey struct{}
21+
22+func secretFrom(ctx context.Context) string {
23+ s, _ := ctx.Value(secretKey{}).(string)
24+ return s
25+}
26+
27+// runnerSecretProcedures are the calls whose bearer secret is a runner token
28+// or a job token: the interceptor does not authenticate them as stored tokens.
29+var runnerSecretProcedures = map[string]bool{
30+ ermiteconnect.RunnerServiceFetchJobProcedure: true,
31+ ermiteconnect.RunnerServiceUpdateJobProcedure: true,
32+ ermiteconnect.RunnerServiceAppendLogProcedure: true,
33+ ermiteconnect.RunnerServiceHeartbeatProcedure: true,
34+ ermiteconnect.RunnerServiceWatchJobProcedure: true,
35+ ermiteconnect.RunnerServiceCompleteJobProcedure: true,
36+}
37+
38+// ciError maps an error of the CI use cases. A call on a job that ended gets
39+// failed_precondition with a JobEnded detail, as runner.proto says.
40+func ciError(ctx context.Context, err error) error {
41+ if ended, ok := domain.IsJobEnded(err); ok {
42+ ce := connect.NewError(connect.CodeFailedPrecondition, errors.New("the job has ended"))
43+ if d, derr := connect.NewErrorDetail(jobEndedToPB(ended)); derr == nil {
44+ ce.AddDetail(d)
45+ }
46+ return ce
47+ }
48+ if errors.Is(err, domain.ErrOutOfRange) {
49+ return connect.NewError(connect.CodeOutOfRange, err)
50+ }
51+ return connectError(ctx, err)
52+}
53+
54+// runnerService implements ermiteconnect.RunnerServiceHandler.
55+type runnerService struct {
56+ runners ports.RunnerService
57+ limiter ports.FailureLimiter
58+}
59+
60+// failed counts a call that was refused for its credential against the client
61+// address, like every other authentication failure, and maps the error.
62+func (s *runnerService) failed(ctx context.Context, err error) error {
63+ if errors.Is(err, domain.ErrUnauthenticated) && s.limiter != nil {
64+ if addr := addrFromContext(ctx); addr != "" {
65+ s.limiter.Fail(ctx, addr)
66+ }
67+ }
68+ return ciError(ctx, err)
69+}
70+
71+func (s *runnerService) RegisterRunner(ctx context.Context, req *connect.Request[pb.RegisterRunnerRequest]) (*connect.Response[pb.RegisterRunnerResponse], error) {
72+ iso, err := isolationFromPB(req.Msg.GetIsolation())
73+ if err != nil {
74+ return nil, ciError(ctx, err)
75+ }
76+ out, err := s.runners.Register(ctx, caller(ctx), ports.RegisterRunnerParams{
77+ Name: req.Msg.GetName(), Labels: req.Msg.GetLabels(), Version: req.Msg.GetVersion(), Isolation: iso,
78+ Arch: req.Msg.GetArch(), OS: req.Msg.GetOs(), Replace: req.Msg.GetReplace(),
79+ })
80+ if err != nil {
81+ return nil, ciError(ctx, err)
82+ }
83+ return connect.NewResponse(&pb.RegisterRunnerResponse{RunnerId: out.Runner.ID, RunnerToken: out.Token}), nil
84+}
85+
86+func (s *runnerService) FetchJob(ctx context.Context, req *connect.Request[pb.FetchJobRequest]) (*connect.Response[pb.FetchJobResponse], error) {
87+ res, err := s.runners.FetchJob(ctx, secretFrom(ctx), ports.FetchJobParams{Labels: req.Msg.GetLabels(), PollID: req.Msg.GetPollId()})
88+ if err != nil {
89+ return nil, s.failed(ctx, err)
90+ }
91+ out := &pb.FetchJobResponse{}
92+ if res.Job != nil {
93+ out.Job = runnerJobToPB(res.Job)
94+ } else if res.RetryAfter > 0 {
95+ out.RetryAfter = durationpb.New(res.RetryAfter)
96+ }
97+ return connect.NewResponse(out), nil
98+}
99+
100+func (s *runnerService) UpdateJob(ctx context.Context, req *connect.Request[pb.UpdateJobRequest]) (*connect.Response[pb.UpdateJobResponse], error) {
101+ steps, err := stepUpdatesFromPB(req.Msg.GetSteps())
102+ if err != nil {
103+ return nil, s.failed(ctx, err)
104+ }
105+ if err := s.runners.UpdateJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), steps); err != nil {
106+ return nil, s.failed(ctx, err)
107+ }
108+ return connect.NewResponse(&pb.UpdateJobResponse{}), nil
109+}
110+
111+func (s *runnerService) AppendLog(ctx context.Context, req *connect.Request[pb.AppendLogRequest]) (*connect.Response[pb.AppendLogResponse], error) {
112+ lines := make([]domain.CILogLine, 0, len(req.Msg.GetLines()))
113+ for _, l := range req.Msg.GetLines() {
114+ lines = append(lines, domain.CILogLine{Time: goTime(l.GetTime()), Text: l.GetText(), Continued: l.GetContinued()})
115+ }
116+ next, err := s.runners.AppendLog(ctx, secretFrom(ctx), req.Msg.GetJobId(), req.Msg.GetStep(), req.Msg.GetOffset(), lines)
117+ if err != nil {
118+ var gap *ports.ErrCILogGap
119+ if errors.As(err, &gap) {
120+ return nil, connect.NewError(connect.CodeOutOfRange, err)
121+ }
122+ return nil, s.failed(ctx, err)
123+ }
124+ return connect.NewResponse(&pb.AppendLogResponse{NextOffset: next}), nil
125+}
126+
127+func (s *runnerService) Heartbeat(ctx context.Context, req *connect.Request[pb.HeartbeatRequest]) (*connect.Response[pb.HeartbeatResponse], error) {
128+ if err := s.runners.Heartbeat(ctx, secretFrom(ctx), req.Msg.GetJobId()); err != nil {
129+ return nil, s.failed(ctx, err)
130+ }
131+ return connect.NewResponse(&pb.HeartbeatResponse{}), nil
132+}
133+
134+func (s *runnerService) WatchJob(ctx context.Context, req *connect.Request[pb.WatchJobRequest], stream *connect.ServerStream[pb.WatchJobResponse]) error {
135+ // A client's call returns when the response begins: send the headers now,
136+ // not with the first message, which may be 30 seconds away. Connect
137+ // flushes them for a nil message; a failure shows on the first real send.
138+ ready := func() { _ = stream.Send(nil) }
139+ err := s.runners.WatchJob(ctx, secretFrom(ctx), req.Msg.GetJobId(), ready, func(sig ports.JobSignal) error {
140+ switch {
141+ case sig.Cancel != nil:
142+ return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Cancel{Cancel: &pb.CancelRequest{
143+ Reason: cancelReasonToPB(sig.Cancel.Reason), Message: sig.Cancel.Message, RequestedAt: timestamp(sig.Cancel.RequestedAt),
144+ }}})
145+ case sig.Ended != nil:
146+ return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Ended{Ended: jobEndedToPB(sig.Ended)}})
147+ }
148+ return stream.Send(&pb.WatchJobResponse{Msg: &pb.WatchJobResponse_Keepalive{Keepalive: &pb.Keepalive{}}})
149+ })
150+ return s.failed(ctx, err)
151+}
152+
153+func (s *runnerService) CompleteJob(ctx context.Context, req *connect.Request[pb.CompleteJobRequest]) (*connect.Response[pb.CompleteJobResponse], error) {
154+ concl, err := conclusionFromPB(req.Msg.GetConclusion())
155+ if err != nil {
156+ return nil, s.failed(ctx, err)
157+ }
158+ steps, err := stepUpdatesFromPB(req.Msg.GetSteps())
159+ if err != nil {
160+ return nil, s.failed(ctx, err)
161+ }
162+ res, err := s.runners.CompleteJob(ctx, secretFrom(ctx), ports.CompleteJobParams{
163+ JobID: req.Msg.GetJobId(), Conclusion: concl, Outputs: req.Msg.GetOutputs(),
164+ RunnerShutdown: req.Msg.GetRunnerShutdown(), Steps: steps,
165+ })
166+ if err != nil {
167+ return nil, s.failed(ctx, err)
168+ }
169+ return connect.NewResponse(&pb.CompleteJobResponse{Conclusion: conclusionToPB(res.Conclusion), Error: res.Error, Applied: res.Applied}), nil
170+}
171+
172+// runService implements ermiteconnect.RunServiceHandler.
173+type runService struct{ runs ports.RunService }
174+
175+func repoOf(s string) (domain.RepoName, error) { return domain.ParseRepoName(s) }
176+
177+func (s *runService) ListRuns(ctx context.Context, req *connect.Request[pb.ListRunsRequest]) (*connect.Response[pb.ListRunsResponse], error) {
178+ name, err := repoOf(req.Msg.GetRepo())
179+ if err != nil {
180+ return nil, ciError(ctx, err)
181+ }
182+ if _, err := statusFromPB(req.Msg.GetStatus()); err != nil {
183+ return nil, ciError(ctx, err)
184+ }
185+ page, err := s.runs.ListRuns(ctx, caller(ctx), name, ports.ListRunsParams{
186+ PageSize: int(req.Msg.GetPageSize()), PageToken: req.Msg.GetPageToken(),
187+ Workflow: req.Msg.GetWorkflow(), Branch: req.Msg.GetBranch(), Status: statusOrZero(req.Msg.GetStatus()),
188+ })
189+ if err != nil {
190+ return nil, ciError(ctx, err)
191+ }
192+ out := &pb.ListRunsResponse{NextPageToken: page.NextPageToken, Runs: make([]*pb.Run, 0, len(page.Runs))}
193+ for _, r := range page.Runs {
194+ out.Runs = append(out.Runs, runToPB(r))
195+ }
196+ return connect.NewResponse(out), nil
197+}
198+
199+func statusOrZero(s pb.CiStatus) domain.CIStatus {
200+ d, _ := statusFromPB(s)
201+ return d
202+}
203+
204+func (s *runService) GetRun(ctx context.Context, req *connect.Request[pb.GetRunRequest]) (*connect.Response[pb.GetRunResponse], error) {
205+ name, err := repoOf(req.Msg.GetRepo())
206+ if err != nil {
207+ return nil, ciError(ctx, err)
208+ }
209+ d, err := s.runs.GetRun(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetAttempt())
210+ if err != nil {
211+ return nil, ciError(ctx, err)
212+ }
213+ out := &pb.GetRunResponse{Run: runToPB(d.Run), LatestAttempt: d.LatestAttempt}
214+ for _, j := range d.Jobs {
215+ out.Jobs = append(out.Jobs, jobViewToPB(j))
216+ }
217+ return connect.NewResponse(out), nil
218+}
219+
220+func (s *runService) GetJob(ctx context.Context, req *connect.Request[pb.GetJobRequest]) (*connect.Response[pb.GetJobResponse], error) {
221+ name, err := repoOf(req.Msg.GetRepo())
222+ if err != nil {
223+ return nil, ciError(ctx, err)
224+ }
225+ j, err := s.runs.GetJob(ctx, caller(ctx), name, req.Msg.GetJobId(), req.Msg.GetAttempt())
226+ if err != nil {
227+ return nil, ciError(ctx, err)
228+ }
229+ return connect.NewResponse(&pb.GetJobResponse{Job: jobViewToPB(j)}), nil
230+}
231+
232+func (s *runService) GetJobLog(ctx context.Context, req *connect.Request[pb.GetJobLogRequest], stream *connect.ServerStream[pb.GetJobLogResponse]) error {
233+ name, err := repoOf(req.Msg.GetRepo())
234+ if err != nil {
235+ return ciError(ctx, err)
236+ }
237+ err = s.runs.GetJobLog(ctx, caller(ctx), name, ports.GetJobLogParams{
238+ JobID: req.Msg.GetJobId(), Attempt: req.Msg.GetAttempt(), Offset: req.Msg.GetOffset(), Follow: req.Msg.GetFollow(),
239+ Client: clientFromContext(ctx),
240+ Ready: func() { _ = stream.Send(nil) },
241+ }, func(b ports.LogBatch) error {
242+ out := &pb.GetJobLogResponse{Offset: b.Offset, Lines: make([]*pb.JobLogLine, 0, len(b.Lines))}
243+ for _, l := range b.Lines {
244+ out.Lines = append(out.Lines, logLineToPB(l))
245+ }
246+ return stream.Send(out)
247+ })
248+ return ciError(ctx, err)
249+}
250+
251+func (s *runService) CancelRun(ctx context.Context, req *connect.Request[pb.CancelRunRequest]) (*connect.Response[pb.CancelRunResponse], error) {
252+ name, err := repoOf(req.Msg.GetRepo())
253+ if err != nil {
254+ return nil, ciError(ctx, err)
255+ }
256+ run, err := s.runs.CancelRun(ctx, caller(ctx), name, req.Msg.GetRunId())
257+ if err != nil {
258+ return nil, ciError(ctx, err)
259+ }
260+ return connect.NewResponse(&pb.CancelRunResponse{Run: runToPB(run)}), nil
261+}
262+
263+func (s *runService) RerunRun(ctx context.Context, req *connect.Request[pb.RerunRunRequest]) (*connect.Response[pb.RerunRunResponse], error) {
264+ name, err := repoOf(req.Msg.GetRepo())
265+ if err != nil {
266+ return nil, ciError(ctx, err)
267+ }
268+ run, err := s.runs.RerunRun(ctx, caller(ctx), name, req.Msg.GetRunId(), req.Msg.GetFailedOnly())
269+ if err != nil {
270+ return nil, ciError(ctx, err)
271+ }
272+ return connect.NewResponse(&pb.RerunRunResponse{Run: runToPB(run)}), nil
273+}
274+
275+func (s *runService) ListChecks(ctx context.Context, req *connect.Request[pb.ListChecksRequest]) (*connect.Response[pb.ListChecksResponse], error) {
276+ name, err := repoOf(req.Msg.GetRepo())
277+ if err != nil {
278+ return nil, ciError(ctx, err)
279+ }
280+ res, err := s.runs.ListChecks(ctx, caller(ctx), name, req.Msg.GetSha())
281+ if err != nil {
282+ return nil, ciError(ctx, err)
283+ }
284+ out := &pb.ListChecksResponse{Truncated: res.Truncated}
285+ for _, c := range res.Checks {
286+ out.Checks = append(out.Checks, checkToPB(c.CICheck))
287+ }
288+ return connect.NewResponse(out), nil
289+}
290+
291+func (s *runService) ListRunners(ctx context.Context, req *connect.Request[pb.ListRunnersRequest]) (*connect.Response[pb.ListRunnersResponse], error) {
292+ page, err := s.runs.ListRunners(ctx, caller(ctx), int(req.Msg.GetPageSize()), req.Msg.GetPageToken())
293+ if err != nil {
294+ return nil, ciError(ctx, err)
295+ }
296+ out := &pb.ListRunnersResponse{NextPageToken: page.NextPageToken, Runners: make([]*pb.Runner, 0, len(page.Runners))}
297+ for _, r := range page.Runners {
298+ out.Runners = append(out.Runners, runnerToPB(r))
299+ }
300+ return connect.NewResponse(out), nil
301+}
302+
303+func (s *runService) RemoveRunner(ctx context.Context, req *connect.Request[pb.RemoveRunnerRequest]) (*connect.Response[pb.RemoveRunnerResponse], error) {
304+ if err := s.runs.RemoveRunner(ctx, caller(ctx), req.Msg.GetRunnerId()); err != nil {
305+ return nil, ciError(ctx, err)
306+ }
307+ return connect.NewResponse(&pb.RemoveRunnerResponse{}), nil
308+}
A internal/adapters/rpc/ci_convert.go +228 -0
@@ -0,0 +1,228 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "fmt"
7+
8+ pb "bnema.dev/ermite-api/gen/ermite"
9+ "bnema.dev/ermite/internal/domain"
10+ "bnema.dev/ermite/internal/ports"
11+)
12+
13+func statusToPB(s domain.CIStatus) pb.CiStatus {
14+ switch s {
15+ case domain.CIQueued:
16+ return pb.CiStatus_CI_STATUS_QUEUED
17+ case domain.CIInProgress:
18+ return pb.CiStatus_CI_STATUS_IN_PROGRESS
19+ case domain.CICompleted:
20+ return pb.CiStatus_CI_STATUS_COMPLETED
21+ }
22+ return pb.CiStatus_CI_STATUS_UNSPECIFIED
23+}
24+
25+func statusFromPB(s pb.CiStatus) (domain.CIStatus, error) {
26+ switch s {
27+ case pb.CiStatus_CI_STATUS_UNSPECIFIED:
28+ return "", nil
29+ case pb.CiStatus_CI_STATUS_QUEUED:
30+ return domain.CIQueued, nil
31+ case pb.CiStatus_CI_STATUS_IN_PROGRESS:
32+ return domain.CIInProgress, nil
33+ case pb.CiStatus_CI_STATUS_COMPLETED:
34+ return domain.CICompleted, nil
35+ }
36+ return "", fmt.Errorf("%w: status %v", domain.ErrInvalid, s)
37+}
38+
39+var conclusionPairs = []struct {
40+ d domain.CIConclusion
41+ p pb.CiConclusion
42+}{
43+ {domain.CISuccess, pb.CiConclusion_CI_CONCLUSION_SUCCESS},
44+ {domain.CIFailure, pb.CiConclusion_CI_CONCLUSION_FAILURE},
45+ {domain.CICancelled, pb.CiConclusion_CI_CONCLUSION_CANCELLED},
46+ {domain.CISkipped, pb.CiConclusion_CI_CONCLUSION_SKIPPED},
47+ {domain.CITimedOut, pb.CiConclusion_CI_CONCLUSION_TIMED_OUT},
48+ {domain.CINeutral, pb.CiConclusion_CI_CONCLUSION_NEUTRAL},
49+ {domain.CIActionRequired, pb.CiConclusion_CI_CONCLUSION_ACTION_REQUIRED},
50+}
51+
52+func conclusionToPB(c domain.CIConclusion) pb.CiConclusion {
53+ for _, e := range conclusionPairs {
54+ if e.d == c {
55+ return e.p
56+ }
57+ }
58+ return pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED
59+}
60+
61+func conclusionFromPB(c pb.CiConclusion) (domain.CIConclusion, error) {
62+ if c == pb.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
63+ return domain.CINoConclusion, nil
64+ }
65+ for _, e := range conclusionPairs {
66+ if e.p == c {
67+ return e.d, nil
68+ }
69+ }
70+ return "", fmt.Errorf("%w: conclusion %v", domain.ErrInvalid, c)
71+}
72+
73+func isolationToPB(i domain.CIIsolation) pb.RunnerIsolation {
74+ switch i {
75+ case domain.CIIsolationVM:
76+ return pb.RunnerIsolation_RUNNER_ISOLATION_VM
77+ case domain.CIIsolationContainerSysbox:
78+ return pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX
79+ case domain.CIIsolationContainerPrivileged:
80+ return pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED
81+ }
82+ return pb.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED
83+}
84+
85+func isolationFromPB(i pb.RunnerIsolation) (domain.CIIsolation, error) {
86+ switch i {
87+ case pb.RunnerIsolation_RUNNER_ISOLATION_VM:
88+ return domain.CIIsolationVM, nil
89+ case pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_SYSBOX:
90+ return domain.CIIsolationContainerSysbox, nil
91+ case pb.RunnerIsolation_RUNNER_ISOLATION_CONTAINER_PRIVILEGED:
92+ return domain.CIIsolationContainerPrivileged, nil
93+ }
94+ return "", fmt.Errorf("%w: isolation is required", domain.ErrInvalid)
95+}
96+
97+func stepStateToPB(s domain.CIStepState) pb.StepState {
98+ switch s {
99+ case domain.CIStepPending:
100+ return pb.StepState_STEP_STATE_PENDING
101+ case domain.CIStepInProgress:
102+ return pb.StepState_STEP_STATE_IN_PROGRESS
103+ case domain.CIStepCompleted:
104+ return pb.StepState_STEP_STATE_COMPLETED
105+ }
106+ return pb.StepState_STEP_STATE_UNSPECIFIED
107+}
108+
109+func stepStateFromPB(s pb.StepState) domain.CIStepState {
110+ switch s {
111+ case pb.StepState_STEP_STATE_PENDING:
112+ return domain.CIStepPending
113+ case pb.StepState_STEP_STATE_IN_PROGRESS:
114+ return domain.CIStepInProgress
115+ case pb.StepState_STEP_STATE_COMPLETED:
116+ return domain.CIStepCompleted
117+ }
118+ return ""
119+}
120+
121+func cancelReasonToPB(r domain.CICancelReason) pb.CancelReason {
122+ switch r {
123+ case domain.CICancelUser:
124+ return pb.CancelReason_CANCEL_REASON_USER
125+ case domain.CICancelFailFast:
126+ return pb.CancelReason_CANCEL_REASON_FAIL_FAST
127+ case domain.CICancelTimeout:
128+ return pb.CancelReason_CANCEL_REASON_TIMEOUT
129+ }
130+ return pb.CancelReason_CANCEL_REASON_UNSPECIFIED
131+}
132+
133+func runToPB(r domain.CIRun) *pb.Run {
134+ return &pb.Run{
135+ Repo: r.Repo.String(), Id: r.ID, Number: r.Number, Attempt: r.Attempt,
136+ WorkflowPath: r.WorkflowPath, WorkflowName: r.WorkflowName, Title: r.Title, Event: r.Event,
137+ Ref: r.Ref, Sha: r.Sha, Actor: r.Actor, Status: statusToPB(r.Status), Conclusion: conclusionToPB(r.Conclusion),
138+ Error: r.Error, CreatedAt: timestamp(r.CreatedAt), StartedAt: timestamp(r.StartedAt), CompletedAt: timestamp(r.CompletedAt),
139+ Version: int64(r.Version), //nolint:gosec // a store sequence, far below 2^63
140+ }
141+}
142+
143+func stepsToPB(steps []domain.CIStep, lines int64) []*pb.Step {
144+ ranges := domain.StepRanges(steps, lines)
145+ out := make([]*pb.Step, 0, len(steps))
146+ for i, s := range steps {
147+ out = append(out, &pb.Step{
148+ Number: s.Number, Name: s.Name, State: stepStateToPB(s.State), Conclusion: conclusionToPB(s.Conclusion),
149+ StartedAt: timestamp(s.StartedAt), CompletedAt: timestamp(s.CompletedAt),
150+ LogStart: ranges[i].Start, LogEnd: ranges[i].End,
151+ })
152+ }
153+ return out
154+}
155+
156+func jobToPB(j domain.CIJob, lines int64) *pb.RunJob {
157+ return &pb.RunJob{
158+ Id: j.ID, RunId: j.RunID, Attempt: j.Attempt, Repo: j.Repo.String(), Key: j.Key, Name: j.Name,
159+ Status: statusToPB(j.Status()), Conclusion: conclusionToPB(j.Conclusion), Error: j.Error,
160+ Labels: j.Labels, Needs: j.Needs, RunnerId: j.RunnerID, RunnerName: j.RunnerName, Isolation: isolationToPB(j.Isolation),
161+ Steps: stepsToPB(j.Steps, lines), CreatedAt: timestamp(j.CreatedAt), StartedAt: timestamp(j.StartedAt),
162+ CompletedAt: timestamp(j.CompletedAt),
163+ Version: int64(j.Version), //nolint:gosec // a store sequence, far below 2^63
164+ }
165+}
166+
167+func jobViewToPB(v ports.JobView) *pb.RunJob {
168+ out := jobToPB(v.CIJob, v.Lines)
169+ if v.RunnerGone {
170+ out.RunnerId = ""
171+ }
172+ return out
173+}
174+
175+func checkToPB(c domain.CICheck) *pb.Check {
176+ return &pb.Check{
177+ Id: c.ID, Repo: c.Repo.String(), Sha: c.Sha, Name: c.Name, Status: statusToPB(c.Status),
178+ Conclusion: conclusionToPB(c.Conclusion), RunId: c.RunID, JobId: c.JobID, Attempt: c.Attempt,
179+ WorkflowName: c.WorkflowName, StartedAt: timestamp(c.StartedAt), CompletedAt: timestamp(c.CompletedAt),
180+ }
181+}
182+
183+func runnerToPB(r ports.RunnerView) *pb.Runner {
184+ return &pb.Runner{
185+ Id: r.ID, Name: r.Name, Labels: r.Labels, Isolation: isolationToPB(r.Isolation), Version: r.Version,
186+ Arch: r.Arch, Os: r.OS, RegisteredAt: timestamp(r.RegisteredAt), LastSeen: timestamp(r.LastSeen), Busy: r.Busy,
187+ }
188+}
189+
190+func logLineToPB(l ports.JobLogLine) *pb.JobLogLine {
191+ return &pb.JobLogLine{Step: l.Step, Time: timestamp(l.Time), Text: l.Text, Continued: l.Continued}
192+}
193+
194+func runnerJobToPB(j *ports.RunnerJob) *pb.Job {
195+ return &pb.Job{
196+ JobId: j.Job.ID, RunId: j.Job.RunID, Attempt: j.Job.RunAttempt, JobToken: j.Token,
197+ WorkflowPath: j.WorkflowPath, WorkflowYaml: j.WorkflowYAML, JobKey: j.Job.Key, Matrix: j.Job.MatrixJSON,
198+ Contexts: j.Job.ContextsJSON, Labels: j.Job.Labels, TimeoutMinutes: j.Job.TimeoutMinutes,
199+ Repo: j.Repo.String(), Sha: j.Sha, Ref: j.Ref,
200+ }
201+}
202+
203+func stepUpdatesFromPB(steps []*pb.StepUpdate) ([]ports.StepUpdate, error) {
204+ out := make([]ports.StepUpdate, 0, len(steps))
205+ for _, s := range steps {
206+ concl, err := conclusionFromPB(s.GetConclusion())
207+ if err != nil {
208+ return nil, err
209+ }
210+ out = append(out, ports.StepUpdate{
211+ Number: s.GetNumber(), Name: s.GetName(), State: stepStateFromPB(s.GetState()), Conclusion: concl,
212+ StartedAt: goTime(s.GetStartedAt()), CompletedAt: goTime(s.GetCompletedAt()),
213+ })
214+ }
215+ return out, nil
216+}
217+
218+func jobEndedToPB(e *domain.JobEndedError) *pb.JobEnded {
219+ return &pb.JobEnded{Conclusion: conclusionToPB(e.Conclusion), Error: e.Message}
220+}
221+
222+func runEventToPB(ev domain.RunUpdated) *pb.Event_RunUpdated {
223+ return &pb.Event_RunUpdated{RunUpdated: &pb.RunUpdated{Run: runToPB(ev.Run)}}
224+}
225+
226+func jobEventToPB(ev domain.JobUpdated) *pb.Event_JobUpdated {
227+ return &pb.Event_JobUpdated{JobUpdated: &pb.JobUpdated{Job: jobToPB(ev.Job, ev.Lines)}}
228+}
A internal/adapters/rpc/ci_test.go +101 -0
@@ -0,0 +1,101 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package rpc
4+
5+import (
6+ "context"
7+ "testing"
8+ "time"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+ "bnema.dev/ermite/internal/ports/mocks"
19+)
20+
21+// RunService.GetJob: the job with its steps and the range of their lines, the
22+// states and conclusions of the contract, and the codes of the refusals.
23+func TestRunServiceGetJob(t *testing.T) {
24+ t.Parallel()
25+ at := time.Date(2026, 10, 12, 10, 0, 0, 0, time.UTC)
26+ job := ports.JobView{CIJob: domain.CIJob{
27+ ID: "7-0-0", RunID: 7, Attempt: 2, RunAttempt: 2, Repo: mustRepoName(t, "demo"), Key: "build", Name: "build (linux)",
28+ Phase: domain.CIPhaseEnded, Conclusion: domain.CIFailure, Error: "boom", Labels: []string{"self-hosted"}, Needs: []string{"lint"},
29+ RunnerName: "alpha", Isolation: domain.CIIsolationVM, CreatedAt: at, StartedAt: at, CompletedAt: at.Add(time.Minute), Version: 9,
30+ Steps: []domain.CIStep{
31+ {Number: 1, Name: "Set up job", State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 0},
32+ {Number: 2, Name: "Run make", State: domain.CIStepCompleted, Conclusion: domain.CIFailure, StartedAt: at, CompletedAt: at, HasLines: true, LogStart: 3},
33+ },
34+ }, Lines: 8}
35+
36+ runs := mocks.NewRunService(t)
37+ runs.EXPECT().GetJob(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), "7-0-0", int32(2)).Return(job, nil).Once()
38+ resp, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: "demo", JobId: "7-0-0", Attempt: 2}))
39+ require.NoError(t, err)
40+ got := resp.Msg.GetJob()
41+ assert.Equal(t, "7-0-0", got.GetId())
42+ assert.EqualValues(t, 2, got.GetAttempt())
43+ assert.Equal(t, "build (linux)", got.GetName())
44+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, got.GetStatus())
45+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, got.GetConclusion())
46+ assert.Equal(t, "boom", got.GetError())
47+ assert.Equal(t, pb.RunnerIsolation_RUNNER_ISOLATION_VM, got.GetIsolation())
48+ assert.Equal(t, []string{"lint"}, got.GetNeeds())
49+ assert.EqualValues(t, 9, got.GetVersion())
50+ require.Len(t, got.GetSteps(), 2)
51+ assert.Equal(t, []int64{0, 3}, []int64{got.GetSteps()[0].GetLogStart(), got.GetSteps()[0].GetLogEnd()}, "a step ends where the next begins")
52+ assert.Equal(t, []int64{3, 8}, []int64{got.GetSteps()[1].GetLogStart(), got.GetSteps()[1].GetLogEnd()}, "the last ends at the end of the log")
53+ assert.Equal(t, pb.StepState_STEP_STATE_COMPLETED, got.GetSteps()[1].GetState())
54+
55+ for name, tc := range map[string]struct {
56+ repo string
57+ err error
58+ code connect.Code
59+ }{
60+ "invalid repo": {"../x", nil, connect.CodeInvalidArgument},
61+ "unknown job": {"demo", domain.ErrNotFound, connect.CodeNotFound},
62+ "not readable": {"demo", domain.ErrNotFound, connect.CodeNotFound},
63+ "store failure": {"demo", assert.AnError, connect.CodeInternal},
64+ } {
65+ t.Run(name, func(t *testing.T) {
66+ t.Parallel()
67+ runs := mocks.NewRunService(t)
68+ if tc.err != nil {
69+ runs.EXPECT().GetJob(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.JobView{}, tc.err).Once()
70+ }
71+ _, err := (&runService{runs: runs}).GetJob(ownerCtx(), connect.NewRequest(&pb.GetJobRequest{Repo: tc.repo, JobId: "7-0-0"}))
72+ requireCode(t, tc.code, err)
73+ })
74+ }
75+}
76+
77+// The end of a job reaches the runner as failed_precondition with the JobEnded
78+// detail; a bad credential counts against the client address.
79+func TestRunnerServiceErrors(t *testing.T) {
80+ t.Parallel()
81+ ended := &domain.JobEndedError{Conclusion: domain.CICancelled, Message: "stopped"}
82+ err := ciError(ownerCtx(), ended)
83+ requireCode(t, connect.CodeFailedPrecondition, err)
84+ var ce *connect.Error
85+ require.ErrorAs(t, err, &ce)
86+ require.Len(t, ce.Details(), 1)
87+ msg, derr := ce.Details()[0].Value()
88+ require.NoError(t, derr)
89+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, msg.(*pb.JobEnded).GetConclusion())
90+ assert.Equal(t, "stopped", msg.(*pb.JobEnded).GetError())
91+
92+ requireCode(t, connect.CodeOutOfRange, ciError(ownerCtx(), &ports.ErrCILogGap{Count: 4}))
93+
94+ limiter := mocks.NewFailureLimiter(t)
95+ limiter.EXPECT().Fail(mock.Anything, "client-key").Return().Once()
96+ s := &runnerService{runners: mocks.NewRunnerService(t), limiter: limiter}
97+ ctx := t.Context()
98+ ctx = context.WithValue(ctx, addrKey{}, "client-key")
99+ requireCode(t, connect.CodeUnauthenticated, s.failed(ctx, domain.ErrUnauthenticated))
100+ requireCode(t, connect.CodePermissionDenied, s.failed(ctx, domain.ErrForbidden)) // not a bad credential: no Fail
101+}
M internal/adapters/rpc/convert.go +6 -0
@@ -391,6 +391,12 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
391391 case domain.CommentAdded:
392392 out.Time = timestamp(ev.At)
393393 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
394+ case domain.RunUpdated:
395+ out.Time = timestamp(ev.At)
396+ out.Kind = runEventToPB(ev)
397+ case domain.JobUpdated:
398+ out.Time = timestamp(ev.At)
399+ out.Kind = jobEventToPB(ev)
394400 default:
395401 return nil
396402 }
M internal/adapters/rpc/interceptor.go +16 -0
@@ -69,6 +69,13 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
6969 if !present {
7070 return ctx, nil
7171 }
72+ if runnerSecretProcedures[procedure] {
73+ // A runner or job token: not a stored token. The use case checks it
74+ // and answers unauthenticated; runnerService.failed counts the
75+ // failures against the client address.
76+ ctx = context.WithValue(ctx, addrKey{}, addr)
77+ return context.WithValue(ctx, secretKey{}, secret), nil
78+ }
7279 p, err := i.auth.Authenticate(ctx, secret)
7380 switch {
7481 case errors.Is(err, domain.ErrUnauthenticated):
@@ -83,6 +90,15 @@ func (i *interceptor) authenticate(ctx context.Context, procedure, peerAddr stri
8390
8491 type clientKey struct{}
8592
93+// addrKey carries the normalized client address of a call whose credential the
94+// use case checks.
95+type addrKey struct{}
96+
97+func addrFromContext(ctx context.Context) string {
98+ a, _ := ctx.Value(addrKey{}).(string)
99+ return a
100+}
101+
86102 // clientFromContext returns the normalized address of the client of a call:
87103 // its peer, empty when there is none.
88104 func clientFromContext(ctx context.Context) string {
M internal/adapters/web/ASSETS.md +1 -0
@@ -9,6 +9,7 @@ content-hashed names. Nothing is fetched at runtime.
99 | `static/js/hx-live.min.js` | htmx 4 `hx-live` extension | `mockups/assets/htmx/ext/hx-live.min.js` | `adf9b90949d57baf20dc2eaf1d1fd46c81347ac82c090a5e2912ce6b37726734` |
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
1111 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
12+| `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
1213 | `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
1314 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1415 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
A internal/adapters/web/actions.templ +278 -0
@@ -0,0 +1,278 @@
1+package web
2+
3+// The CI pages: the runs of a repository, a run with its jobs, a job with its
4+// steps and its log (actions.go). A state is always a word as well as a
5+// symbol and a colour.
6+
7+// CheckIcon is the symbol of a state: a mark in the colour of its kind, with
8+// the state as its text for a screen reader.
9+templ CheckIcon(kind, state string) {
10+ <span class={ "ck", "ck-" + kind } role="img" aria-label={ state } title={ state }></span>
11+}
12+
13+templ Actions(l Layout, p ActionsPage) {
14+ @Shell(l, nil) {
15+ if len(p.Runs) == 0 {
16+ <p class="dim">no runs yet: a push that matches a workflow of .github/workflows starts one.</p>
17+ } else {
18+ <table class="list runs" aria-label="Runs">
19+ <thead>
20+ <tr><th class="c-st"></th><th class="c-name">workflow</th><th class="c-sub">commit</th><th class="c-ref">ref</th><th class="c-who">actor</th><th class="c-dur">time</th><th class="c-when">created</th></tr>
21+ </thead>
22+ <tbody id="run-rows" data-rows>
23+ for _, r := range p.Runs {
24+ @RunListRow(r)
25+ }
26+ </tbody>
27+ </table>
28+ }
29+ if p.More != "" {
30+ <p class="more"><a href={ templ.SafeURL(p.More) } rel="next">older runs</a></p>
31+ }
32+ }
33+}
34+
35+// RunListRow is a row of the list of runs; its id is the target of the live
36+// updates.
37+templ RunListRow(r RunRow) {
38+ <tr id={ "run-" + r.ID }>
39+ <td class="c-st">@CheckIcon(r.Kind, r.State)</td>
40+ <td class="c-name">
41+ <a href={ templ.SafeURL(r.Href) }>{ r.Workflow }</a>
42+ <span class="mute">{ r.Number }</span>
43+ if r.Attempt > 1 {
44+ <span class="pill">attempt { itoa(int(r.Attempt)) }</span>
45+ }
46+ </td>
47+ <td class="c-sub"><a href={ templ.SafeURL(r.CHref) }>{ r.Short }</a> { r.Title }</td>
48+ <td class="c-ref"><a href={ templ.SafeURL(r.RefHref) }>{ r.Ref }</a></td>
49+ <td class="c-who dim">{ r.Actor }</td>
50+ <td class="c-dur dim">{ r.State }{ " " }{ r.Duration }</td>
51+ <td class="c-when dim">
52+ if r.WhenAt != "" {
53+ <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
54+ }
55+ </td>
56+ </tr>
57+}
58+
59+templ RunDetail(l Layout, p RunPage) {
60+ @Shell(l, runRail(p)) {
61+ @LiveSlot("run-notice")
62+ <div id="run-body">
63+ @RunBody(p)
64+ </div>
65+ }
66+}
67+
68+// runBodyFragment is the body of the run page as a live fragment.
69+templ runBodyFragment(p RunPage) {
70+ <div id="run-body">
71+ @RunBody(p)
72+ </div>
73+}
74+
75+// RunBody is the jobs of a run, replaced by the live updates.
76+templ RunBody(p RunPage) {
77+ <h2 class="run-h">@CheckIcon(p.Kind, p.State)<span>{ p.Workflow }</span> <span class="mute">{ p.Number }</span></h2>
78+ if p.Error != "" {
79+ <pre class="run-err">{ p.Error }</pre>
80+ }
81+ <ul class="jobs">
82+ for _, j := range p.Jobs {
83+ <li id={ "job-" + j.ID }>
84+ <a class="job-h" href={ templ.SafeURL(j.Href) }>
85+ @CheckIcon(j.Kind, j.State)
86+ <span class="j-name">{ j.Name }</span>
87+ <span class="dim">{ j.State }{ " " }{ j.Duration }</span>
88+ </a>
89+ if j.Runner != "" {
90+ <span class="mute">on { runnerText(j) }</span>
91+ }
92+ if len(j.Needs) > 0 {
93+ <span class="mute">needs { joinWords(j.Needs) }</span>
94+ }
95+ if j.Error != "" {
96+ <p class="job-err">{ j.Error }</p>
97+ }
98+ if len(j.Steps) > 0 {
99+ <ol class="steps">
100+ for _, s := range j.Steps {
101+ <li>@CheckIcon(s.Kind, s.State) <span>{ s.Name }</span> <span class="dim">{ s.Duration }</span></li>
102+ }
103+ </ol>
104+ }
105+ </li>
106+ }
107+ </ul>
108+}
109+
110+templ runRail(p RunPage) {
111+ <section aria-labelledby="r-run">
112+ <h2 id="r-run">Run</h2>
113+ <dl class="kv">
114+ <dt>result</dt>
115+ <dd>{ p.State }</dd>
116+ <dt>commit</dt>
117+ <dd><a href={ templ.SafeURL(p.CHref) }>{ p.Short }</a></dd>
118+ <dt>ref</dt>
119+ <dd><a href={ templ.SafeURL(p.RefHref) }>{ p.Ref }</a></dd>
120+ <dt>actor</dt>
121+ <dd>{ p.Actor }</dd>
122+ <dt>attempt</dt>
123+ <dd>{ itoa(int(p.Attempt)) } of { itoa(int(p.Latest)) }</dd>
124+ </dl>
125+ if len(p.Attempts) > 0 {
126+ <ul class="attempts">
127+ for _, a := range p.Attempts {
128+ <li>
129+ if a.Current {
130+ <span aria-current="page">{ a.Label }</span>
131+ } else {
132+ <a href={ templ.SafeURL(a.Href) }>{ a.Label }</a>
133+ }
134+ </li>
135+ }
136+ </ul>
137+ }
138+ </section>
139+ if p.CancelAction != "" || p.RerunAction != "" {
140+ <section aria-labelledby="r-act" class="decide">
141+ <h2 id="r-act">Actions</h2>
142+ <div class="d-btns">
143+ if p.CancelAction != "" {
144+ <form method="post" action={ templ.SafeURL(p.CancelAction) }><button type="submit" class="btn">Cancel run</button></form>
145+ }
146+ if p.RerunAction != "" {
147+ <form method="post" action={ templ.SafeURL(p.RerunAction) }><button type="submit" class="btn primary">Re-run all jobs</button></form>
148+ }
149+ if p.CanRerunFailed {
150+ <form method="post" action={ templ.SafeURL(p.RerunFailedAction) }><button type="submit" class="btn">Re-run failed jobs</button></form>
151+ }
152+ </div>
153+ </section>
154+ }
155+}
156+
157+// JobHead is the title of the job page with its state and its error, replaced
158+// by the live updates. total is the number of lines the log holds.
159+templ JobHead(j JobRow, total int64) {
160+ <div id="job-head" data-total={ itoa64(total) }>
161+ <h2 class="run-h">@CheckIcon(j.Kind, j.State)<span>{ j.Name }</span> <span class="dim">{ j.State }{ " " }{ j.Duration }</span></h2>
162+ if j.Error != "" {
163+ <p class="job-err">{ j.Error }</p>
164+ }
165+ </div>
166+}
167+
168+// JobSteps is the steps of the job in the rail, replaced by the live updates.
169+templ JobSteps(steps []StepView) {
170+ <ol class="steps" id="job-steps">
171+ for _, s := range steps {
172+ <li>
173+ @CheckIcon(s.Kind, s.State)
174+ if s.LogEnd > s.LogStart {
175+ <a href={ templ.SafeURL("#L" + itoa64(s.LogStart)) }>{ s.Name }</a>
176+ } else {
177+ <span>{ s.Name }</span>
178+ }
179+ <span class="dim">{ s.Duration }</span>
180+ </li>
181+ }
182+ </ol>
183+}
184+
185+templ Job(l Layout, p JobPage) {
186+ @Shell(l, jobRail(p)) {
187+ @LiveSlot("job-notice")
188+ @JobHead(p.JobRow, p.Total)
189+ <div id="job-log" class="joblog" data-total={ itoa64(p.Total) } data-follow?={ p.Follow } data-stream={ p.StreamHref } data-next={ itoa64(p.From + int64(len(p.Lines))) }>
190+ if p.Note != "" {
191+ <p class="dim">{ p.Note }</p>
192+ }
193+ if p.Prev != "" {
194+ <p class="more"><a href={ templ.SafeURL(p.Prev) } rel="prev">earlier lines</a></p>
195+ }
196+ <ol class="log" start={ itoa64(p.From + 1) }>
197+ for _, ln := range p.Lines {
198+ @LogLine(ln)
199+ }
200+ </ol>
201+ if p.Next != "" {
202+ <p class="more"><a href={ templ.SafeURL(p.Next) } rel="next">more lines</a></p>
203+ }
204+ </div>
205+ }
206+}
207+
208+// LogLine is a line of the log. Group and error markers of the runner show as
209+// headings and errors, never as raw text.
210+templ LogLine(ln LogLineView) {
211+ <li id={ "L" + itoa64(ln.N) } class={ logClass(ln) } data-step={ itoa(int(ln.Step)) } data-cont?={ ln.Continued }><time>{ ln.Time }</time><span class="t">{ ln.Text }</span></li>
212+}
213+
214+templ jobRail(p JobPage) {
215+ <section aria-labelledby="r-job">
216+ <h2 id="r-job">Steps</h2>
217+ @JobSteps(p.Steps)
218+ </section>
219+ <section aria-labelledby="r-jobf">
220+ <h2 id="r-jobf">Job</h2>
221+ <dl class="kv">
222+ <dt>run</dt>
223+ <dd><a href={ templ.SafeURL(p.RunHref) }>{ p.RunName }</a></dd>
224+ if p.Runner != "" {
225+ <dt>runner</dt>
226+ <dd>{ p.Runner }</dd>
227+ }
228+ if p.Isolation != "" {
229+ <dt>isolation</dt>
230+ <dd>{ p.Isolation }</dd>
231+ }
232+ <dt>lines</dt>
233+ <dd>{ itoa64(p.Total) }</dd>
234+ </dl>
235+ <p><a href={ templ.SafeURL(p.RawHref) }>raw log</a></p>
236+ </section>
237+}
238+
239+// CommitChecks is the combined state of the checks of a commit, as a mark
240+// with a count, linking to the page that explains it.
241+templ CommitChecks(c ChecksSummary) {
242+ if c.Total > 0 {
243+ <a class="ck-sum" href={ templ.SafeURL(c.Href) } title={ c.Title }>
244+ @CheckIcon(c.Kind, c.State)
245+ <span class="mute">{ c.Label }</span>
246+ </a>
247+ }
248+}
249+
250+// MRChecks is the checks section of the rail of a merge request.
251+templ MRChecks(c ChecksSection) {
252+ <section aria-labelledby="r-checks" id="mr-checks">
253+ <h2 id="r-checks">Checks <span class="count">{ itoa(len(c.Rows)) }</span></h2>
254+ if len(c.Rows) == 0 {
255+ <p class="dim">no checks</p>
256+ } else {
257+ <ul class="checks">
258+ for _, r := range c.Rows {
259+ <li>
260+ @CheckIcon(r.Kind, r.State)
261+ if r.Href != "" {
262+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
263+ } else {
264+ <span>{ r.Name }</span>
265+ }
266+ <span class="dim">{ r.State }</span>
267+ if r.Waiting {
268+ <span class="mute">waiting for a runner</span>
269+ }
270+ </li>
271+ }
272+ </ul>
273+ }
274+ if c.Note != "" {
275+ <p class="mute">{ c.Note }</p>
276+ }
277+ </section>
278+}
A internal/adapters/web/actions_log.go +266 -0
@@ -0,0 +1,266 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "bnema.dev/ermite/internal/clientlimit"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// LogLineView is a line of a job log, as shown.
21+type LogLineView struct {
22+ N int64
23+ Step int32
24+ Text string
25+ Time string
26+ // Group marks "##[group]" and "##[endgroup]" lines and Error the
27+ // "##[error]" ones; the text is shown without the marker.
28+ Group, End, Error bool
29+ // Continued marks a piece of a line whose beginning is in an earlier batch
30+ // of the live feed: the page appends its text to the line before it.
31+ Continued bool
32+}
33+
34+// JobPage is one job with its steps and its log.
35+type JobPage struct {
36+ JobRow
37+ RunHref string
38+ RunName string
39+ Run string
40+ Steps []StepView
41+ Lines []LogLineView
42+ // From is the first line shown; Next is the link to the lines after the
43+ // last shown, "" when it is the end; Follow says the log is still being
44+ // written, so the page follows it live.
45+ From int64
46+ Next string
47+ Prev string
48+ Total int64
49+ Follow bool
50+ RawHref string
51+ // StreamHref is the live feed of the lines (logStream).
52+ StreamHref string
53+ Note string
54+}
55+
56+// appendLogLine adds the line n of a job log to the lines shown. The markers
57+// of the runner ("##[group]", "##[endgroup]", "##[error]") become structure and
58+// are not shown as text. A piece that continues a line too long to send whole
59+// is joined to the line before it; one that opens the list (its line is in an
60+// earlier batch) keeps Continued, and the page appends it to that line.
61+func appendLogLine(out []LogLineView, n int64, l ports.JobLogLine) []LogLineView {
62+ text := l.Text
63+ v := LogLineView{N: n, Step: l.Step, Time: l.Time.UTC().Format("15:04:05"), Continued: l.Continued}
64+ switch {
65+ case strings.HasPrefix(text, "##[group]"):
66+ v.Group, text = true, strings.TrimPrefix(text, "##[group]")
67+ case strings.HasPrefix(text, "##[endgroup]"):
68+ v.End, text = true, strings.TrimPrefix(text, "##[endgroup]")
69+ case strings.HasPrefix(text, "##[error]"):
70+ v.Error, text = true, strings.TrimPrefix(text, "##[error]")
71+ }
72+ v.Text = visibleText(text)
73+ if l.Continued && len(out) > 0 {
74+ out[len(out)-1].Text += v.Text
75+ return out
76+ }
77+ return append(out, v)
78+}
79+
80+// logClient is the key of the visitor for the limit of log streams per client.
81+func (s *Server) logClient(r *http.Request) string {
82+ return clientlimit.Key(clientlimit.ClientAddr(r, s.cfg.TrustedProxies))
83+}
84+
85+// readLog reads up to max lines from a line number, through the log stream of
86+// the use case, and returns the line after the last read.
87+func (s *Server) readLog(ctx context.Context, client string, repo domain.RepoName, j ports.JobView, from int64, max int) ([]LogLineView, int64, error) {
88+ if from > j.Lines || j.Lines == 0 {
89+ return nil, from, nil
90+ }
91+ var out []LogLineView
92+ next := from
93+ stop := errors.New("enough")
94+ err := s.cfg.Runs.GetJobLog(ctx, principal(ctx), repo, ports.GetJobLogParams{JobID: j.ID, Attempt: j.Attempt, Offset: from, Client: client}, func(b ports.LogBatch) error {
95+ for i, l := range b.Lines {
96+ if len(out) >= max && !l.Continued {
97+ return stop // the rest of a cut line still belongs to this page
98+ }
99+ out = appendLogLine(out, b.Offset+int64(i), l)
100+ next = b.Offset + int64(i) + 1
101+ }
102+ return nil
103+ })
104+ if err != nil && !errors.Is(err, stop) && !errors.Is(err, domain.ErrNotFound) {
105+ return nil, from, err
106+ }
107+ return out, next, nil
108+}
109+
110+// rawLog is GET /<repo>/actions/runs/<id>/job/<job id>/raw: the log as text,
111+// one line per line with its time, joined where the runner cut a long line.
112+func (s *Server) rawLog(w http.ResponseWriter, r *http.Request) {
113+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
114+ defer cancel()
115+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
116+ if err != nil {
117+ s.fail(ctx, w, r, err)
118+ return
119+ }
120+ id, err := parseRunPath(r)
121+ jobID := r.PathValue("job")
122+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
123+ s.fail(ctx, w, r, errNotFound)
124+ return
125+ }
126+ var attempt int32
127+ if a := r.URL.Query().Get("attempt"); a != "" {
128+ n, perr := strconv.ParseInt(a, 10, 32)
129+ if perr != nil || n < 1 {
130+ s.fail(ctx, w, r, errNotFound)
131+ return
132+ }
133+ attempt = int32(n)
134+ }
135+ // The job is read first: a job that is not there, or not readable, is a 404
136+ // before any header of the log is sent.
137+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt); err != nil {
138+ s.fail(ctx, w, r, err)
139+ return
140+ }
141+ h := w.Header()
142+ h.Set("Content-Type", "text/plain; charset=utf-8")
143+ h.Set("X-Content-Type-Options", "nosniff")
144+ h.Set("Content-Security-Policy", "default-src 'none'; sandbox")
145+ h.Set("Content-Disposition", fmt.Sprintf(`inline; filename="job-%s.log"`, jobID))
146+ cacheHeaders(ctx, h, "no-cache", sessionCache)
147+ written := 0
148+ open := false
149+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{JobID: jobID, Attempt: attempt, Client: s.logClient(r)}, func(b ports.LogBatch) error {
150+ for _, l := range b.Lines {
151+ if written > rawLogMaxBytes {
152+ return errors.New("log too large")
153+ }
154+ var line string
155+ switch {
156+ case l.Continued && open:
157+ line = l.Text
158+ case open:
159+ line = "\n" + l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
160+ default:
161+ line = l.Time.UTC().Format(time.RFC3339Nano) + " " + l.Text
162+ }
163+ open = true
164+ n, werr := fmt.Fprint(w, line)
165+ written += n
166+ if werr != nil {
167+ return werr
168+ }
169+ }
170+ return nil
171+ })
172+ if open {
173+ _, _ = fmt.Fprintln(w)
174+ }
175+ if err != nil && !open && errors.Is(err, domain.ErrNotFound) {
176+ w.WriteHeader(http.StatusNotFound)
177+ }
178+}
179+
180+// logClass is the class of a line of the log.
181+func logClass(ln LogLineView) string {
182+ switch {
183+ case ln.Group:
184+ return "lg group"
185+ case ln.End:
186+ return "lg endgroup"
187+ case ln.Error:
188+ return "lg error"
189+ }
190+ return "lg"
191+}
192+
193+// logStream is GET /<repo>/actions/runs/<id>/job/<job id>/stream?from=N: the
194+// lines of a job log from line N as Server-Sent Events, following the job while
195+// it runs. Each "ci-log" event holds the <li> of the lines of one batch and has
196+// the number of the next line as its id, so that a reconnection (Last-Event-ID)
197+// goes on where it stopped. "ci-end" tells the job is over and every line was
198+// sent. The job events of the repository stream carry no line (see events.go).
199+func (s *Server) logStream(w http.ResponseWriter, r *http.Request) {
200+ ctx, cancel := context.WithCancel(r.Context())
201+ defer cancel()
202+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
203+ if err != nil {
204+ s.denyEvents(ctx, w, err)
205+ return
206+ }
207+ id, err := parseRunPath(r)
208+ jobID := r.PathValue("job")
209+ if runOf, _, _, ok := domain.ParseJobID(jobID); err != nil || !ok || runOf != id {
210+ s.denyEvents(ctx, w, errNotFound)
211+ return
212+ }
213+ from := int64(0)
214+ if v := r.Header.Get("Last-Event-ID"); v != "" {
215+ from, err = strconv.ParseInt(v, 10, 64)
216+ } else if v := r.URL.Query().Get("from"); v != "" {
217+ from, err = strconv.ParseInt(v, 10, 64)
218+ }
219+ if err != nil || from < 0 {
220+ s.denyEvents(ctx, w, errNotFound)
221+ return
222+ }
223+ if _, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, 0); err != nil {
224+ s.denyEvents(ctx, w, err)
225+ return
226+ }
227+
228+ rc := http.NewResponseController(w)
229+ hdr := w.Header()
230+ hdr.Set("Content-Type", "text/event-stream")
231+ cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform")
232+ hdr.Set("X-Accel-Buffering", "no")
233+ w.WriteHeader(http.StatusOK)
234+ out := &sseWriter{w: w, rc: rc}
235+ if out.raw(": connected\n\n") != nil {
236+ return
237+ }
238+ n := from
239+ err = s.cfg.Runs.GetJobLog(ctx, principal(ctx), d.repo.Name, ports.GetJobLogParams{
240+ JobID: jobID, Offset: from, Follow: true,
241+ Client: s.logClient(r),
242+ }, func(b ports.LogBatch) error {
243+ var views []LogLineView
244+ for i, l := range b.Lines {
245+ views = appendLogLine(views, b.Offset+int64(i), l)
246+ }
247+ var html strings.Builder
248+ for _, v := range views {
249+ var b2 strings.Builder
250+ if err := LogLine(v).Render(ctx, &b2); err != nil {
251+ return err
252+ }
253+ html.WriteString(strings.ReplaceAll(b2.String(), "\n", ""))
254+ }
255+ n = b.Offset + int64(len(b.Lines))
256+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
257+ _, werr := io.WriteString(w, "event: ci-log\nid: "+strconv.FormatInt(n, 10)+"\ndata: "+html.String()+"\n\n")
258+ if werr != nil {
259+ return werr
260+ }
261+ return rc.Flush()
262+ })
263+ if err == nil || errors.Is(err, domain.ErrNotFound) {
264+ _ = out.raw("event: ci-end\nid: " + strconv.FormatInt(n, 10) + "\ndata: end\n\n")
265+ }
266+}
A internal/adapters/web/actions_pages.go +468 -0
@@ -0,0 +1,468 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "fmt"
8+ "net/http"
9+ "strconv"
10+ "strings"
11+ "time"
12+
13+ "github.com/a-h/templ"
14+
15+ "bnema.dev/ermite/internal/domain"
16+ "bnema.dev/ermite/internal/ports"
17+)
18+
19+// The CI pages (ADR 0025): the runs of a repository, one run with its jobs,
20+// one job with its steps and its log. They read through ports.RunService and
21+// follow the events RunUpdated and JobUpdated of the repository topic.
22+//
23+// GET /<repo>/actions the runs, newest first
24+// GET /<repo>/actions/runs/<id> one run: jobs and steps
25+// GET /<repo>/actions/runs/<id>/job/<job id> one job: steps and live log
26+// GET /<repo>/actions/runs/<id>/job/<job id>/raw the log as text
27+// POST /<repo>/actions/runs/<id>/cancel | rerun | rerun-failed (the owner)
28+
29+const (
30+ // actionsPageSize is the number of runs one page lists.
31+ actionsPageSize = 30
32+ // jobLogPageLines is the number of log lines a job page shows at once; the
33+ // rest is reached with ?from=.
34+ jobLogPageLines = 2000
35+ // rawLogMaxBytes bounds the raw log the server writes to one response.
36+ rawLogMaxBytes = 64 << 20
37+)
38+
39+func actionsBase(repo string) string { return repoHref(repo, "actions") }
40+func runHref(repo string, id int64) string {
41+ return repoHref(repo, "actions", "runs", strconv.FormatInt(id, 10))
42+}
43+func jobHref(repo string, runID int64, jobID string) string {
44+ return runHref(repo, runID) + "/job/" + jobID
45+}
46+
47+// ciState is the one word shown for a status and a conclusion: what is going
48+// on, or how it ended.
49+func ciState(status domain.CIStatus, c domain.CIConclusion) string {
50+ switch status {
51+ case domain.CIQueued:
52+ return "queued"
53+ case domain.CIInProgress:
54+ return "running"
55+ }
56+ switch c {
57+ case domain.CISuccess:
58+ return "success"
59+ case domain.CIFailure:
60+ return "failure"
61+ case domain.CICancelled:
62+ return "cancelled"
63+ case domain.CISkipped:
64+ return "skipped"
65+ case domain.CITimedOut:
66+ return "timed out"
67+ case domain.CINeutral:
68+ return "neutral"
69+ case domain.CIActionRequired:
70+ return "action required"
71+ }
72+ return "done"
73+}
74+
75+// ciKind groups the states into the few that have an icon and a colour.
76+func ciKind(status domain.CIStatus, c domain.CIConclusion) string {
77+ switch {
78+ case status == domain.CIQueued:
79+ return "queued"
80+ case status == domain.CIInProgress:
81+ return "running"
82+ case c == domain.CISuccess, c == domain.CINeutral:
83+ return "ok"
84+ case c == domain.CISkipped:
85+ return "skipped"
86+ case c == domain.CICancelled:
87+ return "cancelled"
88+ }
89+ return "bad"
90+}
91+
92+// duration is a short elapsed time between two instants; empty when the
93+// start is unknown, running up to now when the end is not.
94+func duration(start, end, now time.Time) string {
95+ if start.IsZero() {
96+ return ""
97+ }
98+ if end.IsZero() {
99+ end = now
100+ }
101+ d := end.Sub(start).Round(time.Second)
102+ switch {
103+ case d < 0:
104+ return ""
105+ case d < time.Minute:
106+ return fmt.Sprintf("%ds", int(d.Seconds()))
107+ case d < time.Hour:
108+ return fmt.Sprintf("%dm %02ds", int(d.Minutes()), int(d.Seconds())%60)
109+ }
110+ return fmt.Sprintf("%dh %02dm", int(d.Hours()), int(d.Minutes())%60)
111+}
112+
113+// RunRow is a run of the list.
114+type RunRow struct {
115+ ID string
116+ Href string
117+ Kind string // ciKind
118+ State string // ciState
119+ Workflow string
120+ Title string
121+ Ref string
122+ RefHref string
123+ Short string
124+ CHref string
125+ Actor string
126+ When string
127+ WhenAt string
128+ Duration string
129+ Number string
130+ Attempt int32
131+}
132+
133+// ActionsPage lists the runs of a repository.
134+type ActionsPage struct {
135+ Runs []RunRow
136+ // More is the link to the next page, "" on the last.
137+ More string
138+ // NoRunner says that CI has no runner to run on yet.
139+ NoRunner bool
140+}
141+
142+// StepView is a step of a job.
143+type StepView struct {
144+ Number int32
145+ Name string
146+ Kind, State string
147+ Duration string
148+ // LogHref jumps to the lines of the step in the job log.
149+ LogStart, LogEnd int64
150+}
151+
152+// JobRow is a job of a run.
153+type JobRow struct {
154+ ID, Name, Href string
155+ Kind, State string
156+ Runner string
157+ Isolation string
158+ Duration string
159+ Error string
160+ Needs []string
161+ Steps []StepView
162+ Attempt int32
163+ // Waiting says that the job is queued for a runner that no registered
164+ // runner can be.
165+ Waiting bool
166+}
167+
168+// RunPage is one run with its jobs.
169+type RunPage struct {
170+ RunRow
171+ Sha, Error string
172+ Attempt int32
173+ Latest int32
174+ Attempts []AttemptLink
175+ Jobs []JobRow
176+ Active bool
177+ // Forms are the owner's actions; nil/empty when the visitor cannot.
178+ CancelAction, RerunAction, RerunFailedAction string
179+ CanRerunFailed bool
180+}
181+
182+// AttemptLink switches between the attempts of a run.
183+type AttemptLink struct {
184+ Label, Href string
185+ Current bool
186+}
187+
188+// actions is GET /<repo>/actions.
189+func (s *Server) actions(ctx context.Context, r *http.Request) (templ.Component, int, error) {
190+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
191+ if err != nil {
192+ return nil, 0, err
193+ }
194+ token := r.URL.Query().Get("page")
195+ page, err := s.cfg.Runs.ListRuns(ctx, principal(ctx), d.repo.Name, ports.ListRunsParams{PageSize: actionsPageSize, PageToken: token})
196+ if err != nil {
197+ return nil, 0, err
198+ }
199+ now := s.cfg.Now()
200+ p := ActionsPage{}
201+ for _, run := range page.Runs {
202+ p.Runs = append(p.Runs, s.runRow(d.name(), run, now))
203+ }
204+ if page.NextPageToken != "" {
205+ p.More = actionsBase(d.name()) + "?page=" + page.NextPageToken
206+ }
207+ l := s.repoLayout(ctx, d, "actions")
208+ l.Title = "actions · " + d.name() + " · " + s.instance
209+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=actions")
210+ l.Crumbs[0].Current = false
211+ l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Current: true})
212+ return Actions(l, p), http.StatusOK, nil
213+}
214+
215+func (s *Server) runRow(repo string, run domain.CIRun, now time.Time) RunRow {
216+ row := RunRow{
217+ ID: strconv.FormatInt(run.ID, 10), Href: runHref(repo, run.ID),
218+ Kind: ciKind(run.Status, run.Conclusion), State: ciState(run.Status, run.Conclusion),
219+ Workflow: visibleText(run.WorkflowName), Title: visibleText(run.Title),
220+ Ref: visibleText(strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/")),
221+ Short: short(run.Sha), CHref: repoHref(repo, "commit", run.Sha), Actor: visibleText(run.Actor),
222+ Duration: duration(run.StartedAt, run.CompletedAt, now), Number: "#" + strconv.FormatInt(run.Number, 10), Attempt: run.Attempt,
223+ }
224+ row.RefHref = repoHref(repo, "tree", strings.TrimPrefix(strings.TrimPrefix(run.Ref, "refs/heads/"), "refs/tags/"))
225+ row.When, row.WhenAt = whenOf(now, run.CreatedAt)
226+ return row
227+}
228+
229+func stepViews(j ports.JobView, now time.Time) []StepView {
230+ ranges := j.StepRanges()
231+ out := make([]StepView, 0, len(j.Steps))
232+ for i, st := range j.Steps {
233+ status := domain.CIQueued
234+ switch st.State {
235+ case domain.CIStepInProgress:
236+ status = domain.CIInProgress
237+ case domain.CIStepCompleted:
238+ status = domain.CICompleted
239+ }
240+ out = append(out, StepView{
241+ Number: st.Number, Name: visibleText(st.Name), Kind: ciKind(status, st.Conclusion), State: ciState(status, st.Conclusion),
242+ Duration: duration(st.StartedAt, st.CompletedAt, now), LogStart: ranges[i].Start, LogEnd: ranges[i].End,
243+ })
244+ }
245+ return out
246+}
247+
248+func (s *Server) jobRow(repo string, j ports.JobView, now time.Time) JobRow {
249+ return JobRow{
250+ ID: j.ID, Name: visibleText(j.Name), Href: jobHref(repo, j.RunID, j.ID), Kind: ciKind(j.Status(), j.Conclusion),
251+ State: ciState(j.Status(), j.Conclusion), Runner: visibleText(j.RunnerName), Isolation: string(j.Isolation),
252+ Duration: duration(j.StartedAt, j.CompletedAt, now), Error: visibleText(j.Error), Needs: j.Needs,
253+ Steps: stepViews(j, now), Attempt: j.Attempt,
254+ }
255+}
256+
257+func parseRunPath(r *http.Request) (int64, error) {
258+ id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
259+ if err != nil || id < 1 {
260+ return 0, errNotFound
261+ }
262+ return id, nil
263+}
264+
265+// run is GET /<repo>/actions/runs/<id>.
266+func (s *Server) run(ctx context.Context, r *http.Request) (templ.Component, int, error) {
267+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
268+ if err != nil {
269+ return nil, 0, err
270+ }
271+ id, err := parseRunPath(r)
272+ if err != nil {
273+ return nil, 0, err
274+ }
275+ var attempt int32
276+ if a := r.URL.Query().Get("attempt"); a != "" {
277+ n, perr := strconv.ParseInt(a, 10, 32)
278+ if perr != nil || n < 1 {
279+ return nil, 0, errNotFound
280+ }
281+ attempt = int32(n)
282+ }
283+ p, err := s.runPage(ctx, d, id, attempt)
284+ if err != nil {
285+ return nil, 0, err
286+ }
287+ l := s.repoLayout(ctx, d, "actions")
288+ l.Title = "run " + p.ID + " · " + d.name() + " · " + s.instance
289+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=run&n="+p.ID)
290+ l.Crumbs[0].Current = false
291+ l.Crumbs = append(l.Crumbs, Crumb{Label: "actions", Href: actionsBase(d.name())}, Crumb{Label: p.Workflow + " " + p.Number, Current: true})
292+ l.HasRail = true
293+ return RunDetail(l, p), http.StatusOK, nil
294+}
295+
296+//hexcheck:ignore suspicious-business-logic-in-adapter builds the run page from the use case results; no rule is decided here
297+func (s *Server) runPage(ctx context.Context, d *repoData, id int64, attempt int32) (RunPage, error) {
298+ detail, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, attempt)
299+ if err != nil {
300+ return RunPage{}, err
301+ }
302+ now := s.cfg.Now()
303+ run := detail.Run
304+ p := RunPage{
305+ RunRow: s.runRow(d.name(), run, now), Sha: run.Sha, Error: visibleText(run.Error),
306+ Attempt: run.Attempt, Latest: detail.LatestAttempt, Active: run.Status != domain.CICompleted,
307+ }
308+ for a := detail.LatestAttempt; a >= 1 && detail.LatestAttempt > 1; a-- {
309+ p.Attempts = append(p.Attempts, AttemptLink{
310+ Label: "attempt " + strconv.Itoa(int(a)), Href: runHref(d.name(), id) + "?attempt=" + strconv.Itoa(int(a)), Current: a == run.Attempt,
311+ })
312+ }
313+ for _, j := range detail.Jobs {
314+ row := s.jobRow(d.name(), j, now)
315+ if run.Attempt == detail.LatestAttempt {
316+ // An earlier attempt keeps the job as it ran then.
317+ row.Href = jobHref(d.name(), id, j.ID)
318+ }
319+ p.Jobs = append(p.Jobs, row)
320+ }
321+ if SignedIn(ctx) && run.Attempt == detail.LatestAttempt {
322+ base := runHref(d.name(), id)
323+ if p.Active {
324+ p.CancelAction = base + "/cancel"
325+ } else if len(detail.Jobs) > 0 {
326+ p.RerunAction = base + "/rerun"
327+ for _, j := range detail.Jobs {
328+ if j.Conclusion.IsFailure() || j.Conclusion == domain.CICancelled {
329+ p.RerunFailedAction, p.CanRerunFailed = base+"/rerun-failed", true
330+ break
331+ }
332+ }
333+ }
334+ }
335+ return p, nil
336+}
337+
338+// job is GET /<repo>/actions/runs/<id>/job/<job id>.
339+func (s *Server) job(ctx context.Context, r *http.Request) (templ.Component, int, error) {
340+ d, err := s.loadRepo(ctx, r.PathValue("repo"))
341+ if err != nil {
342+ return nil, 0, err
343+ }
344+ id, err := parseRunPath(r)
345+ if err != nil {
346+ return nil, 0, err
347+ }
348+ jobID := r.PathValue("job")
349+ if runOf, _, _, ok := domain.ParseJobID(jobID); !ok || runOf != id {
350+ return nil, 0, errNotFound
351+ }
352+ var attempt int32
353+ if a := r.URL.Query().Get("attempt"); a != "" {
354+ n, perr := strconv.ParseInt(a, 10, 32)
355+ if perr != nil || n < 1 {
356+ return nil, 0, errNotFound
357+ }
358+ attempt = int32(n)
359+ }
360+ view, err := s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, jobID, attempt)
361+ if err != nil {
362+ return nil, 0, err
363+ }
364+ run, err := s.cfg.Runs.GetRun(ctx, principal(ctx), d.repo.Name, id, 0)
365+ if err != nil {
366+ return nil, 0, err
367+ }
368+ now := s.cfg.Now()
369+ var from int64
370+ if f := r.URL.Query().Get("from"); f != "" {
371+ from, err = strconv.ParseInt(f, 10, 64)
372+ if err != nil || from < 0 {
373+ return nil, 0, errNotFound
374+ }
375+ }
376+ p := JobPage{
377+ JobRow: s.jobRow(d.name(), view, now), RunHref: runHref(d.name(), id), RunName: visibleText(run.Run.WorkflowName),
378+ Run: strconv.FormatInt(id, 10), Steps: stepViews(view, now), From: from, Total: view.Lines,
379+ RawHref: jobHref(d.name(), id, jobID) + "/raw",
380+ }
381+ if attempt > 0 {
382+ p.RawHref += "?attempt=" + strconv.Itoa(int(attempt))
383+ }
384+ p.Follow = !view.Ended()
385+ p.StreamHref = jobHref(d.name(), id, jobID) + "/stream"
386+ lines, next, err := s.readLog(ctx, s.logClient(r), d.repo.Name, view, from, jobLogPageLines)
387+ if err != nil {
388+ return nil, 0, err
389+ }
390+ p.Lines = lines
391+ if next < view.Lines {
392+ p.Next = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(next, 10)
393+ p.Follow = false // more is waiting before the live end
394+ }
395+ if from > 0 {
396+ p.Prev = jobHref(d.name(), id, jobID) + "?from=" + strconv.FormatInt(max(from-jobLogPageLines, 0), 10)
397+ }
398+ if view.Phase == domain.CIPhaseWaiting || view.Phase == domain.CIPhaseReady {
399+ p.Note = "the job has not started: no log yet."
400+ } else if view.Lines == 0 && view.Ended() {
401+ p.Note = "the job wrote no log."
402+ }
403+ l := s.repoLayout(ctx, d, "actions")
404+ l.Title = view.Name + " · run " + p.Run + " · " + d.name() + " · " + s.instance
405+ l.Live = true
406+ l.LiveURL = liveURL("repo:"+d.name(), "view=job&job="+jobID)
407+ l.Crumbs[0].Current = false
408+ l.Crumbs = append(l.Crumbs,
409+ Crumb{Label: "actions", Href: actionsBase(d.name())},
410+ Crumb{Label: p.RunName + " #" + strconv.FormatInt(run.Run.Number, 10), Href: p.RunHref},
411+ Crumb{Label: p.Name, Current: true})
412+ l.HasRail = true
413+ return Job(l, p), http.StatusOK, nil
414+}
415+
416+// ciAction wraps a POST of the actions pages: the form gate, the bounded
417+// context and the mapping of a refusal to a page.
418+func (s *Server) ciAction(h func(ctx context.Context, r *http.Request) (to string, err error)) http.HandlerFunc {
419+ return s.mrAction(func(ctx context.Context, r *http.Request) (string, string, error) {
420+ to, err := h(ctx, r)
421+ return to, actionsBase(r.PathValue("repo")), err
422+ })
423+}
424+
425+func (s *Server) runCancel(ctx context.Context, r *http.Request) (string, error) {
426+ name, id, err := s.runTarget(r)
427+ if err != nil {
428+ return "", err
429+ }
430+ if _, err := s.cfg.Runs.CancelRun(ctx, principal(ctx), name, id); err != nil {
431+ return "", err
432+ }
433+ return runHref(name.String(), id), nil
434+}
435+
436+func (s *Server) runRerun(failedOnly bool) func(ctx context.Context, r *http.Request) (string, error) {
437+ return func(ctx context.Context, r *http.Request) (string, error) {
438+ name, id, err := s.runTarget(r)
439+ if err != nil {
440+ return "", err
441+ }
442+ if _, err := s.cfg.Runs.RerunRun(ctx, principal(ctx), name, id, failedOnly); err != nil {
443+ return "", err
444+ }
445+ return runHref(name.String(), id), nil
446+ }
447+}
448+
449+func (s *Server) runTarget(r *http.Request) (domain.RepoName, int64, error) {
450+ name, err := domain.ParseRepoName(r.PathValue("repo"))
451+ if err != nil {
452+ return domain.RepoName{}, 0, errNotFound
453+ }
454+ id, err := parseRunPath(r)
455+ return name, id, err
456+}
457+
458+func itoa64(n int64) string { return strconv.FormatInt(n, 10) }
459+
460+// runnerText names the runner of a job and how it isolated it.
461+func runnerText(j JobRow) string {
462+ if j.Isolation == "" {
463+ return j.Runner
464+ }
465+ return j.Runner + " (" + j.Isolation + ")"
466+}
467+
468+func joinWords(ws []string) string { return strings.Join(ws, ", ") }
A internal/adapters/web/actions_templ.go +2022 -0 generated file: collapsed
A internal/adapters/web/actions_test.go +112 -0
@@ -0,0 +1,112 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "net/http"
8+ "net/http/httptest"
9+ "net/netip"
10+ "testing"
11+ "time"
12+
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/clientlimit"
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+ "bnema.dev/ermite/internal/ports/mocks"
21+)
22+
23+// S6: every page that streams a log through the use case names the visitor, so
24+// that the limit of log streams per client applies to the pages too, behind a
25+// trusted proxy by the address it forwards.
26+func TestLogPagesPassTheClientKey(t *testing.T) {
27+ t.Parallel()
28+ const jobID = "1-0-0"
29+ repo := repoName(t, "proj")
30+ job := ports.JobView{CIJob: domain.CIJob{
31+ ID: jobID, RunID: 1, Attempt: 1, RunAttempt: 1, Repo: repo, Key: "build", Name: "build",
32+ Phase: domain.CIPhaseEnded, Conclusion: domain.CISuccess,
33+ }, Lines: 1}
34+ want := clientlimit.Key("198.51.100.7")
35+
36+ runs := mocks.NewRunService(t)
37+ runs.EXPECT().GetJob(mock.Anything, mock.Anything, repo, jobID, int32(0)).Return(job, nil).Maybe()
38+ runs.EXPECT().GetRun(mock.Anything, mock.Anything, repo, int64(1), int32(0)).Return(ports.RunDetail{
39+ Run: domain.CIRun{Repo: repo, ID: 1, Attempt: 1}, LatestAttempt: 1,
40+ }, nil).Maybe()
41+ var seen []string
42+ runs.EXPECT().GetJobLog(mock.Anything, mock.Anything, repo, mock.Anything, mock.Anything).RunAndReturn(
43+ func(_ context.Context, _ domain.Principal, _ domain.RepoName, p ports.GetJobLogParams, send func(ports.LogBatch) error) error {
44+ seen = append(seen, p.Client)
45+ return send(ports.LogBatch{Lines: []ports.JobLogLine{{Step: 1, CILogLine: domain.CILogLine{Time: time.Unix(0, 0), Text: "x"}}}})
46+ })
47+
48+ browse := mocks.NewBrowseService(t)
49+ browse.EXPECT().ListRefs(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.RefPage{}, nil).Maybe()
50+ srv := newTestServer(t, Config{
51+ Repos: reposServing(t, publicRepo(t, "proj")), Browse: browse, Runs: runs,
52+ TrustedProxies: []netip.Prefix{netip.MustParsePrefix("192.0.2.0/24")},
53+ })
54+ for _, path := range []string{
55+ "/proj/actions/runs/1/job/" + jobID,
56+ "/proj/actions/runs/1/job/" + jobID + "/raw",
57+ "/proj/actions/runs/1/job/" + jobID + "/stream",
58+ } {
59+ req := httptest.NewRequest(http.MethodGet, path, nil)
60+ req.RemoteAddr = "192.0.2.1:4000"
61+ req.Header.Set("X-Forwarded-For", "198.51.100.7")
62+ rec := httptest.NewRecorder()
63+ srv.ServeHTTP(rec, req)
64+ require.Equal(t, http.StatusOK, rec.Code, path)
65+ }
66+ require.Len(t, seen, 3)
67+ for _, c := range seen {
68+ assert.Equal(t, want, c)
69+ }
70+}
71+
72+// M4: one helper shapes the lines of the page, the raw feed and the live feed.
73+func TestAppendLogLine(t *testing.T) {
74+ t.Parallel()
75+ at := time.Date(2026, 10, 12, 10, 11, 12, 0, time.UTC)
76+ line := func(text string, cont bool) ports.JobLogLine {
77+ return ports.JobLogLine{Step: 2, CILogLine: domain.CILogLine{Time: at, Text: text, Continued: cont}}
78+ }
79+ var out []LogLineView
80+ out = appendLogLine(out, 10, line("##[group]Run make", false))
81+ out = appendLogLine(out, 11, line("plain <b>", false))
82+ out = appendLogLine(out, 12, line("##[error]Process completed with exit code 1.", false))
83+ out = appendLogLine(out, 13, line("##[endgroup]", false))
84+ require.Len(t, out, 4)
85+ assert.True(t, out[0].Group)
86+ assert.Equal(t, "Run make", out[0].Text, "the marker is structure, not text")
87+ assert.EqualValues(t, 10, out[0].N)
88+ assert.Equal(t, "10:11:12", out[0].Time)
89+ assert.EqualValues(t, 2, out[0].Step)
90+ assert.False(t, out[1].Group || out[1].Error || out[1].End)
91+ assert.True(t, out[2].Error)
92+ assert.Equal(t, "Process completed with exit code 1.", out[2].Text)
93+ assert.True(t, out[3].End)
94+
95+ // A cut line comes back as one line, with the number of its first piece.
96+ var cut []LogLineView
97+ cut = appendLogLine(cut, 20, line("first half, ", false))
98+ cut = appendLogLine(cut, 21, line("second half", true))
99+ require.Len(t, cut, 1)
100+ assert.Equal(t, "first half, second half", cut[0].Text)
101+ assert.EqualValues(t, 20, cut[0].N)
102+
103+ // A piece that opens a batch has no line before it here: it stays, marked,
104+ // for the page to join to the line it already holds.
105+ open := appendLogLine(nil, 30, line("rest", true))
106+ require.Len(t, open, 1)
107+ assert.True(t, open[0].Continued)
108+
109+ // Invisible characters of the runner's text are shown, never applied.
110+ hidden := appendLogLine(nil, 40, line("a\u202eb", false))
111+ assert.NotContains(t, hidden[0].Text, "\u202e")
112+}
M internal/adapters/web/assets.go +2 -1
@@ -23,7 +23,7 @@ const StaticPrefix = "/-/static/"
2323 // Assets are the URLs of the assets a page loads. Their names carry a hash of
2424 // the content, so they can be cached for ever.
2525 type Assets struct {
26- CSS, HTMX, Live, SSE, App, Files, Icon string
26+ CSS, HTMX, Live, SSE, App, Files, Log, Icon string
2727 // Font is the UI font, preloaded by every page.
2828 Font string
2929 // Rules is the speculation rules file named by the Speculation-Rules header.
@@ -56,6 +56,7 @@ var scripts = []struct {
5656 {"hx-sse.min.js", func(a *Assets, u string) { a.SSE = u }},
5757 {"ermite.js", func(a *Assets, u string) { a.App = u }},
5858 {"files.js", func(a *Assets, u string) { a.Files = u }},
59+ {"log.js", func(a *Assets, u string) { a.Log = u }},
5960 }
6061
6162 // sheetAssets are the files the stylesheet references as url(<dir>/<file>):
A internal/adapters/web/checks.go +106 -0
@@ -0,0 +1,106 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+
10+ "github.com/bnema/zerowrap"
11+
12+ "bnema.dev/ermite/internal/domain"
13+ "bnema.dev/ermite/internal/ports"
14+)
15+
16+// ChecksSummary is the combined state of the checks of a commit.
17+type ChecksSummary struct {
18+ Total int
19+ Kind, State string
20+ Label, Title string
21+ Href string
22+}
23+
24+// CheckRow is a check of a commit or a merge request.
25+type CheckRow struct {
26+ Name, Kind, State, Href string
27+ Waiting bool
28+}
29+
30+// ChecksSection is the checks of the head of a merge request.
31+type ChecksSection struct {
32+ Rows []CheckRow
33+ // Note explains the rule that applies to them.
34+ Note string
35+}
36+
37+// checksFor reads the checks of a commit as the caller of the request. A page
38+// shows them when it can: a failure of the read is logged and the page goes on
39+// without, since the code and the review do not depend on CI.
40+func (s *Server) checksFor(ctx context.Context, repo domain.RepoName, sha string) []ports.CheckView {
41+ if s.cfg.Runs == nil || sha == "" {
42+ return nil
43+ }
44+ res, err := s.cfg.Runs.ListChecks(ctx, principal(ctx), repo, sha)
45+ if err != nil {
46+ if !errors.Is(err, domain.ErrNotFound) {
47+ log := zerowrap.FromCtx(ctx)
48+ log.Warn().Err(err).Str("repo", repo.String()).Msg("read the checks of a commit")
49+ }
50+ return nil
51+ }
52+ return res.Checks
53+}
54+
55+// checksSummary reduces checks to the mark a list shows next to a commit.
56+func checksSummary(repo domain.RepoName, sha string, checks []ports.CheckView) ChecksSummary {
57+ if len(checks) == 0 {
58+ return ChecksSummary{}
59+ }
60+ plain := make([]domain.CICheck, 0, len(checks))
61+ passed := 0
62+ for _, c := range checks {
63+ plain = append(plain, c.CICheck)
64+ if c.Status == domain.CICompleted && c.Conclusion.PassesMerge() {
65+ passed++
66+ }
67+ }
68+ sum := ChecksSummary{Total: len(checks), Href: repoHref(repo.String(), "actions"), Label: fmt.Sprintf("%d/%d", passed, len(checks))}
69+ switch domain.CombineChecks(plain) {
70+ case domain.CheckStateSuccess:
71+ sum.Kind, sum.State = "ok", "checks passed"
72+ case domain.CheckStateFailure:
73+ sum.Kind, sum.State = "bad", "checks failed"
74+ default:
75+ sum.Kind, sum.State = "running", "checks pending"
76+ }
77+ sum.Title = sum.State + ": " + sum.Label
78+ return sum
79+}
80+
81+// checksSection is the checks of the rail of a merge request, with the rule of
82+// the repository that applies to them.
83+func checksSection(repo domain.Repo, checks []ports.CheckView) ChecksSection {
84+ sec := ChecksSection{}
85+ for _, c := range checks {
86+ row := CheckRow{
87+ Name: visibleText(c.Name), Kind: ciKind(c.Status, c.Conclusion), State: ciState(c.Status, c.Conclusion),
88+ Waiting: c.WaitingForRunner,
89+ }
90+ if c.JobID != "" {
91+ row.Href = jobHref(repo.Name.String(), c.RunID, c.JobID)
92+ } else {
93+ row.Href = runHref(repo.Name.String(), c.RunID)
94+ }
95+ sec.Rows = append(sec.Rows, row)
96+ }
97+ switch {
98+ case len(checks) == 0:
99+ sec.Note = "no check on this commit: nothing blocks the merge."
100+ case repo.Settings.FailingChecksBlock:
101+ sec.Note = "a failing or unfinished check blocks the merge."
102+ default:
103+ sec.Note = "checks do not block the merge in this repository."
104+ }
105+ return sec
106+}
M internal/adapters/web/commit.go +3 -0
@@ -44,6 +44,8 @@ type CommitPage struct {
4444 // Invisible is true when some line holds an invisible character, shown as a
4545 // marker.
4646 Invisible bool
47+ // Checks is the combined state of the CI checks of the commit.
48+ Checks ChecksSummary
4749 }
4850
4951 // DiffView is how a page lays its diff out: the layouts in the page and the
@@ -138,6 +140,7 @@ func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component,
138140 }
139141 }
140142 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
143+ p.Checks = checksSummary(d.repo.Name, cid, s.checksFor(ctx, d.repo.Name, cid))
141144
142145 lines, invisible := diffLines(p.Files)
143146 p.Invisible = invisible
M internal/adapters/web/commits.go +9 -2
@@ -73,7 +73,7 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
7373 if token != "" {
7474 p.Newest = base
7575 }
76- s.fillDays(&p, d, cs)
76+ s.fillDays(ctx, &p, d, cs)
7777 if sel.path != "" && token == "" {
7878 p.Open = s.pathLink(ctx, d, sel)
7979 }
@@ -95,14 +95,21 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
9595 return Commits(l, p), http.StatusOK, nil
9696 }
9797
98+// maxCheckLookups bounds the commits of one page whose checks are read: each
99+// is one read of the store.
100+const maxCheckLookups = 100
101+
98102 // fillDays groups the commits of a page by day and lists the refs among them.
99-func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []domain.Commit) {
103+func (s *Server) fillDays(ctx context.Context, p *CommitsPage, d *repoData, cs []domain.Commit) {
100104 now := s.cfg.Now()
101105 branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags)
102106 for i, c := range cs {
103107 row := commitRow(d.name(), c, now)
104108 row.ID, row.Cursor = "c-"+row.Short, i == 0
105109 row.Branches, row.Tags = branchesAt[c.ID.String()], tagsAt[c.ID.String()]
110+ if i < maxCheckLookups {
111+ row.Checks = checksSummary(d.repo.Name, c.ID.String(), s.checksFor(ctx, d.repo.Name, c.ID.String()))
112+ }
106113 day := "unknown date"
107114 if t := c.Committer.Time; !t.IsZero() {
108115 day = t.UTC().Format("2006-01-02")
M internal/adapters/web/components.templ +1 -0
@@ -112,6 +112,7 @@ templ CommitRows(rows []CommitRow) {
112112 <span class="pill tag"><span class="sr">tag </span>{ t }</span>
113113 }
114114 </td>
115+ <td class="c-ck">@CommitChecks(c.Checks)</td>
115116 <td class="c-who dim">{ c.Author }</td>
116117 <td class="c-when nowrap dim"><time datetime={ c.WhenAt } title={ c.WhenAt }>{ c.When }</time></td>
117118 </tr>
M internal/adapters/web/components_templ.go +18 -10 generated file: collapsed
M internal/adapters/web/diff.templ +4 -0
@@ -40,6 +40,10 @@ templ Commit(l Layout, p CommitPage) {
4040 { " · diff against the first parent" }
4141 }
4242 </dd>
43+ if p.Checks.Total > 0 {
44+ <dt>checks</dt>
45+ <dd>@CommitChecks(p.Checks)</dd>
46+ }
4347 if len(p.Refs) > 0 {
4448 <dt>refs</dt>
4549 <dd>
M internal/adapters/web/diff_templ.go +180 -166 generated file: collapsed
M internal/adapters/web/events.go +9 -1
@@ -31,6 +31,9 @@ import (
3131 // GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
3232 // GET /-/events?topic=repo:<name>&view=mrs
3333 // GET /-/events?topic=repo:<name>&view=mr&n=<number>&ref=<source branch>
34+// GET /-/events?topic=repo:<name>&view=actions
35+// GET /-/events?topic=repo:<name>&view=run&n=<run id>
36+// GET /-/events?topic=repo:<name>&view=job&job=<job id>
3437 //
3538 // topic is the Watch topic. view says which fragments of a repository the
3639 // page shows, so that the server renders only those (summary when absent).
@@ -50,6 +53,11 @@ import (
5053 // commits-head data: <div id="commits-head">N new commits — reload</div>
5154 // mr-notice data: <div id="mr-notice">…</div>, on the merge request pages:
5255 // something changed, with the link that reloads the page
56+// ci-run data: <tr id="run-<id>">…</tr> (the list of runs) or
57+// <div id="run-body">…</div> (one run)
58+// ci-job data: <h2 id="job-head">…</h2> and <ol id="job-steps">…</ol>; the log
59+// itself is never sent in this stream: the page follows it on its own
60+// feed, /<repo>/actions/runs/<id>/job/<job>/stream?from=<line>
5361 // gap data: gap; events were lost, or what the page shows is gone or
5462 // changed: the page fetches itself again (ermiteLive in ermite.js)
5563 //
@@ -194,7 +202,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
194202 return
195203 }
196204
197- st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n")}
205+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head"), n: q.Get("n"), job: q.Get("job")}
198206 handle := func(m recvd) bool {
199207 if m.err != nil {
200208 if ctx.Err() == nil && !errors.Is(m.err, context.Canceled) {
M internal/adapters/web/fragments.go +105 -2
@@ -7,6 +7,7 @@ import (
77 "context"
88 "errors"
99 "strconv"
10+ "strings"
1011 "sync"
1112 "time"
1213
@@ -23,7 +24,8 @@ type liveStream struct {
2324 view string // "summary", "refs" or "commits"
2425 ref string // commits: the branch listed
2526 head string // commits: the commit listed first
26- n string // mr: the number of the merge request shown
27+ n string // mr: the number of the merge request shown; run: the run id
28+ job string // job: the id of the job shown
2729 }
2830
2931 // renderFragment renders a component to the data of an event.
@@ -46,7 +48,8 @@ func liveEvent(we ports.WatchEvent) bool {
4648 }
4749 switch we.Event.(type) {
4850 case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
49- domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded:
51+ domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded,
52+ domain.RunUpdated, domain.JobUpdated:
5053 return true
5154 }
5255 return false
@@ -117,6 +120,10 @@ func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error)
117120 // repoPage handles the events of the topic of one repository.
118121 func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
119122 switch ev := we.Event.(type) {
123+ case domain.RunUpdated:
124+ return l.runEvent(ctx, ev)
125+ case domain.JobUpdated:
126+ return l.jobEvent(ctx, ev)
120127 case domain.RepoDeleted, domain.RepoCreated:
121128 // Gone, or a new repository under the same name: whatever the page
122129 // shows is not true any more.
@@ -395,3 +402,99 @@ func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment,
395402 f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))
396403 return []fragment{f}, err
397404 }
405+
406+// runEvent updates the pages of CI that a RunUpdated changes: the row of the
407+// list of runs, or the body of the run page shown. Only a state is sent: the
408+// events carry no log and no text of the workflow.
409+func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {
410+ if l.s.cfg.Runs == nil {
411+ return nil, nil
412+ }
413+ switch l.view {
414+ case "actions":
415+ f, err := renderFragment(ctx, "ci-run", RunListRow(l.s.runRow(l.repo, ev.Run, l.s.cfg.Now())))
416+ if err != nil {
417+ return nil, err
418+ }
419+ if ev.Run.Status == domain.CIQueued {
420+ f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)
421+ }
422+ return []fragment{f}, nil
423+ case "run":
424+ if strconv.FormatInt(ev.Run.ID, 10) != l.n {
425+ return nil, nil
426+ }
427+ return l.runBody(ctx)
428+ }
429+ return nil, nil
430+}
431+
432+// jobEvent updates the body of the run page and the steps of the job page.
433+func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {
434+ if l.s.cfg.Runs == nil {
435+ return nil, nil
436+ }
437+ switch l.view {
438+ case "run":
439+ if strconv.FormatInt(ev.Job.RunID, 10) != l.n {
440+ return nil, nil
441+ }
442+ return l.runBody(ctx)
443+ case "job":
444+ if ev.Job.ID != l.job {
445+ return nil, nil
446+ }
447+ return l.jobState(ctx)
448+ }
449+ return nil, nil
450+}
451+
452+// runBody renders again the jobs of the run page.
453+func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) {
454+ id, err := strconv.ParseInt(l.n, 10, 64)
455+ if err != nil {
456+ return nil, nil
457+ }
458+ d, err := l.s.loadRepo(ctx, l.repo)
459+ if err != nil {
460+ return nil, err
461+ }
462+ p, err := l.s.runPage(ctx, d, id, 0)
463+ if err != nil {
464+ return nil, err
465+ }
466+ body, err := renderFragment(ctx, "ci-run", runBodyFragment(p))
467+ if err != nil {
468+ return nil, err
469+ }
470+ // The buttons depend on the state: the page fetches itself again when the
471+ // run completed, so that the owner gets the right ones.
472+ if !p.Active {
473+ return []fragment{body, {event: "gap", html: "gap"}}, nil
474+ }
475+ return []fragment{body}, nil
476+}
477+
478+// jobState tells the job page that its state or its steps changed: the page
479+// reloads the part of the log after the last line it holds (ermite.js).
480+func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
481+ d, err := l.s.loadRepo(ctx, l.repo)
482+ if err != nil {
483+ return nil, err
484+ }
485+ view, err := l.s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, l.job, 0)
486+ if err != nil {
487+ return nil, err
488+ }
489+ now := l.s.cfg.Now()
490+ row := l.s.jobRow(d.name(), view, now)
491+ f, err := renderFragment(ctx, "ci-job", JobHead(row, view.Lines))
492+ if err != nil {
493+ return nil, err
494+ }
495+ steps, err := renderFragment(ctx, "ci-job", JobSteps(stepViews(view, now)))
496+ if err != nil {
497+ return nil, err
498+ }
499+ return []fragment{f, steps}, nil
500+}
M internal/adapters/web/fragments.templ +1 -1
@@ -101,7 +101,7 @@ templ Live(l Layout) {
101101 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
102102 htmx:sse:error -> ermiteLive.error(event.detail.status);
103103 htmx:sse:before:connection -> ermiteLive.gate(event);
104- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
104+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
105105 gap -> ermiteLive.reload()"
106106 ></span>
107107 }
M internal/adapters/web/fragments_templ.go +1 -1
@@ -516,7 +516,7 @@ func Live(l Layout) templ.Component {
516516 if templ_7745c5c3_Err != nil {
517517 return templ_7745c5c3_Err
518518 }
519- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-swap=\"none\" :text=\"data.state === 'offline' ? 'live updates paused' : ''\" hx-on=\"htmx:sse:after:connection -> data.state = 'live';\n htmx:sse:close, htmx:sse:error -> data.state = 'offline';\n htmx:sse:error -> ermiteLive.error(event.detail.status);\n htmx:sse:before:connection -> ermiteLive.gate(event);\n home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);\n gap -> ermiteLive.reload()\"></span>")
519+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" hx-swap=\"none\" :text=\"data.state === 'offline' ? 'live updates paused' : ''\" hx-on=\"htmx:sse:after:connection -> data.state = 'live';\n htmx:sse:close, htmx:sse:error -> data.state = 'offline';\n htmx:sse:error -> ermiteLive.error(event.detail.status);\n htmx:sse:before:connection -> ermiteLive.gate(event);\n home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);\n gap -> ermiteLive.reload()\"></span>")
520520 if templ_7745c5c3_Err != nil {
521521 return templ_7745c5c3_Err
522522 }
A internal/adapters/web/jstest/log.test.mjs +103 -0
@@ -0,0 +1,103 @@
1+// Tests of the live job log of static/js/log.js (window.ermiteLog) with a stub
2+// EventSource on the tiny hand-rolled DOM. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test } from 'node:test';
6+import { el, installDom } from './dom.mjs';
7+
8+// A line is "text" or "+text" for a piece that continues the line before it.
9+function line(text) {
10+ const cont = text.startsWith('+');
11+ const li = el('li', { cls: 'lg', attrs: cont ? { 'data-cont': '' } : {} }, el('span', { cls: 't', text: cont ? text.slice(1) : text }));
12+ return li;
13+}
14+
15+installDom({ parseHTML: (data) => data.split('|').filter(Boolean).map(line) });
16+globalThis.document.addEventListener = () => {}; // log.js starts itself on DOMContentLoaded
17+
18+class StubEventSource {
19+ static CLOSED = 2;
20+ static last = null;
21+ constructor(url) {
22+ this.url = url;
23+ this.readyState = 1;
24+ this.handlers = new Map();
25+ StubEventSource.last = this;
26+ }
27+ addEventListener(name, fn) { this.handlers.set(name, fn); }
28+ close() { this.readyState = StubEventSource.CLOSED; }
29+ emit(name, data) { this.handlers.get(name)?.({ data }); }
30+}
31+globalThis.EventSource = StubEventSource;
32+
33+let scrolls = [];
34+globalThis.scrollTo = (x, y) => scrolls.push(y);
35+Object.assign(globalThis, { innerHeight: 800, scrollY: 0 });
36+
37+await import('../static/js/log.js');
38+const { follow } = window.ermiteLog;
39+
40+// page makes a job page body: the box with its list and the lines already there.
41+function page(attrs, ...lines) {
42+ const list = el('ol', { cls: 'log' }, ...lines.map(line));
43+ const box = el('div', { id: 'job-log', attrs }, list);
44+ const body = document.body;
45+ body.replaceChildren(box);
46+ Object.defineProperty(globalThis.document.body, 'scrollHeight', { value: 1000, configurable: true });
47+ return { box, list };
48+}
49+const running = { 'data-follow': '', 'data-stream': '/r/job/stream', 'data-next': '3' };
50+const texts = (list) => list.children.map((li) => li.querySelector('.t').textContent);
51+
52+test('it does nothing for a job that is over, or a page without a feed', () => {
53+ assert.equal(follow(null), null);
54+ assert.equal(follow(page({ 'data-stream': '/s' }).box), null, 'no data-follow: the job ended');
55+ assert.equal(follow(page({ 'data-follow': '' }).box), null, 'no stream URL');
56+});
57+
58+test('it opens the feed after the last line the page holds', () => {
59+ const { box } = page(running, 'a', 'b', 'c');
60+ const es = follow(box);
61+ assert.equal(es.url, '/r/job/stream?from=3');
62+});
63+
64+test('it appends the lines of a batch', () => {
65+ const { box, list } = page(running, 'a');
66+ const es = follow(box);
67+ es.emit('ci-log', 'b|c');
68+ es.emit('ci-log', 'd');
69+ assert.deepEqual(texts(list), ['a', 'b', 'c', 'd']);
70+});
71+
72+test('a piece that continues a line of an earlier batch joins that line', () => {
73+ const { box, list } = page(running, 'start of a long');
74+ const es = follow(box);
75+ es.emit('ci-log', '+ line|next');
76+ assert.deepEqual(texts(list), ['start of a long line', 'next']);
77+});
78+
79+test('it scrolls with the log only while the reader is at its end', () => {
80+ const { box } = page(running, 'a');
81+ const es = follow(box);
82+ scrolls = [];
83+ Object.assign(globalThis, { scrollY: 190 }); // 800 + 190 >= 1000 - 80: at the end
84+ es.emit('ci-log', 'b');
85+ assert.deepEqual(scrolls, [1000]);
86+ scrolls = [];
87+ Object.assign(globalThis, { scrollY: 0 }); // reading above
88+ es.emit('ci-log', 'c');
89+ assert.deepEqual(scrolls, []);
90+});
91+
92+test('ci-end closes the feed, and a closed connection stays closed', () => {
93+ const { box } = page(running, 'a');
94+ const es = follow(box);
95+ assert.equal(es.readyState, 1);
96+ es.emit('ci-end', 'end');
97+ assert.equal(es.readyState, StubEventSource.CLOSED);
98+
99+ const again = follow(page(running, 'a').box);
100+ again.readyState = StubEventSource.CLOSED;
101+ again.onerror();
102+ assert.equal(again.readyState, StubEventSource.CLOSED);
103+});
M internal/adapters/web/mr.go +8 -0
@@ -205,6 +205,8 @@ type MRPage struct {
205205 CommitsNote string
206206 Modes []string
207207 ChecksBlock bool
208+ // Checks are the CI checks of the head of the request.
209+ Checks ChecksSection
208210
209211 Box *MergeBox
210212 // Live is true when the page announces changes.
@@ -456,6 +458,12 @@ func (s *Server) mrPage(ctx context.Context, r *http.Request, since bool) (templ
456458 }
457459 }
458460 p.ChecksBlock = d.repo.Settings.FailingChecksBlock
461+ if open && head != "" {
462+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
463+ } else if head != "" {
464+ p.Checks = checksSection(d.repo.Repo, s.checksFor(ctx, d.repo.Name, head))
465+ p.Checks.Note = ""
466+ }
459467 p.Note = diffNote(res.Diff, len(p.Files), p.NFiles)
460468
461469 // The commits of the request, newest first.
M internal/adapters/web/mr.templ +1 -5
@@ -302,11 +302,7 @@ templ mrRail(p MRPage) {
302302 if p.Box != nil {
303303 @mergeBox(p)
304304 }
305- <section aria-labelledby="r-checks">
306- <h2 id="r-checks">Checks <span class="count">0</span></h2>
307- <p class="dim">no checks</p>
308- <p class="mute">there is no CI yet: nothing blocks the merge.</p>
309- </section>
305+ @MRChecks(p.Checks)
310306 <section aria-labelledby="r-settings">
311307 <h2 id="r-settings">Settings</h2>
312308 <dl class="kv">
M internal/adapters/web/mr_templ.go +56 -52 generated file: collapsed
M internal/adapters/web/pages.templ +1 -0
@@ -128,6 +128,7 @@ templ refRow(kind string, r RefRow) {
128128 } else {
129129 { r.Short }
130130 }
131+ @CommitChecks(r.Checks)
131132 </td>
132133 <td class="c-sub">{ r.Subject }</td>
133134 <td class="c-when dim">
M internal/adapters/web/pages_templ.go +17 -13 generated file: collapsed
M internal/adapters/web/refspage.go +4 -3
@@ -43,14 +43,14 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
4343 return a && !b
4444 })
4545 for i, b := range branches {
46- p.Branches = append(p.Branches, s.refRow(d, b, commits, b.ShortName == def, i == 0))
46+ p.Branches = append(p.Branches, s.refRow(ctx, d, b, commits, b.ShortName == def, i == 0))
4747 }
4848 tags := append([]domain.Ref(nil), d.tags...)
4949 sort.SliceStable(tags, func(i, j int) bool {
5050 return commits[tags[i].CommitID.String()].at > commits[tags[j].CommitID.String()].at
5151 })
5252 for i, t := range tags {
53- p.Tags = append(p.Tags, s.refRow(d, t, commits, false, len(p.Branches) == 0 && i == 0))
53+ p.Tags = append(p.Tags, s.refRow(ctx, d, t, commits, false, len(p.Branches) == 0 && i == 0))
5454 }
5555 if len(tags) > 0 {
5656 p.Latest = tags[0].ShortName
@@ -61,7 +61,7 @@ func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
6161 return p
6262 }
6363
64-func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
64+func (s *Server) refRow(ctx context.Context, d *repoData, r domain.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
6565 id := r.CommitID.String()
6666 c := commits[id]
6767 row := RefRow{
@@ -71,6 +71,7 @@ func (s *Server) refRow(d *repoData, r domain.Ref, commits map[string]refCommit,
7171 }
7272 if id != "" {
7373 row.CHref = repoHref(d.name(), "commit", id)
74+ row.Checks = checksSummary(d.repo.Name, id, s.checksFor(ctx, d.repo.Name, id))
7475 }
7576 return row
7677 }
M internal/adapters/web/repo.go +9 -0
@@ -135,6 +135,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
135135 if s.cfg.MergeRequests != nil {
136136 l.Sections = append(l.Sections, mk("m", "Merge requests", mrBase(d.name()), "merge-requests"))
137137 }
138+ l.Sections = s.withActions(l.Sections, d.name(), section)
138139 l.Crumbs = []Crumb{{Label: d.name(), Href: repoHref(d.name()), Current: section == "summary"}}
139140
140141 l.Cmd = append(l.Cmd, CmdItem{Kind: "go", Label: "home", Href: "/"})
@@ -161,6 +162,14 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
161162 return l
162163 }
163164
165+// withActions adds the Actions tab when the server runs CI.
166+func (s *Server) withActions(sections []Section, repo, current string) []Section {
167+ if s.cfg.Runs == nil {
168+ return sections
169+ }
170+ return append(sections, Section{Key: "a", Label: "Actions", Href: actionsBase(repo), Current: current == "actions"})
171+}
172+
164173 // cloneURL is the HTTPS clone URL of a repository.
165174 func (s *Server) cloneURL(name string) string {
166175 return s.cfg.PublicURL + "/" + name + ".git"
M internal/adapters/web/shell.templ +1 -0
@@ -22,6 +22,7 @@ templ Shell(l Layout, rail templ.Component) {
2222 <script src={ l.Assets.SSE } defer></script>
2323 <script src={ l.Assets.App } defer></script>
2424 <script src={ l.Assets.Files } defer></script>
25+ <script src={ l.Assets.Log } defer></script>
2526 </head>
2627 <body
2728 data-ref={ l.RefPath }
M internal/adapters/web/shell_templ.go +155 -142 generated file: collapsed
A internal/adapters/web/static/css/65-ci.css +53 -0
@@ -0,0 +1,53 @@
1+@layer components {
2+ /* CI: runs, jobs, steps, checks and the log. Hue only where it carries a
3+ verdict, and every mark has its word beside it or in its label. */
4+ .ck {
5+ display: inline-block; width: 14px; height: 14px; margin-right: var(--s1); vertical-align: -2px;
6+ border: 2px solid var(--ink-dim); border-radius: 50%; box-sizing: border-box; flex: none;
7+ }
8+ .ck-ok { background: var(--teal); border-color: var(--teal); }
9+ .ck-bad { background: var(--red); border-color: var(--red); border-radius: 3px; transform: rotate(45deg) scale(.85); }
10+ .ck-cancelled { border-style: dashed; }
11+ .ck-skipped { border-color: var(--border-strong); background: var(--bg-raised); }
12+ .ck-queued { border-style: dotted; }
13+ .ck-running { border-color: var(--teal); border-top-color: transparent; animation: ck-spin 1s linear infinite; }
14+ @keyframes ck-spin { to { transform: rotate(360deg); } }
15+ @media (prefers-reduced-motion: reduce) { .ck-running { animation: none; border-top-color: var(--teal); } }
16+
17+ .ck-sum { display: inline-flex; align-items: center; gap: var(--s1); margin-left: var(--s2); font-size: var(--fs-xs); white-space: nowrap; }
18+ .c-ck { width: 1%; white-space: nowrap; }
19+
20+ .runs .c-st { width: 1%; }
21+ .runs .c-dur, .runs .c-who { white-space: nowrap; }
22+ .runs tr[data-new] { background: var(--bg-raised); }
23+
24+ .run-h { display: flex; align-items: center; gap: var(--s2); margin: 0 0 var(--s4); }
25+ .run-err, .job-err {
26+ max-width: var(--measure); padding: var(--s3) var(--s4); margin: 0 0 var(--s4); white-space: pre-wrap; overflow-wrap: anywhere;
27+ background: var(--bg-surface); border: 1px solid var(--border); border-left: 3px solid var(--red); border-radius: var(--r-s);
28+ font: inherit; font-size: var(--fs-s);
29+ }
30+
31+ .jobs { display: grid; gap: var(--s4); padding: 0; list-style: none; }
32+ .jobs > li { padding: var(--s3) var(--s4); background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); }
33+ .job-h { display: flex; align-items: center; gap: var(--s2); font-weight: 700; }
34+ .steps { display: grid; gap: 2px; padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
35+ .steps li { display: flex; align-items: center; gap: var(--s1); }
36+ .attempts { padding: 0; margin: var(--s2) 0 0; list-style: none; font-size: var(--fs-s); }
37+ .checks { display: grid; gap: var(--s1); padding: 0; margin: 0; list-style: none; font-size: var(--fs-s); }
38+ .checks li { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s1); }
39+
40+ .joblog { margin-top: var(--s4); }
41+ .log {
42+ margin: 0; padding: var(--s2) 0; list-style: none; overflow-x: auto;
43+ font: var(--fs-s) / 1.5 var(--mono); background: var(--bg-inset); border: 1px solid var(--border); border-radius: var(--r-s);
44+ }
45+ .log .lg { display: flex; gap: var(--s3); padding: 0 var(--s3); white-space: pre-wrap; overflow-wrap: anywhere; }
46+ .log .lg:target { background: var(--bg-raised); }
47+ .log .lg time { flex: none; color: var(--ink-dim); user-select: none; }
48+ .log .lg .t { min-width: 0; }
49+ .log .group .t { font-weight: 700; }
50+ .log .endgroup { display: none; }
51+ .log .error .t { color: var(--red); }
52+ .joblog .more { margin: var(--s2) 0; font-size: var(--fs-s); }
53+}
A internal/adapters/web/static/js/log.js +43 -0
@@ -0,0 +1,43 @@
1+// log.js: the live log of a running job (page /<repo>/actions/runs/<id>/job/<id>).
2+// The lines arrive as "ci-log" events (internal/adapters/web/actions.go,
3+// logStream), each one the <li> of the lines of a batch, and are appended to the
4+// list. The browser reconnects by itself with Last-Event-ID, the number of the
5+// next line; "ci-end" closes the feed when the job is over. The page follows the
6+// log (scrolls to its end) only while the reader is at the end already. A piece
7+// that continues a line begun in an earlier batch (data-cont) is joined to the
8+// last line of the list.
9+window.ermiteLog = (() => {
10+ const nearEnd = () => window.innerHeight + window.scrollY >= document.body.scrollHeight - 80;
11+
12+ // follow starts the feed of the box (#job-log) and returns the EventSource,
13+ // or null when the box is not a running job's.
14+ function follow(box) {
15+ if (!box || !box.hasAttribute('data-follow') || !box.dataset.stream || !window.EventSource) return null;
16+ const list = box.querySelector('ol.log');
17+ if (!list) return null;
18+ const es = new window.EventSource(box.dataset.stream + '?from=' + encodeURIComponent(box.dataset.next || '0'));
19+ es.addEventListener('ci-log', (e) => {
20+ const t = document.createElement('template');
21+ t.innerHTML = e.data;
22+ const atEnd = nearEnd();
23+ for (const line of [...t.content.children]) {
24+ const last = list.children[list.children.length - 1];
25+ if (line.hasAttribute('data-cont') && last) {
26+ const text = line.querySelector('.t');
27+ const into = last.querySelector('.t');
28+ if (text && into) into.textContent += text.textContent;
29+ } else {
30+ list.append(line);
31+ }
32+ }
33+ if (atEnd) window.scrollTo(0, document.body.scrollHeight);
34+ });
35+ es.addEventListener('ci-end', () => es.close());
36+ es.onerror = () => { if (es.readyState === window.EventSource.CLOSED) es.close(); };
37+ return es;
38+ }
39+
40+ return { follow };
41+})();
42+
43+document.addEventListener('DOMContentLoaded', () => window.ermiteLog.follow(document.getElementById('job-log')));
M internal/adapters/web/view.go +5 -0
@@ -135,6 +135,9 @@ type CommitRow struct {
135135 Branches []string
136136 Tags []string
137137 Cursor bool
138+ // Checks is the combined state of the CI checks of the commit; empty when
139+ // it has none.
140+ Checks ChecksSummary
138141 }
139142
140143 // SummaryPage is the repository summary.
@@ -162,6 +165,8 @@ type LatestTag struct {
162165 type RefRow struct {
163166 Name, Href, Short, CHref, Subject, When, WhenAt string
164167 Default, Cursor bool
168+ // Checks is the combined state of the CI checks of the commit.
169+ Checks ChecksSummary
165170 }
166171
167172 // RefsPage lists the branches and tags of a repository.
M internal/adapters/web/web.go +15 -0
@@ -54,6 +54,10 @@ type Config struct {
5454 // there are no such pages, nor section for them. Leave it unset rather
5555 // than assigning a typed nil pointer.
5656 MergeRequests ports.MergeRequestService
57+ // Runs serves the CI pages (/<repo>/actions) and the checks shown next to
58+ // commits and merge requests. Without it there are no such pages. Leave it
59+ // unset rather than assigning a typed nil pointer.
60+ Runs ports.RunService
5761 // Events is the source of the event stream behind /-/events. Without
5862 // it the pages are not live and the route answers 404. Leave it unset
5963 // rather than assigning a typed nil pointer.
@@ -161,6 +165,17 @@ func (s *Server) routes() *http.ServeMux {
161165 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
162166 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
163167 }
168+ if s.cfg.Runs != nil {
169+ mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
170+ mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
171+ mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run))
172+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job))
173+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog)
174+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream)
175+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel))
176+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
177+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
178+ }
164179 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
165180 return mux
166181 }
M internal/app/api_helpers_test.go +4 -0
@@ -33,6 +33,8 @@ type clients struct {
3333 mr ermiteconnect.MergeRequestServiceClient
3434
3535 session ermiteconnect.SessionServiceClient
36+ runner ermiteconnect.RunnerServiceClient
37+ run ermiteconnect.RunServiceClient
3638 }
3739
3840 // bearerTransport adds the Authorization header of a secret, if any.
@@ -81,6 +83,8 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti
8183 mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...),
8284
8385 session: ermiteconnect.NewSessionServiceClient(hc, base, opts...),
86+ runner: ermiteconnect.NewRunnerServiceClient(hc, base, opts...),
87+ run: ermiteconnect.NewRunServiceClient(hc, base, opts...),
8488 }
8589 }
8690
M internal/app/api_test.go +1 -1
@@ -196,7 +196,7 @@ func TestAC2ReadAccess(t *testing.T) {
196196 for _, who := range allPrincipals {
197197 resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
198198 require.NoError(t, err, who)
199- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, resp.Msg.GetFeatures(), who)
199+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
A internal/app/ci_e2e_test.go +447 -0
@@ -0,0 +1,447 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync"
10+ "testing"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
16+ "google.golang.org/protobuf/types/known/fieldmaskpb"
17+ "google.golang.org/protobuf/types/known/timestamppb"
18+
19+ pb "bnema.dev/ermite-api/gen/ermite"
20+ "bnema.dev/ermite/internal/domain"
21+ "bnema.dev/ermite/internal/ports"
22+ "bnema.dev/ermite/internal/usecase"
23+)
24+
25+// ciHarness is a server with a repository whose pushes run workflows, and a
26+// fake runner that talks to the real Connect handlers.
27+type ciHarness struct {
28+ *harness
29+ root *clients
30+ wt string
31+ repo string
32+}
33+
34+const ciWorkflow = `name: ci
35+on:
36+ push:
37+ branches: [main, feat]
38+jobs:
39+ build:
40+ runs-on: ubuntu-latest
41+ steps:
42+ - run: echo hello
43+ test:
44+ needs: build
45+ runs-on: ubuntu-latest
46+ steps:
47+ - run: echo test
48+`
49+
50+func newCIHarness(t *testing.T, tweak func(*usecase.CIConfig)) *ciHarness {
51+ t.Helper()
52+ h := newHarnessWith(t, harnessOpts{}, func(o *options) { o.ci = tweak })
53+ c := &ciHarness{harness: h, root: h.api(adminToken), repo: "proj"}
54+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("proj"), Visibility: domain.VisibilityPrivate})
55+ require.NoError(t, err)
56+ c.wt = h.workTree("main")
57+ return c
58+}
59+
60+// push commits the files to branch and pushes it.
61+func (c *ciHarness) push(branch string, files map[string]string, msg string) string {
62+ c.t.Helper()
63+ if cur, err := c.git(c.wt, "rev-parse", "--abbrev-ref", "HEAD"); err != nil || strings.TrimSpace(cur) != branch {
64+ if _, err := c.git(c.wt, "checkout", "-q", branch); err != nil {
65+ c.mustGit(c.wt, "checkout", "-q", "-b", branch)
66+ }
67+ }
68+ for name, content := range files {
69+ c.writeFile(c.wt, name, []byte(content))
70+ }
71+ if msg != "" {
72+ c.mustGit(c.wt, "commit", "-q", "--amend", "-m", msg)
73+ }
74+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/"+c.repo+".git"), branch)
75+ return strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
76+}
77+
78+// registerRunner registers a runner and returns a fake that polls with it.
79+func (c *ciHarness) registerRunner(name string, labels ...string) *fakeRunner {
80+ c.t.Helper()
81+ res, err := c.root.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
82+ Name: name, Labels: labels, Version: "devel", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux",
83+ }))
84+ require.NoError(c.t, err)
85+ return &fakeRunner{c: c, id: res.Msg.GetRunnerId(), secret: res.Msg.GetRunnerToken(), api: c.api(res.Msg.GetRunnerToken())}
86+}
87+
88+// fakeRunner is the runner side of the protocol, as a test.
89+type fakeRunner struct {
90+ c *ciHarness
91+ id string
92+ secret string
93+ api *clients
94+}
95+
96+// runnerSecret is the runner token of a fake runner.
97+func (c *ciHarness) runnerSecret(r *fakeRunner) string { return r.secret }
98+
99+// fetch polls once for a job.
100+func (r *fakeRunner) fetch(pollID string) (*pb.Job, error) {
101+ res, err := r.api.runner.FetchJob(r.c.ctx, connect.NewRequest(&pb.FetchJobRequest{PollId: pollID}))
102+ if err != nil {
103+ return nil, err
104+ }
105+ return res.Msg.GetJob(), nil
106+}
107+
108+// await polls until a job is given.
109+func (r *fakeRunner) await() *pb.Job {
110+ r.c.t.Helper()
111+ deadline := time.Now().Add(15 * time.Second)
112+ for time.Now().Before(deadline) {
113+ j, err := r.fetch("")
114+ require.NoError(r.c.t, err)
115+ if j != nil {
116+ return j
117+ }
118+ }
119+ r.c.t.Fatal("no job was given to the runner")
120+ return nil
121+}
122+
123+// jobAPI is the client of a job: its token is the bearer secret.
124+func (r *fakeRunner) jobAPI(j *pb.Job) *clients { return r.c.api(j.GetJobToken()) }
125+
126+func now() *timestamppb.Timestamp { return timestamppb.Now() }
127+
128+// runJob executes a job as a runner does: heartbeat, steps, log, completion.
129+func (r *fakeRunner) runJob(j *pb.Job, conclusion pb.CiConclusion, outputs map[string]string, text ...string) {
130+ r.c.t.Helper()
131+ ctx, api := r.c.ctx, r.jobAPI(j)
132+ _, err := api.runner.Heartbeat(ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j.GetJobId()}))
133+ require.NoError(r.c.t, err)
134+ start := now()
135+ _, err = api.runner.UpdateJob(ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: j.GetJobId(), Steps: []*pb.StepUpdate{
136+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: start},
137+ }}))
138+ require.NoError(r.c.t, err)
139+ var lines []*pb.LogLine
140+ for _, t := range text {
141+ lines = append(lines, &pb.LogLine{Time: now(), Text: t})
142+ }
143+ if len(lines) > 0 {
144+ _, err = api.runner.AppendLog(ctx, connect.NewRequest(&pb.AppendLogRequest{JobId: j.GetJobId(), Step: 2, Offset: 0, Lines: lines}))
145+ require.NoError(r.c.t, err)
146+ }
147+ step := pb.CiConclusion_CI_CONCLUSION_SUCCESS
148+ if conclusion != pb.CiConclusion_CI_CONCLUSION_SUCCESS {
149+ step = conclusion
150+ }
151+ res, err := api.runner.CompleteJob(ctx, connect.NewRequest(&pb.CompleteJobRequest{
152+ JobId: j.GetJobId(), Conclusion: conclusion, Outputs: outputs,
153+ Steps: []*pb.StepUpdate{
154+ {Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
155+ {Number: 2, Name: "Run echo", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: step, StartedAt: start, CompletedAt: now()},
156+ },
157+ }))
158+ require.NoError(r.c.t, err)
159+ assert.True(r.c.t, res.Msg.GetApplied())
160+}
161+
162+// waitFor polls cond until it holds.
163+func (c *ciHarness) waitFor(what string, cond func() bool) {
164+ c.t.Helper()
165+ require.Eventually(c.t, cond, 15*time.Second, 20*time.Millisecond, what)
166+}
167+
168+func (c *ciHarness) runs() []*pb.Run {
169+ c.t.Helper()
170+ res, err := c.root.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: c.repo}))
171+ require.NoError(c.t, err)
172+ return res.Msg.GetRuns()
173+}
174+
175+func (c *ciHarness) checks(sha string) []*pb.Check {
176+ c.t.Helper()
177+ res, err := c.root.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: c.repo, Sha: sha}))
178+ require.NoError(c.t, err)
179+ return res.Msg.GetChecks()
180+}
181+
182+func (c *ciHarness) getRun(id int64) *pb.GetRunResponse {
183+ c.t.Helper()
184+ res, err := c.root.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: c.repo, RunId: id}))
185+ require.NoError(c.t, err)
186+ return res.Msg
187+}
188+
189+func ciErrCode(err error) connect.Code {
190+ var ce *connect.Error
191+ if errors.As(err, &ce) {
192+ return ce.Code()
193+ }
194+ return connect.Code(0)
195+}
196+
197+// AC3, AC5: a push runs the workflow on a runner; its jobs follow their needs;
198+// the checks appear on the commit; the log is readable.
199+func TestCIPushRunsAWorkflowOnARunner(t *testing.T) {
200+ c := newCIHarness(t, nil)
201+ runner := c.registerRunner("alpha", "ubuntu-latest")
202+ sha := c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
203+
204+ c.waitFor("a run is created", func() bool { return len(c.runs()) == 1 })
205+ run := c.runs()[0]
206+ assert.Equal(t, "ci", run.GetWorkflowName())
207+ assert.Equal(t, "refs/heads/main", run.GetRef())
208+ assert.Equal(t, sha, run.GetSha())
209+ assert.Equal(t, "push", run.GetEvent())
210+ assert.EqualValues(t, 1, run.GetNumber())
211+ assert.Equal(t, "root", run.GetActor())
212+
213+ // Only the first job is ready: the second needs it.
214+ job := runner.await()
215+ assert.Equal(t, "build", job.GetJobKey())
216+ assert.Equal(t, run.GetId(), job.GetRunId())
217+ assert.Equal(t, sha, job.GetSha())
218+ assert.Equal(t, "refs/heads/main", job.GetRef())
219+ assert.Equal(t, "proj", job.GetRepo())
220+ assert.Equal(t, ciWorkflow, job.GetWorkflowYaml())
221+ assert.Equal(t, []string{"ubuntu-latest"}, job.GetLabels())
222+ assert.EqualValues(t, 360, job.GetTimeoutMinutes())
223+ assert.Equal(t, "{}", job.GetMatrix())
224+ assert.True(t, strings.HasPrefix(job.GetJobToken(), "ermitejob_"))
225+ assert.Contains(t, job.GetContexts(), `"repository":"owner/proj"`)
226+ assert.Contains(t, job.GetContexts(), `"needs":{}`)
227+
228+ // Assigned but not acknowledged: still queued for the owner.
229+ assert.Equal(t, pb.CiStatus_CI_STATUS_QUEUED, c.getRun(run.GetId()).GetJobs()[0].GetStatus())
230+
231+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, map[string]string{"artifact": "out.bin"}, "hello", "world")
232+
233+ next := runner.await()
234+ assert.Equal(t, "test", next.GetJobKey())
235+ assert.Contains(t, next.GetContexts(), `"needs":{"build":{"result":"success","outputs":{"artifact":"out.bin"}}}`)
236+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "tested")
237+
238+ c.waitFor("the run completes", func() bool {
239+ return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
240+ })
241+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, c.runs()[0].GetConclusion())
242+
243+ checks := c.checks(sha)
244+ require.Len(t, checks, 2)
245+ assert.Equal(t, "build", checks[0].GetName())
246+ assert.Equal(t, "test", checks[1].GetName())
247+ for _, ck := range checks {
248+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, ck.GetStatus())
249+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, ck.GetConclusion())
250+ }
251+
252+ detail := c.getRun(run.GetId())
253+ require.Len(t, detail.GetJobs(), 2)
254+ steps := detail.GetJobs()[0].GetSteps()
255+ require.Len(t, steps, 2)
256+ assert.EqualValues(t, 0, steps[1].GetLogStart(), "step 2 has the lines")
257+ assert.EqualValues(t, 2, steps[1].GetLogEnd())
258+
259+ // The log is streamed back with its steps.
260+ stream, err := c.root.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: job.GetJobId()}))
261+ require.NoError(t, err)
262+ var got []string
263+ for stream.Receive() {
264+ for _, l := range stream.Msg().GetLines() {
265+ got = append(got, l.GetText())
266+ assert.EqualValues(t, 2, l.GetStep())
267+ }
268+ }
269+ require.NoError(t, stream.Err())
270+ assert.Equal(t, []string{"hello", "world"}, got)
271+}
272+
273+// AC5: merge refused while checks are pending or failing, allowed when they
274+// pass, and when failing_checks_block is off.
275+func TestCIMergeIsBlockedByChecks(t *testing.T) {
276+ c := newCIHarness(t, nil)
277+ runner := c.registerRunner("alpha", "ubuntu-latest")
278+ c.push("main", map[string]string{"README": "x\n"}, "")
279+
280+ feat := c.push("feat", map[string]string{".github/workflows/ci.yml": strings.ReplaceAll(ciWorkflow, " test:\n needs: build\n runs-on: ubuntu-latest\n steps:\n - run: echo test\n", "")}, "")
281+ mr, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
282+ require.NoError(t, err)
283+ merge := func() error {
284+ _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: mr.Msg.GetMergeRequest().GetNumber(), HeadId: feat}))
285+ return err
286+ }
287+
288+ c.waitFor("the feature run is queued", func() bool { return len(c.checks(feat)) == 1 })
289+ err = merge()
290+ require.Error(t, err)
291+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err), "a pending check blocks the merge")
292+ assert.Contains(t, err.Error(), "have not finished")
293+
294+ job := runner.await()
295+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "boom")
296+ c.waitFor("the check fails", func() bool {
297+ cs := c.checks(feat)
298+ return len(cs) == 1 && cs[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
299+ })
300+ err = merge()
301+ require.Error(t, err)
302+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
303+ assert.Contains(t, err.Error(), "failing checks block the merge")
304+
305+ // Off: the merge goes through.
306+ _, err = c.root.repo.UpdateRepo(c.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
307+ Repo: "proj",
308+ Settings: &pb.RepoSettings{FailingChecksBlock: false},
309+ UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.failing_checks_block"}},
310+ }))
311+ require.NoError(t, err)
312+ require.NoError(t, merge())
313+}
314+
315+// AC2: a job token works for its job only, and not once the job has ended.
316+func TestCIJobTokensAreScopedToTheirJob(t *testing.T) {
317+ c := newCIHarness(t, nil)
318+ runner := c.registerRunner("alpha", "ubuntu-latest")
319+ c.push("main", map[string]string{".github/workflows/a.yml": ciWorkflow, ".github/workflows/b.yml": ciWorkflow}, "")
320+ c.waitFor("two runs", func() bool { return len(c.runs()) == 2 })
321+ j1, j2 := runner.await(), runner.await()
322+ require.NotEqual(t, j1.GetJobId(), j2.GetJobId())
323+
324+ // Another job's id with this job's token: permission_denied.
325+ _, err := runner.jobAPI(j1).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j2.GetJobId()}))
326+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
327+ // A runner token is not a job token, and a stored token is neither.
328+ _, err = runner.api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j1.GetJobId()}))
329+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err))
330+ _, err = c.root.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j1.GetJobId()}))
331+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err))
332+ // A job token does not poll.
333+ _, err = runner.jobAPI(j1).runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{}))
334+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err))
335+
336+ runner.runJob(j1, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a")
337+ // After the end: failed_precondition with the JobEnded detail; CompleteJob
338+ // still answers with the stored result.
339+ _, err = runner.jobAPI(j1).runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
340+ JobId: j1.GetJobId(), Step: 2, Offset: 1, Lines: []*pb.LogLine{{Time: now(), Text: "late"}},
341+ }))
342+ require.Error(t, err)
343+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
344+ var ce *connect.Error
345+ require.True(t, errors.As(err, &ce))
346+ require.Len(t, ce.Details(), 1)
347+ msg, derr := ce.Details()[0].Value()
348+ require.NoError(t, derr)
349+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, msg.(*pb.JobEnded).GetConclusion())
350+
351+ res, err := runner.jobAPI(j1).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
352+ JobId: j1.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE,
353+ }))
354+ require.NoError(t, err)
355+ assert.False(t, res.Msg.GetApplied(), "a second CompleteJob changes nothing")
356+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, res.Msg.GetConclusion(), "and answers the stored conclusion")
357+}
358+
359+// AC2: a job is given to exactly one runner, however many poll at once.
360+func TestCIConcurrentFetchJobAssignsOnce(t *testing.T) {
361+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 300 * time.Millisecond })
362+ var runners []*fakeRunner
363+ for i := range 8 {
364+ runners = append(runners, c.registerRunner(fmt.Sprintf("r%d", i), "ubuntu-latest"))
365+ }
366+ var wf strings.Builder
367+ wf.WriteString("on: push\njobs:\n")
368+ for i := range 3 {
369+ fmt.Fprintf(&wf, " j%d:\n runs-on: ubuntu-latest\n steps:\n - run: echo %d\n", i, i)
370+ }
371+ c.push("main", map[string]string{".github/workflows/many.yml": wf.String()}, "")
372+ c.waitFor("the run exists", func() bool { return len(c.runs()) == 1 })
373+
374+ var mu sync.Mutex
375+ got := map[string]int{}
376+ var wg sync.WaitGroup
377+ for _, r := range runners {
378+ wg.Add(1)
379+ go func() {
380+ defer wg.Done()
381+ for range 20 {
382+ j, err := r.fetch("")
383+ if err != nil {
384+ return
385+ }
386+ if j != nil {
387+ mu.Lock()
388+ got[j.GetJobId()]++
389+ mu.Unlock()
390+ }
391+ }
392+ }()
393+ }
394+ wg.Wait()
395+ assert.Len(t, got, 3, "every job was given")
396+ for id, n := range got {
397+ assert.Equal(t, 1, n, "job %s was given %d times", id, n)
398+ }
399+}
400+
401+// AC2: the first heartbeat acknowledges; without it the assignment is
402+// cancelled and the job is queued again, three times in a row at most.
403+func TestCIUnacknowledgedAssignmentGoesBackToTheQueue(t *testing.T) {
404+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
405+ cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 150*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
406+ })
407+ runner := c.registerRunner("alpha", "ubuntu-latest")
408+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
409+
410+ first := runner.await()
411+ // No heartbeat: the token is revoked and the job comes back with a new one.
412+ second := runner.await()
413+ assert.Equal(t, first.GetJobId(), second.GetJobId())
414+ assert.NotEqual(t, first.GetJobToken(), second.GetJobToken())
415+ _, err := runner.jobAPI(first).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: first.GetJobId()}))
416+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(err), "the replaced token is rejected")
417+
418+ // The third assignment is the last: ignoring it fails the job.
419+ runner.await()
420+ c.waitFor("the job fails", func() bool {
421+ return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
422+ })
423+ detail := c.getRun(c.runs()[0].GetId())
424+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, detail.GetJobs()[0].GetConclusion())
425+ assert.Contains(t, detail.GetJobs()[0].GetError(), "acknowledged")
426+}
427+
428+// A retried poll with the same poll_id returns the same assignment.
429+func TestCIRetriedPollReturnsTheSameAssignment(t *testing.T) {
430+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 200 * time.Millisecond })
431+ runner := c.registerRunner("alpha", "ubuntu-latest")
432+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
433+ c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
434+
435+ var first *pb.Job
436+ c.waitFor("a job", func() bool {
437+ j, err := runner.fetch("poll-1")
438+ require.NoError(t, err)
439+ first = j
440+ return j != nil
441+ })
442+ again, err := runner.fetch("poll-1")
443+ require.NoError(t, err)
444+ require.NotNil(t, again)
445+ assert.Equal(t, first.GetJobId(), again.GetJobId())
446+ assert.Equal(t, first.GetJobToken(), again.GetJobToken())
447+}
A internal/app/ci_flow_test.go +686 -0
@@ -0,0 +1,686 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "fmt"
9+ "net/http/httptest"
10+ "os"
11+ "path/filepath"
12+ "strings"
13+ "testing"
14+ "time"
15+
16+ "connectrpc.com/connect"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+ "bnema.dev/ermite/internal/usecase"
24+)
25+
26+const oneJob = `on:
27+ push:
28+ %s
29+jobs:
30+ j:
31+ runs-on: ubuntu-latest
32+ steps:
33+ - run: echo hi
34+`
35+
36+func workflowWith(filter string) string { return fmt.Sprintf(oneJob, filter) }
37+
38+// AC3: filters, tags, skip directives, invalid files and deletions.
39+func TestCITriggerRules(t *testing.T) {
40+ c := newCIHarness(t, nil)
41+ c.registerRunner("alpha", "ubuntu-latest")
42+ count := func() int { return len(c.runs()) }
43+ settle := func() { time.Sleep(300 * time.Millisecond) } // a push that runs nothing leaves nothing to wait for
44+
45+ // A workflow with a branch filter does not run for another branch.
46+ c.push("main", map[string]string{".github/workflows/only-main.yml": workflowWith("branches: [main]"), "src/a.go": "package a\n"}, "")
47+ c.waitFor("main runs", func() bool { return count() == 1 })
48+ c.push("other", map[string]string{"x.txt": "x\n"}, "")
49+ settle()
50+ assert.Equal(t, 1, count(), "branches: [main] does not run for other")
51+
52+ // A paths filter looks at the files of the push.
53+ c.push("main", map[string]string{".github/workflows/paths.yml": workflowWith("paths: ['docs/**']")}, "")
54+ c.waitFor("only-main runs again for the new commit", func() bool { return count() == 2 })
55+ c.push("main", map[string]string{"docs/readme.md": "doc\n"}, "")
56+ c.waitFor("both run for docs", func() bool { return count() == 4 })
57+ c.push("main", map[string]string{"src/b.go": "package b\n"}, "")
58+ c.waitFor("only-main runs for src", func() bool { return count() == 5 })
59+ settle()
60+ assert.Equal(t, 5, count(), "the paths filter kept paths.yml out")
61+
62+ // [skip ci] on the head commit.
63+ c.push("main", map[string]string{"src/c.go": "package c\n"}, "wip [skip ci]")
64+ settle()
65+ assert.Equal(t, 5, count())
66+
67+ // An invalid workflow file is a failed run without a job.
68+ c.push("main", map[string]string{".github/workflows/broken.yml": "on: push\njobs:\n j:\n steps: [\n"}, "")
69+ c.waitFor("the invalid file gives a run", func() bool {
70+ for _, r := range c.runs() {
71+ if r.GetWorkflowPath() == ".github/workflows/broken.yml" {
72+ return true
73+ }
74+ }
75+ return false
76+ })
77+ var broken *pb.Run
78+ for _, r := range c.runs() {
79+ if r.GetWorkflowPath() == ".github/workflows/broken.yml" {
80+ broken = r
81+ }
82+ }
83+ assert.Equal(t, pb.CiStatus_CI_STATUS_COMPLETED, broken.GetStatus())
84+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, broken.GetConclusion())
85+ assert.NotEmpty(t, broken.GetError())
86+ assert.Empty(t, c.getRun(broken.GetId()).GetJobs(), "an invalid file has no job")
87+ sha := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
88+ var failed []string
89+ for _, ck := range c.checks(sha) {
90+ if ck.GetConclusion() == pb.CiConclusion_CI_CONCLUSION_FAILURE {
91+ failed = append(failed, ck.GetName())
92+ }
93+ }
94+ assert.Equal(t, []string{".github/workflows/broken.yml"}, failed, "and a failed check on the commit")
95+
96+ // A push never fails for CI: the push above succeeded (mustGit). A deletion
97+ // runs nothing.
98+ before := count()
99+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), ":other")
100+ settle()
101+ assert.Equal(t, before, count())
102+}
103+
104+// AC3: tags run tag-filtered workflows; more than 3 tags in one push run none.
105+func TestCITags(t *testing.T) {
106+ c := newCIHarness(t, nil)
107+ c.registerRunner("alpha", "ubuntu-latest")
108+ c.push("main", map[string]string{".github/workflows/rel.yml": workflowWith("tags: ['v*']")}, "")
109+ settle := func() { time.Sleep(300 * time.Millisecond) }
110+ settle()
111+ assert.Empty(t, c.runs(), "a tags-only workflow does not run for a branch")
112+
113+ c.mustGit(c.wt, "tag", "v1")
114+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
115+ c.waitFor("a tag runs", func() bool { return len(c.runs()) == 1 })
116+ assert.Equal(t, "refs/tags/v1", c.runs()[0].GetRef())
117+
118+ for _, tag := range []string{"v2", "v3", "v4", "v5"} {
119+ c.mustGit(c.wt, "tag", tag)
120+ }
121+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v2", "v3", "v4", "v5")
122+ settle()
123+ assert.Len(t, c.runs(), 1, "more than 3 tags in one push run nothing")
124+}
125+
126+// AC6: cancelling a run asks the runner to stop through WatchJob; a runner that
127+// does not answer is ended by the server.
128+func TestCICancelReachesTheRunnerAndIsEnforced(t *testing.T) {
129+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
130+ cfg.CancelGrace, cfg.Tick, cfg.PollTimeout = 400*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
131+ })
132+ runner := c.registerRunner("alpha", "ubuntu-latest")
133+ c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
134+ job := runner.await()
135+ api := runner.jobAPI(job)
136+ _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
137+ require.NoError(t, err)
138+
139+ watch, err := api.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: job.GetJobId()}))
140+ require.NoError(t, err)
141+ run := c.runs()[0]
142+
143+ // A reader without ci:write cannot cancel.
144+ reader := c.api(c.token(scopes(domain.ScopeRepoRead)))
145+ _, err = reader.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
146+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
147+
148+ _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
149+ require.NoError(t, err)
150+ // Cancelling again changes nothing.
151+ _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
152+ require.NoError(t, err)
153+
154+ var cancel *pb.CancelRequest
155+ for watch.Receive() {
156+ if m := watch.Msg().GetCancel(); m != nil {
157+ cancel = m
158+ break
159+ }
160+ }
161+ require.NotNil(t, cancel, "the runner is told")
162+ assert.Equal(t, pb.CancelReason_CANCEL_REASON_USER, cancel.GetReason())
163+
164+ // The queued job that needs it never ran: cancelled at once. The running
165+ // one is ended by the server after the grace period.
166+ c.waitFor("the server ends the job", func() bool {
167+ return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
168+ })
169+ detail := c.getRun(run.GetId())
170+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, detail.GetRun().GetConclusion())
171+ for _, j := range detail.GetJobs() {
172+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion(), j.GetKey())
173+ }
174+ assert.Contains(t, detail.GetJobs()[0].GetError(), "did not answer")
175+
176+ // The runner's late CompleteJob returns the server's result.
177+ res, err := api.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
178+ JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED,
179+ }))
180+ require.NoError(t, err)
181+ assert.False(t, res.Msg.GetApplied())
182+
183+ // A completed run cannot be cancelled.
184+ _, err = c.root.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: run.GetId()}))
185+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
186+}
187+
188+// A running job without heartbeats fails with GitHub's lost-runner message.
189+func TestCILostRunnerFailsTheJob(t *testing.T) {
190+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
191+ cfg.HeartbeatDeadline, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
192+ })
193+ runner := c.registerRunner("alpha", "ubuntu-latest")
194+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
195+ job := runner.await()
196+ _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
197+ require.NoError(t, err)
198+
199+ c.waitFor("the job is lost", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
200+ j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
201+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
202+ assert.Contains(t, j.GetError(), "The self-hosted runner: alpha lost communication with the server")
203+}
204+
205+// AC6: a re-run is a new attempt of the same run; the failed-only variant keeps
206+// the jobs that passed.
207+func TestCIRerun(t *testing.T) {
208+ c := newCIHarness(t, nil)
209+ runner := c.registerRunner("alpha", "ubuntu-latest")
210+ c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
211+ run := func() *pb.Run { return c.runs()[0] }
212+
213+ build := runner.await()
214+ runner.runJob(build, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
215+ test := runner.await()
216+ runner.runJob(test, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "red")
217+ c.waitFor("the run fails", func() bool { return run().GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
218+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, run().GetConclusion())
219+ id, number := run().GetId(), run().GetNumber()
220+
221+ // A running run cannot be re-run; a completed one can, by who has ci:write.
222+ reader := c.api(c.token(scopes(domain.ScopeRepoRead)))
223+ _, err := reader.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id}))
224+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err))
225+
226+ res, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true}))
227+ require.NoError(t, err)
228+ assert.Equal(t, id, res.Msg.GetRun().GetId())
229+ assert.Equal(t, number, res.Msg.GetRun().GetNumber())
230+ assert.EqualValues(t, 2, res.Msg.GetRun().GetAttempt())
231+
232+ // build keeps its attempt-1 result; only test runs again.
233+ again := runner.await()
234+ assert.Equal(t, "test", again.GetJobKey())
235+ assert.EqualValues(t, 2, again.GetAttempt())
236+ runner.runJob(again, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "green")
237+ c.waitFor("attempt 2 passes", func() bool {
238+ r := run()
239+ return r.GetAttempt() == 2 && r.GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED
240+ })
241+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, run().GetConclusion())
242+ detail := c.getRun(id)
243+ require.Len(t, detail.GetJobs(), 2)
244+ assert.EqualValues(t, 1, detail.GetJobs()[0].GetAttempt(), "build was kept from attempt 1")
245+ assert.EqualValues(t, 2, detail.GetJobs()[1].GetAttempt())
246+
247+ // Attempt 1 is still readable, with its failure.
248+ first, err := c.root.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: id, Attempt: 1}))
249+ require.NoError(t, err)
250+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, first.Msg.GetRun().GetConclusion())
251+ assert.EqualValues(t, 2, first.Msg.GetLatestAttempt())
252+
253+ // Re-running a run with nothing failed is refused with failed-only.
254+ _, err = c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: id, FailedOnly: true}))
255+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(err))
256+}
257+
258+// A matrix is expanded when its job is ready, with fail-fast.
259+func TestCIMatrixAndFailFast(t *testing.T) {
260+ c := newCIHarness(t, nil)
261+ runner := c.registerRunner("alpha", "ubuntu-latest")
262+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
263+jobs:
264+ build:
265+ runs-on: ubuntu-latest
266+ strategy:
267+ matrix:
268+ go: ['1.26', '1.27']
269+ steps:
270+ - run: echo ${{ matrix.go }}
271+ after:
272+ needs: build
273+ if: always()
274+ runs-on: ubuntu-latest
275+ steps:
276+ - run: echo done
277+`}, "")
278+ a := runner.await()
279+ b := runner.await()
280+ assert.Equal(t, "build", a.GetJobKey())
281+ assert.JSONEq(t, `{"go":"1.26"}`, a.GetMatrix())
282+ assert.JSONEq(t, `{"go":"1.27"}`, b.GetMatrix())
283+ assert.Contains(t, a.GetContexts(), `"job-total":2`)
284+
285+ jobs := c.getRun(c.runs()[0].GetId()).GetJobs()
286+ var names []string
287+ for _, j := range jobs {
288+ names = append(names, j.GetName())
289+ }
290+ assert.Equal(t, []string{"build (1.26)", "build (1.27)", "after"}, names)
291+
292+ // The first fails: fail-fast asks the second to stop.
293+ apiB := runner.jobAPI(b)
294+ _, err := apiB.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: b.GetJobId()}))
295+ require.NoError(t, err)
296+ watch, err := apiB.runner.WatchJob(c.ctx, connect.NewRequest(&pb.WatchJobRequest{JobId: b.GetJobId()}))
297+ require.NoError(t, err)
298+ runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x")
299+ var reason pb.CancelReason
300+ for watch.Receive() {
301+ if m := watch.Msg().GetCancel(); m != nil {
302+ reason = m.GetReason()
303+ break
304+ }
305+ }
306+ assert.Equal(t, pb.CancelReason_CANCEL_REASON_FAIL_FAST, reason)
307+ _, err = apiB.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: b.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_CANCELLED}))
308+ require.NoError(t, err)
309+
310+ // "after" has if: always(), so it runs once both ended.
311+ after := runner.await()
312+ assert.Equal(t, "after", after.GetJobKey())
313+ assert.Contains(t, after.GetContexts(), `"result":"failure"`)
314+ runner.runJob(after, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "done")
315+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
316+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, c.runs()[0].GetConclusion())
317+}
318+
319+// A job with a feature of a later phase fails clearly, never silently.
320+func TestCIUnsupportedFeatureFailsTheJob(t *testing.T) {
321+ c := newCIHarness(t, nil)
322+ c.registerRunner("alpha", "ubuntu-latest")
323+ c.push("main", map[string]string{".github/workflows/u.yml": `on: push
324+jobs:
325+ j:
326+ runs-on: ubuntu-latest
327+ steps:
328+ - uses: actions/checkout@v4
329+`}, "")
330+ c.waitFor("the run fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
331+ j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
332+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
333+ assert.Equal(t, "not supported yet: uses: actions/checkout@v4", j.GetError())
334+}
335+
336+// A job for a label no runner has is "waiting for a runner" and fails after the
337+// queue timeout.
338+func TestCIQueueTimeout(t *testing.T) {
339+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.QueueTimeout, cfg.Tick = 300*time.Millisecond, 20*time.Millisecond })
340+ c.registerRunner("alpha", "ubuntu-latest")
341+ c.push("main", map[string]string{".github/workflows/q.yml": strings.Replace(workflowWith("branches: [main]"), "ubuntu-latest", "gpu", 1)}, "")
342+ c.waitFor("the job fails", func() bool { return len(c.runs()) == 1 && c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
343+ j := c.getRun(c.runs()[0].GetId()).GetJobs()[0]
344+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, j.GetConclusion())
345+ assert.Contains(t, j.GetError(), "was not acquired")
346+}
347+
348+// The runner API validates what it is given.
349+func TestCIRunnerRegistrationRules(t *testing.T) {
350+ c := newCIHarness(t, nil)
351+ reg := func(api *clients, name string, replace bool) error {
352+ _, err := api.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
353+ Name: name, Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: replace,
354+ }))
355+ return err
356+ }
357+ assert.Equal(t, connect.CodeUnauthenticated, ciErrCode(reg(c.api(""), "a", false)))
358+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(reg(c.api(c.token(scopes(domain.ScopeRepoRead))), "a", false)))
359+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(reg(c.api(c.token(scopes(domain.ScopeRunnerRegister), "proj")), "a", false)), "a repository-restricted token cannot register a runner")
360+ reg1 := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
361+ require.NoError(t, reg(reg1, "a", false))
362+ assert.Equal(t, connect.CodeAlreadyExists, ciErrCode(reg(reg1, "a", false)))
363+ require.NoError(t, reg(reg1, "a", true), "replace")
364+ assert.Equal(t, connect.CodeInvalidArgument, ciErrCode(reg(reg1, "bad name!", false)))
365+
366+ list, err := reg1.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
367+ require.NoError(t, err)
368+ require.Len(t, list.Msg.GetRunners(), 1)
369+ assert.Equal(t, []string{"linux", "self-hosted", "x64"}, list.Msg.GetRunners()[0].GetLabels(), "implicit labels, sorted")
370+}
371+
372+// AC2: after a restart of the server, running jobs go on with their tokens,
373+// queued jobs stay queued, and a job whose runner kept quiet during the outage
374+// is not failed for it.
375+func TestCIRestartKeepsRunningAndQueuedJobs(t *testing.T) {
376+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.HeartbeatDeadline = 2 * time.Second })
377+ runner := c.registerRunner("alpha", "ubuntu-latest")
378+ c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
379+ job := runner.await() // "build"; "test" waits for it
380+ api := runner.jobAPI(job)
381+ _, err := api.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
382+ require.NoError(t, err)
383+ _, err = api.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
384+ JobId: job.GetJobId(), Step: 2, Offset: 0, Lines: []*pb.LogLine{{Time: now(), Text: "before the restart"}},
385+ }))
386+ require.NoError(t, err)
387+
388+ // Stop the server and start another on the same data directory.
389+ cfg, hookExe := c.forge.cfg, binary(t)
390+ c.srv.Close()
391+ c.forge.Close(c.ctx)
392+ f2, err := newForge(c.ctx, cfg, options{hookExe: hookExe, ci: func(cc *usecase.CIConfig) { cc.HeartbeatDeadline = 2 * time.Second }})
393+ require.NoError(t, err)
394+ t.Cleanup(func() { f2.Close(context.WithoutCancel(c.ctx)) })
395+ srv := httptest.NewServer(f2.server.Handler())
396+ t.Cleanup(srv.Close)
397+ api2 := newClients(srv.URL+"/-/api", job.GetJobToken())
398+
399+ // The job token still works, and the log goes on from where it was.
400+ _, err = api2.runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
401+ require.NoError(t, err, "the job token survives a restart")
402+ next, err := api2.runner.AppendLog(c.ctx, connect.NewRequest(&pb.AppendLogRequest{
403+ JobId: job.GetJobId(), Step: 2, Offset: 1, Lines: []*pb.LogLine{{Time: now(), Text: "after the restart"}},
404+ }))
405+ require.NoError(t, err)
406+ assert.EqualValues(t, 2, next.Msg.GetNextOffset())
407+
408+ root2 := newClients(srv.URL+"/-/api", adminToken)
409+ runs, err := root2.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
410+ require.NoError(t, err)
411+ require.Len(t, runs.Msg.GetRuns(), 1)
412+ assert.Equal(t, pb.CiStatus_CI_STATUS_IN_PROGRESS, runs.Msg.GetRuns()[0].GetStatus())
413+
414+ // Finish the job on the new server: its dependant is planned and ready.
415+ _, err = api2.runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
416+ JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS,
417+ }))
418+ require.NoError(t, err)
419+ got := make(chan *pb.Job, 1)
420+ go func() {
421+ for range 50 {
422+ res, err := newClients(srv.URL+"/-/api", c.runnerSecret(runner)).runner.FetchJob(c.ctx, connect.NewRequest(&pb.FetchJobRequest{}))
423+ if err == nil && res.Msg.GetJob() != nil {
424+ got <- res.Msg.GetJob()
425+ return
426+ }
427+ }
428+ got <- nil
429+ }()
430+ select {
431+ case j := <-got:
432+ require.NotNil(t, j, "the runner token and the queue survive a restart")
433+ assert.Equal(t, "test", j.GetJobKey())
434+ case <-time.After(30 * time.Second):
435+ t.Fatal("no job after the restart")
436+ }
437+}
438+
439+// AC5: a head without any check merges, and so does one whose checks passed; a
440+// merge runs the push workflows on the target.
441+func TestCIMergeWithoutChecksAndWithPassingChecks(t *testing.T) {
442+ c := newCIHarness(t, nil)
443+ runner := c.registerRunner("alpha", "ubuntu-latest")
444+ c.push("main", map[string]string{"README": "x\n"}, "")
445+ headOf := func(branch string) string { return strings.TrimSpace(c.bareGit("proj", "rev-parse", branch)) }
446+ merge := func(n int64, head string) error {
447+ _, err := c.root.mr.MergeMergeRequest(c.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: head}))
448+ return err
449+ }
450+
451+ // No workflow on the branch: no check, the merge goes through.
452+ c.push("plain", map[string]string{"a.txt": "a\n"}, "")
453+ mr1, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "plain", SourceBranch: "plain"}))
454+ require.NoError(t, err)
455+ require.NoError(t, merge(mr1.Msg.GetMergeRequest().GetNumber(), headOf("plain")))
456+
457+ // A branch with a workflow: merge after its check passed. The merge itself
458+ // triggers the workflow on main (GitHub runs push workflows on a merge).
459+ c.mustGit(c.wt, "checkout", "-q", "main")
460+ c.mustGit(c.wt, "pull", "-q", c.urlAuth(adminToken, "/proj.git"), "main")
461+ c.mustGit(c.wt, "checkout", "-q", "-b", "ci")
462+ c.writeFile(c.wt, ".github/workflows/ci.yml", []byte(strings.Replace(workflowWith("branches: [main, ci]"), "echo hi", "echo ci", 1)))
463+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "ci")
464+ head := headOf("ci")
465+ c.waitFor("the run on ci", func() bool { return len(c.checks(head)) == 1 })
466+ mr2, err := c.root.mr.CreateMergeRequest(c.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "ci", SourceBranch: "ci"}))
467+ require.NoError(t, err)
468+ job := runner.await()
469+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "ok")
470+ c.waitFor("the check passes", func() bool {
471+ cs := c.checks(head)
472+ return len(cs) == 1 && cs[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_SUCCESS
473+ })
474+ require.NoError(t, merge(mr2.Msg.GetMergeRequest().GetNumber(), head))
475+
476+ // The fast-forward merge moved main to the same commit: the workflow runs
477+ // again for main, with the owner as the actor.
478+ c.waitFor("the merge triggers a run on main", func() bool {
479+ for _, r := range c.runs() {
480+ if r.GetRef() == "refs/heads/main" && r.GetSha() == head {
481+ return true
482+ }
483+ }
484+ return false
485+ })
486+ for _, r := range c.runs() {
487+ if r.GetRef() == "refs/heads/main" {
488+ assert.Equal(t, "owner", r.GetActor())
489+ }
490+ }
491+}
492+
493+// Repository deletion removes the runs, the checks and the logs; a repository
494+// created again starts at run number 1.
495+func TestCIRepositoryDeletionRemovesRunsAndLogs(t *testing.T) {
496+ c := newCIHarness(t, nil)
497+ runner := c.registerRunner("alpha", "ubuntu-latest")
498+ sha := c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
499+ job := runner.await()
500+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "line")
501+ logDir := filepath.Join(c.data, "ci", "logs")
502+ entries, err := os.ReadDir(logDir)
503+ require.NoError(t, err)
504+ require.NotEmpty(t, entries, "the log is a file under ci/logs")
505+
506+ _, err = c.root.repo.DeleteRepo(c.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "proj"}))
507+ require.NoError(t, err)
508+ _, err = c.root.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
509+ assert.Equal(t, connect.CodeNotFound, ciErrCode(err))
510+ entries, err = os.ReadDir(logDir)
511+ require.NoError(t, err)
512+ assert.Empty(t, entries, "the logs are removed with the repository")
513+
514+ _, err = c.forge.Repos.Create(c.ctx, domain.Root(), ports.CreateRepoParams{Name: c.name("proj"), Visibility: domain.VisibilityPrivate})
515+ require.NoError(t, err)
516+ assert.Empty(t, c.checks(sha), "no check survives the repository")
517+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0] + "# again\n"}, "again")
518+ c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
519+ assert.EqualValues(t, 1, c.runs()[0].GetNumber(), "run numbers start again with the repository")
520+ assert.Greater(t, c.runs()[0].GetId(), int64(1), "run ids are never reused")
521+}
522+
523+// The events of a run and its jobs are published on the repository topic.
524+func TestCIEventsAreStreamedOnTheRepositoryTopic(t *testing.T) {
525+ c := newCIHarness(t, nil)
526+ runner := c.registerRunner("alpha", "ubuntu-latest")
527+ s := c.watch(c.root, "", "repo:proj")
528+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
529+ job := runner.await()
530+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
531+
532+ var runUpdates, jobUpdates int
533+ var lastRun *pb.Run
534+ deadline := time.After(10 * time.Second)
535+ for lastRun.GetStatus() != pb.CiStatus_CI_STATUS_COMPLETED {
536+ select {
537+ case ev := <-s.events:
538+ if r := ev.GetRunUpdated(); r != nil {
539+ runUpdates++
540+ if r.GetRun().GetVersion() >= lastRun.GetVersion() {
541+ lastRun = r.GetRun()
542+ }
543+ }
544+ if ev.GetJobUpdated() != nil {
545+ jobUpdates++
546+ }
547+ case <-deadline:
548+ t.Fatalf("no completed run event: %d run and %d job events", runUpdates, jobUpdates)
549+ }
550+ }
551+ assert.GreaterOrEqual(t, runUpdates, 2)
552+ assert.GreaterOrEqual(t, jobUpdates, 2)
553+
554+ // They are never sent on "home".
555+ home := c.watch(c.root, "", "home")
556+ _, err := c.root.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: lastRun.GetId()}))
557+ require.NoError(t, err)
558+ c.sentinel(t, home, "sentinel")
559+}
560+
561+// A new branch at a commit that already exists, and a branch rewound to an
562+// ancestor, bring no new commit: GitHub still runs the push workflows (those
563+// without a paths filter, as no file changed).
564+func TestCIPushWithoutNewCommitsStillRuns(t *testing.T) {
565+ c := newCIHarness(t, nil)
566+ c.registerRunner("alpha", "ubuntu-latest")
567+ c.push("main", map[string]string{".github/workflows/any.yml": workflowWith("branches: ['**']")}, "")
568+ c.waitFor("main runs", func() bool { return len(c.runs()) == 1 })
569+ first := strings.TrimSpace(c.mustGit(c.wt, "rev-parse", "HEAD"))
570+
571+ // A new branch at the commit main already has.
572+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "HEAD:refs/heads/same")
573+ c.waitFor("the new branch runs", func() bool { return len(c.runs()) == 2 })
574+ assert.Equal(t, "refs/heads/same", c.runs()[0].GetRef())
575+ assert.Equal(t, first, c.runs()[0].GetSha())
576+
577+ // A branch moved forward, then rewound to its ancestor (a forced push).
578+ c.push("main", map[string]string{"a.txt": "a\n"}, "")
579+ c.waitFor("the push runs", func() bool { return len(c.runs()) == 3 })
580+ c.mustGit(c.wt, "push", "-q", "-f", c.urlAuth(adminToken, "/proj.git"), first+":refs/heads/main")
581+ c.waitFor("the rewind runs", func() bool { return len(c.runs()) == 4 })
582+ assert.Equal(t, first, c.runs()[0].GetSha())
583+}
584+
585+// CI is on once a runner is registered: before that a push makes no run and no
586+// check; after, it does.
587+func TestCINoRunnerNoRunsNoChecks(t *testing.T) {
588+ c := newCIHarness(t, nil)
589+ sha := c.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
590+ time.Sleep(400 * time.Millisecond)
591+ assert.Empty(t, c.runs())
592+ assert.Empty(t, c.checks(sha))
593+
594+ c.registerRunner("alpha", "ubuntu-latest")
595+ c.push("main", map[string]string{"a.txt": "a\n"}, "")
596+ c.waitFor("a run once a runner exists", func() bool { return len(c.runs()) == 1 })
597+}
598+
599+// A job that fail-fast cancelled while its assignment was not acknowledged is
600+// over when the assignment is cancelled: it is not given to a runner again.
601+func TestCICancelledUnacknowledgedAssignmentIsNotAssignedAgain(t *testing.T) {
602+ c := newCIHarness(t, func(cfg *usecase.CIConfig) {
603+ cfg.AckTimeout, cfg.Tick, cfg.PollTimeout = 300*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond
604+ })
605+ runner := c.registerRunner("alpha", "ubuntu-latest")
606+ c.push("main", map[string]string{".github/workflows/m.yml": `on: push
607+jobs:
608+ build:
609+ runs-on: ubuntu-latest
610+ strategy:
611+ matrix:
612+ n: [1, 2]
613+ steps:
614+ - run: echo ${{ matrix.n }}
615+`}, "")
616+ a, b := runner.await(), runner.await() // both assigned, neither acknowledged yet
617+ assert.NotEqual(t, a.GetJobId(), b.GetJobId())
618+
619+ runner.runJob(a, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "x") // fail-fast asks b to stop
620+
621+ c.waitFor("the run ends", func() bool { return c.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
622+ for _, j := range c.getRun(c.runs()[0].GetId()).GetJobs() {
623+ if j.GetId() == b.GetJobId() {
624+ assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_CANCELLED, j.GetConclusion())
625+ }
626+ }
627+ again, err := runner.fetch("")
628+ require.NoError(t, err)
629+ assert.Nil(t, again, "a cancelled job is not given again")
630+}
631+
632+// Once the runner acknowledged a job, a retried poll with the same poll_id has
633+// nothing to repeat: the plaintext token is no longer kept.
634+func TestCIAcknowledgedAssignmentIsNotRepeated(t *testing.T) {
635+ c := newCIHarness(t, func(cfg *usecase.CIConfig) { cfg.PollTimeout = 150 * time.Millisecond })
636+ runner := c.registerRunner("alpha", "ubuntu-latest")
637+ c.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
638+ var job *pb.Job
639+ c.waitFor("a job", func() bool {
640+ j, err := runner.fetch("poll-9")
641+ require.NoError(t, err)
642+ job = j
643+ return j != nil
644+ })
645+ _, err := runner.jobAPI(job).runner.Heartbeat(c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: job.GetJobId()}))
646+ require.NoError(t, err)
647+ again, err := runner.fetch("poll-9")
648+ require.NoError(t, err)
649+ assert.Nil(t, again, "the acknowledged assignment is not handed out again")
650+}
651+
652+// The push payload follows GitHub's (checked against the recordings of
653+// ermite-conformance): 12-character ids in "compare", the commit page for a
654+// created ref and a compare range for an update, the default branch of the
655+// repository, a tree id, and string counters.
656+func TestCIGithubContextOfAPush(t *testing.T) {
657+ c := newCIHarness(t, nil)
658+ runner := c.registerRunner("alpha", "ubuntu-latest")
659+ oneJobWF := strings.Split(ciWorkflow, " test:")[0]
660+ first := c.push("main", map[string]string{".github/workflows/ci.yml": oneJobWF}, "")
661+ ev := func() (map[string]any, map[string]any) {
662+ job := runner.await()
663+ var ctxs map[string]map[string]any
664+ require.NoError(t, json.Unmarshal([]byte(job.GetContexts()), &ctxs))
665+ gh := ctxs["github"]
666+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "x")
667+ return gh, gh["event"].(map[string]any)
668+ }
669+
670+ gh, event := ev()
671+ assert.Equal(t, publicOrigin+"/proj/commit/"+first[:12], event["compare"], "a created ref compares as its commit")
672+ assert.Equal(t, true, event["created"])
673+ assert.Equal(t, domain.ZeroOID, event["before"])
674+ repo := event["repository"].(map[string]any)
675+ assert.Equal(t, "main", repo["default_branch"])
676+ assert.Equal(t, "main", repo["master_branch"])
677+ assert.Equal(t, "90", gh["retention_days"], "a string, from the configured retention")
678+ head := event["head_commit"].(map[string]any)
679+ tree := strings.TrimSpace(c.bareGit("proj", "rev-parse", first+"^{tree}"))
680+ assert.Equal(t, tree, head["tree_id"])
681+
682+ second := c.push("main", map[string]string{"a.txt": "a\n"}, "")
683+ _, event = ev()
684+ assert.Equal(t, publicOrigin+"/proj/compare/"+first[:12]+"..."+second[:12], event["compare"])
685+ assert.Equal(t, false, event["created"])
686+}
A internal/app/ci_web_test.go +174 -0
@@ -0,0 +1,174 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "net/url"
7+ "strings"
8+ "testing"
9+ "time"
10+
11+ "connectrpc.com/connect"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+ "google.golang.org/protobuf/types/known/fieldmaskpb"
15+
16+ pb "bnema.dev/ermite-api/gen/ermite"
17+ "bnema.dev/ermite/internal/domain"
18+)
19+
20+// AC5, AC6: the pages of CI: the list of runs, a run, a job with its log (page,
21+// raw and stream), the owner's buttons, and the checks on the commit, the
22+// branch list, the commit list and the merge request.
23+func TestWebActionsPages(t *testing.T) {
24+ ci := newCIHarness(t, nil)
25+ c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
26+ b := browser{ci.harness}
27+ runner := ci.registerRunner("alpha", "ubuntu-latest")
28+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
29+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
30+ }))
31+ require.NoError(t, err)
32+ ci.push("main", map[string]string{"README": "x\n"}, "")
33+ sha := ci.push("feat", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "add ci")
34+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
35+
36+ // Queued: the list shows it, with a word for its state.
37+ list := b.get("/proj/actions", nil)
38+ require.Equal(t, 200, list.status, list.body)
39+ assert.Contains(t, list.body, "queued")
40+ assert.Contains(t, list.body, `href="/proj/actions/runs/1"`)
41+ assert.Contains(t, list.body, `aria-label="queued"`)
42+ assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&amp;view=actions", "the page follows the repository stream")
43+ assert.Contains(t, list.body, ">Actions<", "the tab")
44+
45+ // The MR page says "waiting for a runner" only when no runner matches.
46+ mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
47+ require.NoError(t, err)
48+ mrPage := b.get("/proj/merge-requests/1", nil)
49+ require.Equal(t, 200, mrPage.status)
50+ assert.Contains(t, mrPage.body, `id="mr-checks"`)
51+ assert.Contains(t, mrPage.body, "build")
52+ assert.Contains(t, mrPage.body, "a failing or unfinished check blocks the merge", "the rule shown is the one that applies")
53+ assert.NotContains(t, mrPage.body, "waiting for a runner", "a runner matches")
54+ _ = mr
55+
56+ job := runner.await()
57+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "##[group]Run echo hello", "hello <b>bold</b>", "##[endgroup]")
58+ next := runner.await()
59+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "##[error]Process completed with exit code 1.")
60+ ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
61+
62+ run := b.get("/proj/actions/runs/1", nil)
63+ require.Equal(t, 200, run.status, run.body)
64+ assert.Contains(t, run.body, "build")
65+ assert.Contains(t, run.body, "test")
66+ assert.Contains(t, run.body, "failure")
67+ assert.Contains(t, run.body, "on alpha (vm)", "the runner and its isolation are named")
68+ assert.NotContains(t, run.body, `action="/proj/actions/runs/1/rerun"`, "no button for a visitor")
69+
70+ jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
71+ require.Equal(t, 200, jobPage.status, jobPage.body)
72+ assert.Contains(t, jobPage.body, "Run echo hello")
73+ assert.Contains(t, jobPage.body, "hello &lt;b&gt;bold&lt;/b&gt;", "log text is escaped, never markup")
74+ assert.NotContains(t, jobPage.body, "<b>bold</b>")
75+ assert.NotContains(t, jobPage.body, "##[group]", "markers are shown as structure")
76+ assert.Contains(t, jobPage.body, `class="lg group"`)
77+ failed := b.get("/proj/actions/runs/1/job/"+next.GetJobId(), nil)
78+ assert.Contains(t, failed.body, `class="lg error"`)
79+
80+ raw := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/raw", nil)
81+ require.Equal(t, 200, raw.status)
82+ assert.Equal(t, "text/plain; charset=utf-8", raw.hdr.Get("Content-Type"))
83+ assert.Equal(t, "nosniff", raw.hdr.Get("X-Content-Type-Options"))
84+ assert.Contains(t, raw.hdr.Get("Content-Security-Policy"), "sandbox")
85+ assert.Contains(t, raw.body, "hello <b>bold</b>", "raw is raw text")
86+ assert.Regexp(t, `(?m)^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z ##\[group\]Run echo hello$`, raw.body)
87+
88+ // The stream of a finished job sends its lines then ends.
89+ stream := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/stream?from=1", nil)
90+ require.Equal(t, 200, stream.status)
91+ assert.Equal(t, "text/event-stream", stream.hdr.Get("Content-Type"))
92+ assert.Contains(t, stream.body, "event: ci-log")
93+ assert.Contains(t, stream.body, "event: ci-end")
94+ assert.NotContains(t, stream.body, "Run echo hello", "from=1 skips the first line")
95+
96+ // Checks next to the commit, the commits list and the branch list.
97+ commit := b.get("/proj/commit/"+sha, nil)
98+ require.Equal(t, 200, commit.status)
99+ assert.Contains(t, commit.body, "checks failed")
100+ assert.Contains(t, b.get("/proj/commits/feat", nil).body, "checks failed")
101+ assert.Contains(t, b.get("/proj/refs", nil).body, "checks failed")
102+
103+ // Private repository: nothing to anonymous.
104+ _, err = ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
105+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PRIVATE, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
106+ }))
107+ require.NoError(t, err)
108+ for _, p := range []string{"/proj/actions", "/proj/actions/runs/1", "/proj/actions/runs/1/job/" + job.GetJobId(), "/proj/actions/runs/1/job/" + job.GetJobId() + "/raw", "/proj/actions/runs/1/job/" + job.GetJobId() + "/stream"} {
109+ assert.Equal(t, 404, b.get(p, nil).status, p)
110+ }
111+
112+ // The owner sees the buttons and re-runs the failed job.
113+ token := c.signIn(t)
114+ owner := b.get("/proj/actions/runs/1", as(token))
115+ require.Equal(t, 200, owner.status)
116+ assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun"`)
117+ assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun-failed"`)
118+ assert.NotContains(t, owner.body, `action="/proj/actions/runs/1/cancel"`)
119+ // A POST needs the owner's session and the origin.
120+ assert.Equal(t, 403, b.do("POST", "/proj/actions/runs/1/rerun-failed", map[string]string{"Origin": publicOrigin}, "").status, "anonymous")
121+ res := b.form("/proj/actions/runs/1/rerun-failed", token, url.Values{})
122+ require.Equal(t, 303, res.status, res.body)
123+ ci.waitFor("attempt 2 is queued", func() bool { return ci.runs()[0].GetAttempt() == 2 })
124+ again := b.get("/proj/actions/runs/1?attempt=1", as(token))
125+ assert.Contains(t, again.body, "attempt 1")
126+ assert.NotContains(t, again.body, `action="/proj/actions/runs/1/rerun"`, "an earlier attempt has no button")
127+
128+ cancel := b.form("/proj/actions/runs/1/cancel", token, url.Values{})
129+ require.Equal(t, 303, cancel.status, cancel.body)
130+ ci.waitFor("cancelled", func() bool {
131+ return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED && ci.runs()[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_CANCELLED
132+ })
133+ assert.Equal(t, 409, b.form("/proj/actions/runs/1/cancel", token, url.Values{}).status, "a completed run cannot be cancelled")
134+}
135+
136+// The live updates of the pages: the run page gets the body of the run when a
137+// job changes, and the job page its state, never a line of the log.
138+func TestWebActionsLiveFragments(t *testing.T) {
139+ ci := newCIHarness(t, nil)
140+ c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
141+ runner := ci.registerRunner("alpha", "ubuntu-latest")
142+ _, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
143+ Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
144+ }))
145+ require.NoError(t, err)
146+ ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, " test:")[0]}, "")
147+ ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
148+ token := c.signIn(t)
149+
150+ run := ci.sse("topic=repo:proj&view=run&n=1", "")
151+ list := ci.sse("topic=repo:proj&view=actions", "")
152+ job := runner.await()
153+ jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
154+ _ = token
155+
156+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log")
157+ deadline := time.After(10 * time.Second)
158+ var sawRun, sawList, sawJob bool
159+ for !sawRun || !sawList || !sawJob {
160+ select {
161+ case ev := <-run.events:
162+ sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`))
163+ assert.NotContains(t, ev.data, "a secret value in the log")
164+ case ev := <-list.events:
165+ sawList = sawList || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-1"`))
166+ case ev := <-jobStream.events:
167+ sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`))
168+ assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log")
169+ case <-deadline:
170+ t.Fatalf("fragments: run %v, list %v, job %v", sawRun, sawList, sawJob)
171+ }
172+ }
173+ _ = domain.CISuccess
174+}
M internal/app/cli_e2e_test.go +1 -1
@@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
324324 ver := decode[versionOut](t, res.stdout)
325325 assert.Equal(t, c.url, ver.Server.URL)
326326 assert.NotEmpty(t, ver.Server.Version)
327- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)
327+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)
328328 assert.Equal(t, []string{}, ver.Server.MissingFeatures)
329329 stdout, stderr, code = c.bin(t, secret, "version")
330330 require.Equal(t, 0, code, stderr)
M internal/app/cli_repo.go +1 -1
@@ -275,7 +275,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
275275 Long: "Change the fields given as flags and leave the others alone; --description \"\" clears the\n" +
276276 "description. The default branch must already exist. --merge-modes lists the modes a merge request\n" +
277277 "may be merged with (fast-forward, squash; default for a new repository: both), and\n" +
278- "--failing-checks-block says whether a failing check prevents a merge (there are no checks yet).\n" +
278+ "--failing-checks-block says whether a failing check prevents a merge (a failed or unfinished CI check of the head; see README, CI).\n" +
279279 "Needs the repo:admin scope." + jsonRepoHelp,
280280 Args: cobra.ExactArgs(1),
281281 ValidArgsFunction: completeRepoNames(env),
A internal/app/cli_run.go +545 -0
@@ -0,0 +1,545 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "fmt"
8+ "io"
9+ "strconv"
10+ "strings"
11+ "time"
12+
13+ "connectrpc.com/connect"
14+ "github.com/spf13/cobra"
15+
16+ "bnema.dev/ermite-api/gen/ermite"
17+ "bnema.dev/ermite/internal/adapters/client"
18+)
19+
20+// jsonRun is the stable JSON shape of a run.
21+type jsonRun struct {
22+ ID int64 `json:"id"`
23+ Repo string `json:"repo"`
24+ Number int64 `json:"number"`
25+ Attempt int32 `json:"attempt"`
26+ Workflow string `json:"workflow"`
27+ WorkflowPath string `json:"workflow_path"`
28+ Title string `json:"title"`
29+ Event string `json:"event"`
30+ Ref string `json:"ref"`
31+ Sha string `json:"sha"`
32+ Actor string `json:"actor"`
33+ Status string `json:"status"`
34+ Conclusion string `json:"conclusion"`
35+ Error string `json:"error"`
36+ CreatedAt *string `json:"created_at"`
37+ StartedAt *string `json:"started_at"`
38+ CompletedAt *string `json:"completed_at"`
39+}
40+
41+type jsonStep struct {
42+ Number int32 `json:"number"`
43+ Name string `json:"name"`
44+ State string `json:"state"`
45+ Conclusion string `json:"conclusion"`
46+ StartedAt *string `json:"started_at"`
47+ CompletedAt *string `json:"completed_at"`
48+ LogStart int64 `json:"log_start"`
49+ LogEnd int64 `json:"log_end"`
50+}
51+
52+type jsonRunJob struct {
53+ ID string `json:"id"`
54+ Attempt int32 `json:"attempt"`
55+ Key string `json:"key"`
56+ Name string `json:"name"`
57+ Status string `json:"status"`
58+ Conclusion string `json:"conclusion"`
59+ Error string `json:"error"`
60+ Labels []string `json:"labels"`
61+ Needs []string `json:"needs"`
62+ Runner string `json:"runner"`
63+ Isolation string `json:"isolation"`
64+ Steps []jsonStep `json:"steps"`
65+ StartedAt *string `json:"started_at"`
66+ CompletedAt *string `json:"completed_at"`
67+}
68+
69+type jsonRunDetail struct {
70+ jsonRun
71+ LatestAttempt int32 `json:"latest_attempt"`
72+ Jobs []jsonRunJob `json:"jobs"`
73+}
74+
75+const jsonRunHelp = `
76+JSON fields of a run (--json): id, repo, number, attempt, workflow, workflow_path, title, event,
77+ref, sha, actor, status (queued, in_progress, completed), conclusion (success, failure, cancelled,
78+skipped, timed_out, neutral, action_required; empty until completed), error, created_at,
79+started_at and completed_at (RFC 3339 UTC, null when unset). "run view" adds latest_attempt and
80+jobs (id, attempt, key, name, status, conclusion, error, labels, needs, runner, isolation, steps
81+with number, name, state, conclusion, log_start and log_end, started_at, completed_at).`
82+
83+func statusText(s ermite.CiStatus) string {
84+ return strings.ToLower(strings.TrimPrefix(s.String(), "CI_STATUS_"))
85+}
86+
87+func conclusionText(c ermite.CiConclusion) string {
88+ if c == ermite.CiConclusion_CI_CONCLUSION_UNSPECIFIED {
89+ return ""
90+ }
91+ return strings.ToLower(strings.TrimPrefix(c.String(), "CI_CONCLUSION_"))
92+}
93+
94+func isolationText(i ermite.RunnerIsolation) string {
95+ if i == ermite.RunnerIsolation_RUNNER_ISOLATION_UNSPECIFIED {
96+ return ""
97+ }
98+ return strings.ReplaceAll(strings.ToLower(strings.TrimPrefix(i.String(), "RUNNER_ISOLATION_")), "_", "-")
99+}
100+
101+func stepStateText(s ermite.StepState) string {
102+ return strings.ToLower(strings.TrimPrefix(s.String(), "STEP_STATE_"))
103+}
104+
105+// runResult is "status" while the run is going, the conclusion once over.
106+func runResult(status ermite.CiStatus, c ermite.CiConclusion) string {
107+ if status == ermite.CiStatus_CI_STATUS_COMPLETED {
108+ return conclusionText(c)
109+ }
110+ return statusText(status)
111+}
112+
113+func toJSONRun(r *ermite.Run) jsonRun {
114+ return jsonRun{
115+ ID: r.GetId(), Repo: r.GetRepo(), Number: r.GetNumber(), Attempt: r.GetAttempt(), Workflow: r.GetWorkflowName(),
116+ WorkflowPath: r.GetWorkflowPath(), Title: r.GetTitle(), Event: r.GetEvent(), Ref: r.GetRef(), Sha: r.GetSha(),
117+ Actor: r.GetActor(), Status: statusText(r.GetStatus()), Conclusion: conclusionText(r.GetConclusion()), Error: r.GetError(),
118+ CreatedAt: jsonTime(r.GetCreatedAt()), StartedAt: jsonTime(r.GetStartedAt()), CompletedAt: jsonTime(r.GetCompletedAt()),
119+ }
120+}
121+
122+func toJSONRunJob(j *ermite.RunJob) jsonRunJob {
123+ steps := make([]jsonStep, 0, len(j.GetSteps()))
124+ for _, s := range j.GetSteps() {
125+ steps = append(steps, jsonStep{
126+ Number: s.GetNumber(), Name: s.GetName(), State: stepStateText(s.GetState()), Conclusion: conclusionText(s.GetConclusion()),
127+ StartedAt: jsonTime(s.GetStartedAt()), CompletedAt: jsonTime(s.GetCompletedAt()), LogStart: s.GetLogStart(), LogEnd: s.GetLogEnd(),
128+ })
129+ }
130+ labels, needs := j.GetLabels(), j.GetNeeds()
131+ if labels == nil {
132+ labels = []string{}
133+ }
134+ if needs == nil {
135+ needs = []string{}
136+ }
137+ return jsonRunJob{
138+ ID: j.GetId(), Attempt: j.GetAttempt(), Key: j.GetKey(), Name: j.GetName(), Status: statusText(j.GetStatus()),
139+ Conclusion: conclusionText(j.GetConclusion()), Error: j.GetError(), Labels: labels, Needs: needs,
140+ Runner: j.GetRunnerName(), Isolation: isolationText(j.GetIsolation()), Steps: steps,
141+ StartedAt: jsonTime(j.GetStartedAt()), CompletedAt: jsonTime(j.GetCompletedAt()),
142+ }
143+}
144+
145+func newRunCmd(env *cliEnv) *cobra.Command {
146+ run := &cobra.Command{
147+ Use: "run",
148+ Short: "List, read, watch, cancel and re-run CI runs",
149+ Long: "Work with the CI runs of a repository (the workflows that .github/workflows start on a push).\n" +
150+ "Reading needs the access of reading the repository; cancel and rerun need the ci:write scope.",
151+ }
152+ run.AddCommand(newRunListCmd(env), newRunViewCmd(env), newRunWatchCmd(env), newRunCancelCmd(env), newRunRerunCmd(env))
153+ return run
154+}
155+
156+// parseRunID reads a run id argument.
157+func parseRunID(s string) (int64, error) {
158+ id, err := strconv.ParseInt(strings.TrimPrefix(s, "#"), 10, 64)
159+ if err != nil || id < 1 {
160+ return 0, fmt.Errorf("%q is not a run id: use the number shown by `ermite run list`", s)
161+ }
162+ return id, nil
163+}
164+
165+func newRunListCmd(env *cliEnv) *cobra.Command {
166+ var workflow, branch, status string
167+ var limit int
168+ cmd := &cobra.Command{
169+ Use: "list <repo>",
170+ Short: "List the runs of a repository",
171+ Long: "List the runs of a repository, newest first. --workflow keeps one workflow file (its path or its\n" +
172+ "file name), --branch one branch, --status one status (queued, in_progress, completed)." + jsonRunHelp + "\n--json prints an array of runs.",
173+ Args: cobra.ExactArgs(1),
174+ ValidArgsFunction: completeRepoNames(env),
175+ }
176+ cmd.Flags().StringVar(&workflow, "workflow", "", "only the runs of this workflow file")
177+ cmd.Flags().StringVar(&branch, "branch", "", "only the runs of this branch")
178+ cmd.Flags().StringVar(&status, "status", "", "only the runs with this status: queued, in_progress, completed")
179+ cmd.Flags().IntVar(&limit, "limit", 20, "show at most this many runs")
180+ asJSON := addJSONFlag(cmd)
181+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
182+ st, err := parseStatusFlag(status)
183+ if err != nil {
184+ return err
185+ }
186+ if limit < 1 {
187+ return fmt.Errorf("--limit must be at least 1")
188+ }
189+ c, err := env.client(cmd, true)
190+ if err != nil {
191+ return err
192+ }
193+ ctx := env.ctx(cmd)
194+ var runs []*ermite.Run
195+ token := ""
196+ for len(runs) < limit {
197+ res, err := c.Runs().ListRuns(ctx, connect.NewRequest(&ermite.ListRunsRequest{
198+ Repo: args[0], PageSize: int32(min(limit-len(runs), 100)), PageToken: token, //nolint:gosec // bounded
199+ Workflow: workflow, Branch: branch, Status: st,
200+ }))
201+ if err != nil {
202+ return err
203+ }
204+ runs = append(runs, res.Msg.GetRuns()...)
205+ if token = res.Msg.GetNextPageToken(); token == "" {
206+ break
207+ }
208+ }
209+ if *asJSON {
210+ out := make([]jsonRun, 0, len(runs))
211+ for _, r := range runs {
212+ out = append(out, toJSONRun(r))
213+ }
214+ return writeJSON(cmd.OutOrStdout(), out)
215+ }
216+ if len(runs) == 0 {
217+ noticef(cmd, "no runs")
218+ return nil
219+ }
220+ rows := make([][]string, 0, len(runs))
221+ for _, r := range runs {
222+ rows = append(rows, []string{
223+ strconv.FormatInt(r.GetId(), 10), runResult(r.GetStatus(), r.GetConclusion()), r.GetWorkflowName(),
224+ strings.TrimPrefix(strings.TrimPrefix(r.GetRef(), "refs/heads/"), "refs/tags/"), shortSha(r.GetSha()),
225+ r.GetActor(), shortTime(r.GetCreatedAt(), "-"), r.GetTitle(),
226+ })
227+ }
228+ return table(cmd.OutOrStdout(), []string{"ID", "RESULT", "WORKFLOW", "REF", "COMMIT", "ACTOR", "CREATED", "TITLE"}, rows)
229+ }
230+ return cmd
231+}
232+
233+func shortSha(s string) string {
234+ if len(s) > 8 {
235+ return s[:8]
236+ }
237+ return s
238+}
239+
240+func parseStatusFlag(s string) (ermite.CiStatus, error) {
241+ switch strings.ToLower(s) {
242+ case "":
243+ return ermite.CiStatus_CI_STATUS_UNSPECIFIED, nil
244+ case "queued":
245+ return ermite.CiStatus_CI_STATUS_QUEUED, nil
246+ case "in_progress", "in-progress":
247+ return ermite.CiStatus_CI_STATUS_IN_PROGRESS, nil
248+ case "completed":
249+ return ermite.CiStatus_CI_STATUS_COMPLETED, nil
250+ }
251+ return 0, fmt.Errorf("unknown status %q: use queued, in_progress or completed", s)
252+}
253+
254+func newRunViewCmd(env *cliEnv) *cobra.Command {
255+ var attempt int32
256+ var logFlag bool
257+ var jobFlag string
258+ cmd := &cobra.Command{
259+ Use: "view <repo> <run id>",
260+ Short: "Show a run with its jobs and steps, or the log of a job",
261+ Long: "Show a run: its state, its jobs and their steps. --attempt reads an earlier attempt.\n" +
262+ "With --log, print the log of the run's jobs (all of them, or the one chosen by --job: its id\n" +
263+ "or its name) instead; the lines are printed as the runner sent them, prefixed by nothing." + jsonRunHelp,
264+ Args: cobra.ExactArgs(2),
265+ ValidArgsFunction: completeRepoNames(env),
266+ }
267+ cmd.Flags().Int32Var(&attempt, "attempt", 0, "the attempt to read (default: the latest)")
268+ cmd.Flags().BoolVar(&logFlag, "log", false, "print the log of the jobs instead of the summary")
269+ cmd.Flags().StringVar(&jobFlag, "job", "", "with --log, only this job (its id or its name)")
270+ asJSON := addJSONFlag(cmd)
271+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
272+ id, err := parseRunID(args[1])
273+ if err != nil {
274+ return err
275+ }
276+ c, err := env.client(cmd, true)
277+ if err != nil {
278+ return err
279+ }
280+ ctx := env.ctx(cmd)
281+ res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id, Attempt: attempt}))
282+ if err != nil {
283+ return err
284+ }
285+ if logFlag {
286+ return printRunLog(ctx, cmd.OutOrStdout(), c, args[0], res.Msg, jobFlag, false)
287+ }
288+ if *asJSON {
289+ d := jsonRunDetail{jsonRun: toJSONRun(res.Msg.GetRun()), LatestAttempt: res.Msg.GetLatestAttempt(), Jobs: []jsonRunJob{}}
290+ for _, j := range res.Msg.GetJobs() {
291+ d.Jobs = append(d.Jobs, toJSONRunJob(j))
292+ }
293+ return writeJSON(cmd.OutOrStdout(), d)
294+ }
295+ return printRun(cmd.OutOrStdout(), res.Msg)
296+ }
297+ return cmd
298+}
299+
300+func printRun(w io.Writer, r *ermite.GetRunResponse) error {
301+ run := r.GetRun()
302+ pairs := [][2]string{
303+ {"run", fmt.Sprintf("%d (#%d of %s)", run.GetId(), run.GetNumber(), run.GetWorkflowName())},
304+ {"result", runResult(run.GetStatus(), run.GetConclusion())},
305+ {"ref", run.GetRef()},
306+ {"commit", run.GetSha()},
307+ {"title", run.GetTitle()},
308+ {"actor", run.GetActor()},
309+ {"attempt", fmt.Sprintf("%d of %d", run.GetAttempt(), r.GetLatestAttempt())},
310+ {"created", shortTime(run.GetCreatedAt(), "-")},
311+ }
312+ if run.GetError() != "" {
313+ pairs = append(pairs, [2]string{"error", run.GetError()})
314+ }
315+ if err := fields(w, pairs); err != nil {
316+ return err
317+ }
318+ if len(r.GetJobs()) == 0 {
319+ return nil
320+ }
321+ _, _ = fmt.Fprintln(w)
322+ for _, j := range r.GetJobs() {
323+ line := fmt.Sprintf("%s %s", runResult(j.GetStatus(), j.GetConclusion()), j.GetName())
324+ if j.GetRunnerName() != "" {
325+ line += " [" + j.GetRunnerName() + "]"
326+ }
327+ _, _ = fmt.Fprintln(w, clean(line))
328+ if j.GetError() != "" {
329+ _, _ = fmt.Fprintln(w, " "+clean(j.GetError()))
330+ }
331+ for _, s := range j.GetSteps() {
332+ res := stepStateText(s.GetState())
333+ if s.GetState() == ermite.StepState_STEP_STATE_COMPLETED {
334+ res = conclusionText(s.GetConclusion())
335+ }
336+ _, _ = fmt.Fprintln(w, clean(fmt.Sprintf(" %-9s %2d %s", res, s.GetNumber(), s.GetName())))
337+ }
338+ }
339+ return nil
340+}
341+
342+// pickJobs selects the jobs --job names (an id or a name), or all of them.
343+func pickJobs(jobs []*ermite.RunJob, sel string) ([]*ermite.RunJob, error) {
344+ if sel == "" {
345+ return jobs, nil
346+ }
347+ var out []*ermite.RunJob
348+ for _, j := range jobs {
349+ if j.GetId() == sel || j.GetName() == sel || j.GetKey() == sel {
350+ out = append(out, j)
351+ }
352+ }
353+ if len(out) == 0 {
354+ return nil, fmt.Errorf("the run has no job %q", sel)
355+ }
356+ return out, nil
357+}
358+
359+// printRunLog prints the log of the chosen jobs; with follow it waits for the
360+// lines of a job that is running.
361+func printRunLog(ctx context.Context, w io.Writer, c *client.Client, repo string, r *ermite.GetRunResponse, sel string, follow bool) error {
362+ jobs, err := pickJobs(r.GetJobs(), sel)
363+ if err != nil {
364+ return err
365+ }
366+ for _, j := range jobs {
367+ if len(jobs) > 1 {
368+ _, _ = fmt.Fprintf(w, "== %s ==\n", clean(j.GetName()))
369+ }
370+ if err := streamJobLog(ctx, w, c, repo, j, follow); err != nil {
371+ return err
372+ }
373+ }
374+ return nil
375+}
376+
377+// streamJobLog writes the lines of one job. Continued pieces are joined to the
378+// line before them, as the raw log has them.
379+func streamJobLog(ctx context.Context, w io.Writer, c *client.Client, repo string, j *ermite.RunJob, follow bool) error {
380+ if j.GetStatus() == ermite.CiStatus_CI_STATUS_QUEUED && !follow {
381+ noticef2(w, "job %q has not started: no log yet", j.GetName())
382+ return nil
383+ }
384+ stream, err := c.Runs().GetJobLog(ctx, connect.NewRequest(&ermite.GetJobLogRequest{
385+ Repo: repo, JobId: j.GetId(), Attempt: j.GetAttempt(), Follow: follow,
386+ }))
387+ if err != nil {
388+ return err
389+ }
390+ defer func() { _ = stream.Close() }()
391+ open := false
392+ for stream.Receive() {
393+ for _, l := range stream.Msg().GetLines() {
394+ if l.GetContinued() && open {
395+ _, _ = fmt.Fprint(w, clean(l.GetText()))
396+ continue
397+ }
398+ if open {
399+ _, _ = fmt.Fprintln(w)
400+ }
401+ _, _ = fmt.Fprint(w, clean(l.GetText()))
402+ open = true
403+ }
404+ }
405+ if open {
406+ _, _ = fmt.Fprintln(w)
407+ }
408+ if err := stream.Err(); err != nil && connect.CodeOf(err) != connect.CodeNotFound {
409+ return err
410+ }
411+ return nil
412+}
413+
414+func noticef2(w io.Writer, format string, args ...any) {
415+ _, _ = fmt.Fprintf(w, "ermite: "+format+"\n", args...)
416+}
417+
418+func newRunWatchCmd(env *cliEnv) *cobra.Command {
419+ var interval time.Duration
420+ var logFlag bool
421+ cmd := &cobra.Command{
422+ Use: "watch <repo> <run id>",
423+ Short: "Wait for a run to complete and follow its jobs",
424+ Long: "Print the state of a run as its jobs change, until it completes. With --log, also stream the log\n" +
425+ "of its jobs. The exit status is 0 for a run that succeeded and 1 for any other conclusion, so\n" +
426+ "`ermite run watch` can gate a script.",
427+ Args: cobra.ExactArgs(2),
428+ ValidArgsFunction: completeRepoNames(env),
429+ }
430+ cmd.Flags().DurationVar(&interval, "interval", 2*time.Second, "how often to read the run")
431+ cmd.Flags().BoolVar(&logFlag, "log", false, "stream the log of the jobs while they run")
432+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
433+ id, err := parseRunID(args[1])
434+ if err != nil {
435+ return err
436+ }
437+ c, err := env.client(cmd, true)
438+ if err != nil {
439+ return err
440+ }
441+ ctx := env.ctx(cmd)
442+ w := cmd.OutOrStdout()
443+ last := ""
444+ for {
445+ res, err := c.Runs().GetRun(ctx, connect.NewRequest(&ermite.GetRunRequest{Repo: args[0], RunId: id}))
446+ if err != nil {
447+ return err
448+ }
449+ if line := watchLine(res.Msg); line != last {
450+ _, _ = fmt.Fprintln(w, line)
451+ last = line
452+ }
453+ if res.Msg.GetRun().GetStatus() == ermite.CiStatus_CI_STATUS_COMPLETED {
454+ if logFlag {
455+ if err := printRunLog(ctx, w, c, args[0], res.Msg, "", false); err != nil {
456+ return err
457+ }
458+ }
459+ if res.Msg.GetRun().GetConclusion() != ermite.CiConclusion_CI_CONCLUSION_SUCCESS {
460+ return fmt.Errorf("run %d: %s", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
461+ }
462+ return nil
463+ }
464+ select {
465+ case <-ctx.Done():
466+ return ctx.Err()
467+ case <-time.After(interval):
468+ }
469+ }
470+ }
471+ return cmd
472+}
473+
474+// watchLine is a one-line state of a run for `run watch`.
475+func watchLine(r *ermite.GetRunResponse) string {
476+ var parts []string
477+ for _, j := range r.GetJobs() {
478+ parts = append(parts, j.GetName()+" "+runResult(j.GetStatus(), j.GetConclusion()))
479+ }
480+ return clean(fmt.Sprintf("run %d %s %s", r.GetRun().GetId(), runResult(r.GetRun().GetStatus(), r.GetRun().GetConclusion()), strings.Join(parts, ", ")))
481+}
482+
483+func newRunCancelCmd(env *cliEnv) *cobra.Command {
484+ cmd := &cobra.Command{
485+ Use: "cancel <repo> <run id>",
486+ Short: "Cancel a run",
487+ Long: "Ask the runners to stop the jobs of a run that is not over; jobs that no runner has yet are cancelled at once. Needs the ci:write scope.",
488+ Args: cobra.ExactArgs(2),
489+ ValidArgsFunction: completeRepoNames(env),
490+ }
491+ asJSON := addJSONFlag(cmd)
492+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
493+ id, err := parseRunID(args[1])
494+ if err != nil {
495+ return err
496+ }
497+ c, err := env.client(cmd, true)
498+ if err != nil {
499+ return err
500+ }
501+ res, err := c.Runs().CancelRun(env.ctx(cmd), connect.NewRequest(&ermite.CancelRunRequest{Repo: args[0], RunId: id}))
502+ if err != nil {
503+ return err
504+ }
505+ if *asJSON {
506+ return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
507+ }
508+ noticef(cmd, "run %d: cancellation requested (%s)", id, runResult(res.Msg.GetRun().GetStatus(), res.Msg.GetRun().GetConclusion()))
509+ return nil
510+ }
511+ return cmd
512+}
513+
514+func newRunRerunCmd(env *cliEnv) *cobra.Command {
515+ var failed bool
516+ cmd := &cobra.Command{
517+ Use: "rerun <repo> <run id>",
518+ Short: "Run a completed run again",
519+ Long: "Start a new attempt of a completed run, with the same commit and workflow file. With --failed, only the jobs that did not succeed (and the jobs that need them) run again. Needs the ci:write scope." + jsonRunHelp,
520+ Args: cobra.ExactArgs(2),
521+ ValidArgsFunction: completeRepoNames(env),
522+ }
523+ cmd.Flags().BoolVar(&failed, "failed", false, "run again only the jobs that failed, and those that need them")
524+ asJSON := addJSONFlag(cmd)
525+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
526+ id, err := parseRunID(args[1])
527+ if err != nil {
528+ return err
529+ }
530+ c, err := env.client(cmd, true)
531+ if err != nil {
532+ return err
533+ }
534+ res, err := c.Runs().RerunRun(env.ctx(cmd), connect.NewRequest(&ermite.RerunRunRequest{Repo: args[0], RunId: id, FailedOnly: failed}))
535+ if err != nil {
536+ return err
537+ }
538+ if *asJSON {
539+ return writeJSON(cmd.OutOrStdout(), toJSONRun(res.Msg.GetRun()))
540+ }
541+ noticef(cmd, "run %d: attempt %d started", id, res.Msg.GetRun().GetAttempt())
542+ return nil
543+ }
544+ return cmd
545+}
A internal/app/cli_run_test.go +132 -0
@@ -0,0 +1,132 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "strings"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ pb "bnema.dev/ermite-api/gen/ermite"
14+ "bnema.dev/ermite/internal/domain"
15+)
16+
17+// The CLI over a real server: `run list|view|watch|cancel|rerun`, `runner
18+// list|remove`, and the runner token preset.
19+func TestCLIRunsAndRunners(t *testing.T) {
20+ ci := newCIHarness(t, nil)
21+ h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
22+ runner := ci.registerRunner("alpha", "ubuntu-latest")
23+ ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
24+ ci.waitFor("a run", func() bool { return len(ci.runs()) == 1 })
25+
26+ job := runner.await()
27+ runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "hello from the job")
28+ next := runner.await()
29+ runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "this one failed")
30+ ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
31+ id := "1"
32+
33+ // list
34+ out := h.cli(t, adminToken, "run", "list", "proj")
35+ require.Empty(t, out.err, out.stderr)
36+ assert.Contains(t, out.stdout, "RESULT")
37+ assert.Contains(t, out.stdout, "failure")
38+ assert.Contains(t, out.stdout, "ci")
39+
40+ js := h.cli(t, adminToken, "run", "list", "proj", "--json")
41+ require.NoError(t, js.err)
42+ runs := decode[[]jsonRun](t, js.stdout)
43+ require.Len(t, runs, 1)
44+ assert.Equal(t, "failure", runs[0].Conclusion)
45+ assert.Equal(t, "completed", runs[0].Status)
46+ assert.Equal(t, "refs/heads/main", runs[0].Ref)
47+ assert.Equal(t, []string{"actor", "attempt", "completed_at", "conclusion", "created_at", "error", "event", "id", "number", "ref", "repo", "sha", "started_at", "status", "title", "workflow", "workflow_path"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(js.stdout), "[]"))))
48+
49+ none := h.cli(t, adminToken, "run", "list", "proj", "--status", "queued")
50+ require.NoError(t, none.err)
51+ assert.Contains(t, none.stderr, "no runs")
52+ bad := h.cli(t, adminToken, "run", "list", "proj", "--status", "weird")
53+ require.Error(t, bad.err)
54+
55+ // view
56+ view := h.cli(t, adminToken, "run", "view", "proj", id)
57+ require.NoError(t, view.err, view.stderr)
58+ assert.Contains(t, view.stdout, "result")
59+ assert.Contains(t, view.stdout, "build")
60+ assert.Contains(t, view.stdout, "[alpha]")
61+ assert.Contains(t, view.stdout, "Set up job")
62+
63+ vjs := h.cli(t, adminToken, "run", "view", "proj", id, "--json")
64+ detail := decode[jsonRunDetail](t, vjs.stdout)
65+ require.Len(t, detail.Jobs, 2)
66+ assert.Equal(t, "build", detail.Jobs[0].Key)
67+ assert.Equal(t, "vm", detail.Jobs[0].Isolation)
68+ assert.Equal(t, int32(1), detail.LatestAttempt)
69+
70+ // --log, all jobs and one job
71+ all := h.cli(t, adminToken, "run", "view", "proj", id, "--log")
72+ require.NoError(t, all.err, all.stderr)
73+ assert.Contains(t, all.stdout, "== build ==")
74+ assert.Contains(t, all.stdout, "hello from the job")
75+ assert.Contains(t, all.stdout, "this one failed")
76+ one := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "test")
77+ require.NoError(t, one.err)
78+ assert.Contains(t, one.stdout, "this one failed")
79+ assert.NotContains(t, one.stdout, "hello from the job")
80+ missing := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "nope")
81+ require.Error(t, missing.err)
82+ assert.Contains(t, missing.err.Error(), `no job "nope"`)
83+
84+ // watch: a completed failing run prints once and exits with an error.
85+ w := h.cli(t, adminToken, "run", "watch", "proj", id)
86+ require.Error(t, w.err)
87+ assert.Contains(t, w.err.Error(), "failure")
88+ assert.Contains(t, w.stdout, "build success")
89+
90+ // rerun --failed, then cancel the new attempt.
91+ re := h.cli(t, adminToken, "run", "rerun", "proj", id, "--failed")
92+ require.NoError(t, re.err, re.stderr)
93+ assert.Contains(t, re.stderr, "attempt 2 started")
94+ ca := h.cli(t, adminToken, "run", "cancel", "proj", id, "--json")
95+ require.NoError(t, ca.err, ca.stderr)
96+ assert.Equal(t, int32(2), decode[jsonRun](t, ca.stdout).Attempt)
97+ // No runner holds a job of attempt 2, so the cancel ended it at once: a
98+ // completed run cannot be cancelled again.
99+ again := h.cli(t, adminToken, "run", "cancel", "proj", id)
100+ require.Error(t, again.err)
101+ assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(again.err))
102+
103+ // A reader may look but not cancel or rerun.
104+ reader := h.token(scopes(domain.ScopeRepoRead))
105+ assert.NoError(t, h.cli(t, reader, "run", "view", "proj", id).err)
106+ denied := h.cli(t, reader, "run", "cancel", "proj", id)
107+ require.Error(t, denied.err)
108+ assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(denied.err))
109+ assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "abc").err)
110+
111+ // runners
112+ rl := h.cli(t, adminToken, "runner", "list")
113+ require.NoError(t, rl.err, rl.stderr)
114+ assert.Contains(t, rl.stdout, "alpha")
115+ assert.Contains(t, rl.stdout, "vm")
116+ rjs := decode[[]jsonRunner](t, h.cli(t, adminToken, "runner", "list", "--json").stdout)
117+ require.Len(t, rjs, 1)
118+ assert.Equal(t, []string{"linux", "self-hosted", "ubuntu-latest", "x64"}, rjs[0].Labels)
119+ rm := h.cli(t, adminToken, "runner", "remove", rjs[0].ID)
120+ require.NoError(t, rm.err, rm.stderr)
121+ assert.Error(t, h.cli(t, adminToken, "runner", "remove", rjs[0].ID).err, "already removed")
122+ assert.Contains(t, h.cli(t, adminToken, "runner", "list").stderr, "no runners")
123+
124+ // The runner token preset makes a token that registers runners and nothing
125+ // else.
126+ tok := h.cli(t, adminToken, "token", "create", "runner host", "--preset", "runner", "--json")
127+ require.NoError(t, tok.err, tok.stderr)
128+ created := decode[jsonCreatedToken](t, tok.stdout)
129+ assert.Equal(t, []string{"runner:register"}, created.Scopes)
130+ assert.NoError(t, h.cli(t, created.Secret, "runner", "list").err)
131+ assert.Error(t, h.cli(t, created.Secret, "run", "cancel", "proj", id).err)
132+}
A internal/app/cli_runner.go +147 -0
@@ -0,0 +1,147 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "strings"
10+ "time"
11+
12+ "connectrpc.com/connect"
13+ "github.com/spf13/cobra"
14+
15+ "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/adapters/client"
17+)
18+
19+type jsonRunner struct {
20+ ID string `json:"id"`
21+ Name string `json:"name"`
22+ Labels []string `json:"labels"`
23+ Isolation string `json:"isolation"`
24+ Version string `json:"version"`
25+ Arch string `json:"arch"`
26+ Os string `json:"os"`
27+ RegisteredAt *string `json:"registered_at"`
28+ LastSeen *string `json:"last_seen"`
29+ Busy bool `json:"busy"`
30+}
31+
32+const jsonRunnerHelp = `
33+JSON fields of a runner (--json): id, name, labels, isolation (vm, container-sysbox,
34+container-privileged), version, arch, os, registered_at, last_seen (RFC 3339 UTC, null if never
35+seen) and busy. A runner not seen for more than about 2 minutes is offline.`
36+
37+func newRunnerCmd(env *cliEnv) *cobra.Command {
38+ runner := &cobra.Command{
39+ Use: "runner",
40+ Short: "List and remove CI runners",
41+ Long: "Manage the CI runners registered on a remote server. A runner registers itself with a token\n" +
42+ "made by `ermite token create --preset runner`. Needs the runner:register scope, on a token that is\n" +
43+ "not limited to some repositories.",
44+ }
45+ runner.AddCommand(newRunnerListCmd(env), newRunnerRemoveCmd(env))
46+ return runner
47+}
48+
49+func newRunnerListCmd(env *cliEnv) *cobra.Command {
50+ cmd := &cobra.Command{
51+ Use: "list",
52+ Short: "List the registered runners",
53+ Long: "List the registered runners ordered by name." + jsonRunnerHelp + "\n--json prints an array of runners.",
54+ Args: cobra.NoArgs,
55+ }
56+ asJSON := addJSONFlag(cmd)
57+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58+ c, err := env.client(cmd, true)
59+ if err != nil {
60+ return err
61+ }
62+ ctx := env.ctx(cmd)
63+ runners, err := collect(ctx, func(token string) ([]*ermite.Runner, string, error) {
64+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{PageToken: token}))
65+ if err != nil {
66+ return nil, "", err
67+ }
68+ return res.Msg.GetRunners(), res.Msg.GetNextPageToken(), nil
69+ })
70+ if err != nil {
71+ return err
72+ }
73+ if *asJSON {
74+ out := make([]jsonRunner, 0, len(runners))
75+ for _, r := range runners {
76+ labels := r.GetLabels()
77+ if labels == nil {
78+ labels = []string{}
79+ }
80+ out = append(out, jsonRunner{
81+ ID: r.GetId(), Name: r.GetName(), Labels: labels, Isolation: isolationText(r.GetIsolation()), Version: r.GetVersion(),
82+ Arch: r.GetArch(), Os: r.GetOs(), RegisteredAt: jsonTime(r.GetRegisteredAt()), LastSeen: jsonTime(r.GetLastSeen()), Busy: r.GetBusy(),
83+ })
84+ }
85+ return writeJSON(cmd.OutOrStdout(), out)
86+ }
87+ if len(runners) == 0 {
88+ noticef(cmd, "no runners: register one with `ermite-runner register`")
89+ return nil
90+ }
91+ rows := make([][]string, 0, len(runners))
92+ for _, r := range runners {
93+ state := "offline"
94+ if ls := r.GetLastSeen(); ls.IsValid() && env.now().Sub(ls.AsTime()) < 2*time.Minute {
95+ state = "online"
96+ if r.GetBusy() {
97+ state = "busy"
98+ }
99+ }
100+ rows = append(rows, []string{
101+ r.GetId(), r.GetName(), state, isolationText(r.GetIsolation()), r.GetVersion(),
102+ strings.Join(r.GetLabels(), ","), shortTime(r.GetLastSeen(), "never"),
103+ })
104+ }
105+ return table(cmd.OutOrStdout(), []string{"ID", "NAME", "STATE", "ISOLATION", "VERSION", "LABELS", "LAST SEEN"}, rows)
106+ }
107+ return cmd
108+}
109+
110+func newRunnerRemoveCmd(env *cliEnv) *cobra.Command {
111+ cmd := &cobra.Command{
112+ Use: "remove <runner id>",
113+ Short: "Remove a runner",
114+ Long: "Remove a runner: its token stops working at once and it gets no more jobs. A job it is running is not\n" +
115+ "affected: it ends, or fails as a lost runner when its heartbeats stop.",
116+ Args: cobra.ExactArgs(1),
117+ ValidArgsFunction: func(cmd *cobra.Command, args []string, prefix string) ([]string, cobra.ShellCompDirective) {
118+ if len(args) > 0 {
119+ return nil, cobra.ShellCompDirectiveNoFileComp
120+ }
121+ out := complete(env, cmd, prefix, func(ctx context.Context, c *client.Client) ([]*ermite.Runner, error) {
122+ res, err := c.Runs().ListRunners(ctx, connect.NewRequest(&ermite.ListRunnersRequest{}))
123+ if err != nil {
124+ return nil, err
125+ }
126+ return res.Msg.GetRunners(), nil
127+ }, func(r *ermite.Runner) (string, string) { return r.GetId(), r.GetName() })
128+ return out, cobra.ShellCompDirectiveNoFileComp
129+ },
130+ }
131+ cmd.RunE = func(cmd *cobra.Command, args []string) error {
132+ c, err := env.client(cmd, true)
133+ if err != nil {
134+ return err
135+ }
136+ if _, err := c.Runs().RemoveRunner(env.ctx(cmd), connect.NewRequest(&ermite.RemoveRunnerRequest{RunnerId: args[0]})); err != nil {
137+ var ce *connect.Error
138+ if errors.As(err, &ce) && ce.Code() == connect.CodeNotFound {
139+ return fmt.Errorf("no runner has the id %q", args[0])
140+ }
141+ return err
142+ }
143+ noticef(cmd, "runner %s removed", args[0])
144+ return nil
145+ }
146+ return cmd
147+}
M internal/app/cli_token.go +1 -0
@@ -81,6 +81,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
8181 "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +
8282 " cli every scope, for your own daily use\n" +
8383 " agent repo:write, mr:write\n" +
84+ " runner runner:register (a CI runner host)\n" +
8485 " ci repo:read\n" +
8586 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
8687 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
M internal/app/cli_version_test.go +5 -5
@@ -14,7 +14,7 @@ import (
1414 )
1515
1616 // allFeatures are the features this client uses.
17-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session"}
17+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners"}
1818
1919 func versionFake(features ...string) *fakeAPI {
2020 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) {
3535 f := versionFake(allFeatures...)
3636 url := f.serve(t)
3737 res := runCLI(t, envFor(url), "", "version").ok(t)
38- assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session\n", res.stdout)
38+ assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, merge_requests.delete_branch, signing_key, session, ci.runs, ci.runners\n", res.stdout)
3939 assert.Empty(t, res.stderr, "no warning when every feature is there")
4040
4141 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
42- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":[]}}`, res.stdout)
42+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":[]}}`, res.stdout)
4343 }
4444
4545 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
46- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session")
46+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "ci.runs", "ci.runners")
4747 url := f.serve(t)
4848 res := runCLI(t, envFor(url), "", "version").ok(t)
4949 assert.Contains(t, res.stdout, "v0.4.0")
@@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
5151 assert.NotContains(t, res.stderr, "repo.update")
5252
5353 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
54- assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout)
54+ assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","merge_requests.delete_branch","signing_key","session","ci.runs","ci.runners"],"missing_features":["events.resume"]}}`, res.stdout)
5555 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5656
5757 f = versionFake()
M internal/app/e2e_helpers_test.go +11 -1
@@ -53,6 +53,12 @@ type harnessOpts struct {
5353 // newHarness starts a forge. The data directory is short: the hook socket
5454 // lives in it and Unix socket paths are limited to about 100 bytes.
5555 func newHarness(t *testing.T, o harnessOpts) *harness {
56+ t.Helper()
57+ return newHarnessWith(t, o, nil)
58+}
59+
60+// newHarnessWith is newHarness with a change of the wiring options.
61+func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness {
5662 t.Helper()
5763 gitBin := requireGit(t)
5864 data, err := os.MkdirTemp("", "ermite")
@@ -85,7 +91,11 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
8591 if hookExe == "" {
8692 hookExe = binary(t)
8793 }
88- f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery})
94+ opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}
95+ if tweak != nil {
96+ tweak(&opts)
97+ }
98+ f, err := newForge(ctx, cfg, opts)
8999 require.NoError(t, err)
90100
91101 h := &harness{t: t, forge: f, data: data, logs: logs, ctx: ctx, cancel: cancel, hooksDone: make(chan error, 1)}
M internal/app/forge.go +30 -2
@@ -15,6 +15,7 @@ import (
1515
1616 "github.com/bnema/zerowrap"
1717
18+ "bnema.dev/ermite/internal/adapters/cilogs"
1819 "bnema.dev/ermite/internal/adapters/ermitegit"
1920 "bnema.dev/ermite/internal/adapters/eventbus"
2021 "bnema.dev/ermite/internal/adapters/gitcli"
@@ -48,6 +49,9 @@ type options struct {
4849 eventBuffer int
4950 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
5051 revalidateEvery time.Duration
52+ // ci overrides the settings of the CI engine (tests shorten its
53+ // durations).
54+ ci func(*usecase.CIConfig)
5155 }
5256
5357 // forge is the wired server: every adapter and use case, ready to serve.
@@ -74,8 +78,11 @@ type forge struct {
7478 SigningKey *usecase.SigningKey
7579 Sessions *usecase.Sessions
7680 EventLog *usecase.Events
77- API *rpc.API
78- Bus ports.EventBus
81+ // CI is the CI engine: workflow trigger, planner, runner protocol and the
82+ // owner side. It starts with the server and stops with it.
83+ CI *usecase.CI
84+ API *rpc.API
85+ Bus ports.EventBus
7986 }
8087
8188 // newSigningSeed makes the seed of a new ed25519 signing key.
@@ -182,6 +189,21 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
182189 }
183190 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
184191 f.Browse = usecase.NewBrowse(store, reader)
192+
193+ // CI (ADR 0025): the engine reads workflows through the reader, and plans
194+ // them in the background; a push only queues its refs.
195+ ciCfg := usecase.CIConfig{
196+ Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
197+ Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
198+ Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
199+ }
200+ if opts.ci != nil {
201+ opts.ci(&ciCfg)
202+ }
203+ f.CI = usecase.NewCI(ctx, ciCfg)
204+ f.Pushes.WithWorkflowTrigger(f.CI)
205+ f.Repos.WithCIRunCleaner(f.CI)
206+ f.CI.Start()
185207 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
186208 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
187209 })
@@ -200,6 +222,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
200222 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
201223 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
202224 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
225+ CI: f.CI, Checks: f.CI,
203226 })
204227 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
205228
@@ -220,6 +243,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
220243 MergeRequests: f.MergeRequests,
221244 SigningKey: f.SigningKey,
222245 Sessions: f.Sessions,
246+ Runner: f.CI,
247+ Runs: f.CI,
223248 Version: serverVersion(),
224249 TrustedProxies: cfg.TrustedProxies,
225250 Limiter: limiter,
@@ -297,6 +322,9 @@ func (f *forge) Close(ctx context.Context) {
297322 if f.hooks != nil {
298323 f.hooks.Close(ctx)
299324 }
325+ if f.CI != nil {
326+ f.CI.Close(ctx)
327+ }
300328 if f.EventLog != nil {
301329 f.EventLog.Close(ctx)
302330 }
M internal/app/root.go +1 -1
@@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
2626 root.AddCommand(
2727 newServeCmd(), newHookCmd(),
2828 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env),
29+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newRunCmd(env), newRunnerCmd(env),
3030 )
3131 return root
3232 }
M internal/app/serve.go +28 -1
@@ -4,14 +4,17 @@ package app
44
55 import (
66 "context"
7+ "errors"
78 "os"
89 "os/signal"
10+ "strings"
911 "syscall"
1012
1113 "github.com/bnema/zerowrap"
1214 "github.com/spf13/cobra"
1315
1416 "bnema.dev/ermite/internal/config"
17+ "bnema.dev/ermite/internal/domain"
1518 )
1619
1720 func newServeCmd() *cobra.Command {
@@ -32,7 +35,7 @@ func newServeCmd() *cobra.Command {
3235
3336 // serve wires the adapters and runs the server until SIGINT or SIGTERM.
3437 func serve(ctx context.Context) error {
35- cfg, err := config.Load(os.Getenv)
38+ cfg, err := loadConfig(os.Getenv)
3639 if err != nil {
3740 return err
3841 }
@@ -67,3 +70,27 @@ func serve(ctx context.Context) error {
6770 log.Info().Msg("stopped")
6871 return nil
6972 }
73+
74+// loadConfig reads the configuration and adds the checks that need the domain,
75+// which config does not import: ERMITE_OWNER follows the rules of repository
76+// names. Its problem joins the others in one report.
77+func loadConfig(get func(string) string) (config.Config, error) {
78+ cfg, err := config.Load(get)
79+ var problems []string
80+ var ce *config.Error
81+ switch {
82+ case errors.As(err, &ce):
83+ problems = ce.Problems
84+ case err != nil:
85+ return config.Config{}, err
86+ }
87+ if owner := strings.TrimSpace(get(config.EnvOwner)); owner != "" {
88+ if _, perr := domain.ParseRepoName(owner); perr != nil {
89+ problems = append(problems, config.EnvOwner+": must follow the rules of repository names ("+perr.Error()+")")
90+ }
91+ }
92+ if len(problems) > 0 {
93+ return config.Config{}, &config.Error{Problems: problems}
94+ }
95+ return cfg, nil
96+}
A internal/app/serve_test.go +51 -0
@@ -0,0 +1,51 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ "bnema.dev/ermite/internal/config"
13+)
14+
15+func TestLoadConfigChecksTheOwnerName(t *testing.T) {
16+ t.Parallel()
17+ base := map[string]string{
18+ config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org",
19+ config.EnvAdminToken: "0123456789abcdef0123456789abcdef",
20+ }
21+ load := func(extra map[string]string) (config.Config, error) {
22+ env := map[string]string{}
23+ for k, v := range base {
24+ env[k] = v
25+ }
26+ for k, v := range extra {
27+ env[k] = v
28+ }
29+ return loadConfig(func(k string) string { return env[k] })
30+ }
31+
32+ cfg, err := load(map[string]string{config.EnvOwner: " bnema "})
33+ require.NoError(t, err)
34+ assert.Equal(t, "bnema", cfg.Owner)
35+ cfg, err = load(nil)
36+ require.NoError(t, err)
37+ assert.Equal(t, config.DefaultOwner, cfg.Owner)
38+
39+ for _, owner := range []string{"Bad Name", ".dot", "a/b", "x.git"} {
40+ _, err := load(map[string]string{config.EnvOwner: owner})
41+ var ce *config.Error
42+ require.True(t, errors.As(err, &ce), owner)
43+ assert.Contains(t, ce.Problems[0], config.EnvOwner, owner)
44+ }
45+
46+ // The problem is reported with the others, in one error.
47+ _, err = load(map[string]string{config.EnvOwner: "Bad", config.EnvCILogRetention: "soon"})
48+ var ce *config.Error
49+ require.True(t, errors.As(err, &ce))
50+ assert.Len(t, ce.Problems, 2)
51+}
M internal/app/testdata/web/blob-binary.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -308,6 +314,12 @@
308314 <span class="k">section</span>
309315 </a>
310316 </li>
317+<li data-k="section actions">
318+<a href="/files/actions" tabindex="-1">
319+<span class="lbl">actions</span>
320+<span class="k">section</span>
321+</a>
322+</li>
311323 <li data-k="branch feature/x">
312324 <a href="/files/tree/feature/x" tabindex="-1">
313325 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-code.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -378,6 +384,12 @@
378384 <span class="k">section</span>
379385 </a>
380386 </li>
387+<li data-k="section actions">
388+<a href="/files/actions" tabindex="-1">
389+<span class="lbl">actions</span>
390+<span class="k">section</span>
391+</a>
392+</li>
381393 <li data-k="branch feature/x">
382394 <a href="/files/tree/feature/x" tabindex="-1">
383395 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-empty.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -308,6 +314,12 @@
308314 <span class="k">section</span>
309315 </a>
310316 </li>
317+<li data-k="section actions">
318+<a href="/files/actions" tabindex="-1">
319+<span class="lbl">actions</span>
320+<span class="k">section</span>
321+</a>
322+</li>
311323 <li data-k="branch feature/x">
312324 <a href="/files/tree/feature/x" tabindex="-1">
313325 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-image.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -310,6 +316,12 @@
310316 <span class="k">section</span>
311317 </a>
312318 </li>
319+<li data-k="section actions">
320+<a href="/files/actions" tabindex="-1">
321+<span class="lbl">actions</span>
322+<span class="k">section</span>
323+</a>
324+</li>
313325 <li data-k="branch feature/x">
314326 <a href="/files/tree/feature/x" tabindex="-1">
315327 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-markdown.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -86,6 +88,10 @@
8688 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8789 </a>
8890 </li>
91+<li>
92+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
93+</a>
94+</li>
8995 </ul>
9096 </nav>
9197 </header>
@@ -375,6 +381,12 @@
375381 <span class="k">section</span>
376382 </a>
377383 </li>
384+<li data-k="section actions">
385+<a href="/files/actions" tabindex="-1">
386+<span class="lbl">actions</span>
387+<span class="k">section</span>
388+</a>
389+</li>
378390 <li data-k="branch feature/x">
379391 <a href="/files/tree/feature/x" tabindex="-1">
380392 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-svg.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -333,6 +339,12 @@
333339 <span class="k">section</span>
334340 </a>
335341 </li>
342+<li data-k="section actions">
343+<a href="/files/actions" tabindex="-1">
344+<span class="lbl">actions</span>
345+<span class="k">section</span>
346+</a>
347+</li>
336348 <li data-k="branch feature/x">
337349 <a href="/files/tree/feature/x" tabindex="-1">
338350 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-text.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -328,6 +334,12 @@
328334 <span class="k">section</span>
329335 </a>
330336 </li>
337+<li data-k="section actions">
338+<a href="/files/actions" tabindex="-1">
339+<span class="lbl">actions</span>
340+<span class="k">section</span>
341+</a>
342+</li>
331343 <li data-k="branch feature/x">
332344 <a href="/files/tree/feature/x" tabindex="-1">
333345 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-too-large.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -308,6 +314,12 @@
308314 <span class="k">section</span>
309315 </a>
310316 </li>
317+<li data-k="section actions">
318+<a href="/files/actions" tabindex="-1">
319+<span class="lbl">actions</span>
320+<span class="k">section</span>
321+</a>
322+</li>
311323 <li data-k="branch feature/x">
312324 <a href="/files/tree/feature/x" tabindex="-1">
313325 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-trojan.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -375,6 +381,12 @@
375381 <span class="k">section</span>
376382 </a>
377383 </li>
384+<li data-k="section actions">
385+<a href="/files/actions" tabindex="-1">
386+<span class="lbl">actions</span>
387+<span class="k">section</span>
388+</a>
389+</li>
378390 <li data-k="branch feature/x">
379391 <a href="/files/tree/feature/x" tabindex="-1">
380392 <span class="lbl">feature/x</span>
M internal/app/testdata/web/commit-big.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -345,6 +351,12 @@
345351 <span class="k">section</span>
346352 </a>
347353 </li>
354+<li data-k="section actions">
355+<a href="/diffs/actions" tabindex="-1">
356+<span class="lbl">actions</span>
357+<span class="k">section</span>
358+</a>
359+</li>
348360 <li data-k="branch main">
349361 <a href="/diffs/tree/main" tabindex="-1">
350362 <span class="lbl">main</span>
M internal/app/testdata/web/commit-binary.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -340,6 +346,12 @@
340346 <span class="k">section</span>
341347 </a>
342348 </li>
349+<li data-k="section actions">
350+<a href="/diffs/actions" tabindex="-1">
351+<span class="lbl">actions</span>
352+<span class="k">section</span>
353+</a>
354+</li>
343355 <li data-k="branch main">
344356 <a href="/diffs/tree/main" tabindex="-1">
345357 <span class="lbl">main</span>
M internal/app/testdata/web/commit-merge.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -379,6 +385,12 @@
379385 <span class="k">section</span>
380386 </a>
381387 </li>
388+<li data-k="section actions">
389+<a href="/diffs/actions" tabindex="-1">
390+<span class="lbl">actions</span>
391+<span class="k">section</span>
392+</a>
393+</li>
382394 <li data-k="branch main">
383395 <a href="/diffs/tree/main" tabindex="-1">
384396 <span class="lbl">main</span>
M internal/app/testdata/web/commit-modify-split.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -500,6 +506,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
500506 <span class="k">section</span>
501507 </a>
502508 </li>
509+<li data-k="section actions">
510+<a href="/diffs/actions" tabindex="-1">
511+<span class="lbl">actions</span>
512+<span class="k">section</span>
513+</a>
514+</li>
503515 <li data-k="branch main">
504516 <a href="/diffs/tree/main" tabindex="-1">
505517 <span class="lbl">main</span>
M internal/app/testdata/web/commit-modify.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -500,6 +506,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
500506 <span class="k">section</span>
501507 </a>
502508 </li>
509+<li data-k="section actions">
510+<a href="/diffs/actions" tabindex="-1">
511+<span class="lbl">actions</span>
512+<span class="k">section</span>
513+</a>
514+</li>
503515 <li data-k="branch main">
504516 <a href="/diffs/tree/main" tabindex="-1">
505517 <span class="lbl">main</span>
M internal/app/testdata/web/commit-rename.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -405,6 +411,12 @@
405411 <span class="k">section</span>
406412 </a>
407413 </li>
414+<li data-k="section actions">
415+<a href="/diffs/actions" tabindex="-1">
416+<span class="lbl">actions</span>
417+<span class="k">section</span>
418+</a>
419+</li>
408420 <li data-k="branch main">
409421 <a href="/diffs/tree/main" tabindex="-1">
410422 <span class="lbl">main</span>
M internal/app/testdata/web/commit-root.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -721,6 +727,12 @@
721727 <span class="k">section</span>
722728 </a>
723729 </li>
730+<li data-k="section actions">
731+<a href="/diffs/actions" tabindex="-1">
732+<span class="lbl">actions</span>
733+<span class="k">section</span>
734+</a>
735+</li>
724736 <li data-k="branch main">
725737 <a href="/diffs/tree/main" tabindex="-1">
726738 <span class="lbl">main</span>
M internal/app/testdata/web/commit-trojan.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -514,6 +520,12 @@
514520 <span class="k">section</span>
515521 </a>
516522 </li>
523+<li data-k="section actions">
524+<a href="/diffs/actions" tabindex="-1">
525+<span class="lbl">actions</span>
526+<span class="k">section</span>
527+</a>
528+</li>
517529 <li data-k="branch main">
518530 <a href="/diffs/tree/main" tabindex="-1">
519531 <span class="lbl">main</span>
M internal/app/testdata/web/commits-page2.html +16 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -103,6 +109,8 @@
103109 <td class="c-subject">
104110 <a href="/long/commit/SHA40">commit number 1</a>
105111 </td>
112+<td class="c-ck">
113+</td>
106114 <td class="c-who dim">T</td>
107115 <td class="c-when nowrap dim">
108116 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -115,6 +123,8 @@
115123 <td class="c-subject">
116124 <a href="/long/commit/SHA40">commit number 0</a>
117125 </td>
126+<td class="c-ck">
127+</td>
118128 <td class="c-who dim">T</td>
119129 <td class="c-when nowrap dim">
120130 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -327,6 +337,12 @@
327337 <span class="k">section</span>
328338 </a>
329339 </li>
340+<li data-k="section actions">
341+<a href="/long/actions" tabindex="-1">
342+<span class="lbl">actions</span>
343+<span class="k">section</span>
344+</a>
345+</li>
330346 <li data-k="branch main">
331347 <a href="/long/tree/main" tabindex="-1">
332348 <span class="lbl">main</span>
M internal/app/testdata/web/commits-path.html +16 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -107,6 +113,8 @@
107113 <td class="c-subject">
108114 <a href="/long/commit/SHA40">commit number 30</a>
109115 </td>
116+<td class="c-ck">
117+</td>
110118 <td class="c-who dim">T</td>
111119 <td class="c-when nowrap dim">
112120 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -128,6 +136,8 @@
128136 <td class="c-subject">
129137 <a href="/long/commit/SHA40">commit number 3</a>
130138 </td>
139+<td class="c-ck">
140+</td>
131141 <td class="c-who dim">T</td>
132142 <td class="c-when nowrap dim">
133143 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -342,6 +352,12 @@
342352 <span class="k">section</span>
343353 </a>
344354 </li>
355+<li data-k="section actions">
356+<a href="/long/actions" tabindex="-1">
357+<span class="lbl">actions</span>
358+<span class="k">section</span>
359+</a>
360+</li>
345361 <li data-k="branch main">
346362 <a href="/long/tree/main" tabindex="-1">
347363 <span class="lbl">main</span>
M internal/app/testdata/web/commits-root.html +113 -1
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -106,6 +112,8 @@
106112 <a href="/long/commit/SHA40">commit number 51</a>
107113 <span class="pill">main</span>
108114 </td>
115+<td class="c-ck">
116+</td>
109117 <td class="c-who dim">T</td>
110118 <td class="c-when nowrap dim">
111119 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -118,6 +126,8 @@
118126 <td class="c-subject">
119127 <a href="/long/commit/SHA40">commit number 50</a>
120128 </td>
129+<td class="c-ck">
130+</td>
121131 <td class="c-who dim">T</td>
122132 <td class="c-when nowrap dim">
123133 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -139,6 +149,8 @@
139149 <td class="c-subject">
140150 <a href="/long/commit/SHA40">commit number 49</a>
141151 </td>
152+<td class="c-ck">
153+</td>
142154 <td class="c-who dim">T</td>
143155 <td class="c-when nowrap dim">
144156 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -151,6 +163,8 @@
151163 <td class="c-subject">
152164 <a href="/long/commit/SHA40">commit number 48</a>
153165 </td>
166+<td class="c-ck">
167+</td>
154168 <td class="c-who dim">T</td>
155169 <td class="c-when nowrap dim">
156170 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -172,6 +186,8 @@
172186 <td class="c-subject">
173187 <a href="/long/commit/SHA40">commit number 47</a>
174188 </td>
189+<td class="c-ck">
190+</td>
175191 <td class="c-who dim">T</td>
176192 <td class="c-when nowrap dim">
177193 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -184,6 +200,8 @@
184200 <td class="c-subject">
185201 <a href="/long/commit/SHA40">commit number 46</a>
186202 </td>
203+<td class="c-ck">
204+</td>
187205 <td class="c-who dim">T</td>
188206 <td class="c-when nowrap dim">
189207 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -205,6 +223,8 @@
205223 <td class="c-subject">
206224 <a href="/long/commit/SHA40">commit number 45</a>
207225 </td>
226+<td class="c-ck">
227+</td>
208228 <td class="c-who dim">T</td>
209229 <td class="c-when nowrap dim">
210230 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -217,6 +237,8 @@
217237 <td class="c-subject">
218238 <a href="/long/commit/SHA40">commit number 44</a>
219239 </td>
240+<td class="c-ck">
241+</td>
220242 <td class="c-who dim">T</td>
221243 <td class="c-when nowrap dim">
222244 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -238,6 +260,8 @@
238260 <td class="c-subject">
239261 <a href="/long/commit/SHA40">commit number 43</a>
240262 </td>
263+<td class="c-ck">
264+</td>
241265 <td class="c-who dim">T</td>
242266 <td class="c-when nowrap dim">
243267 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -250,6 +274,8 @@
250274 <td class="c-subject">
251275 <a href="/long/commit/SHA40">commit number 42</a>
252276 </td>
277+<td class="c-ck">
278+</td>
253279 <td class="c-who dim">T</td>
254280 <td class="c-when nowrap dim">
255281 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -271,6 +297,8 @@
271297 <td class="c-subject">
272298 <a href="/long/commit/SHA40">commit number 41</a>
273299 </td>
300+<td class="c-ck">
301+</td>
274302 <td class="c-who dim">T</td>
275303 <td class="c-when nowrap dim">
276304 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -283,6 +311,8 @@
283311 <td class="c-subject">
284312 <a href="/long/commit/SHA40">commit number 40</a>
285313 </td>
314+<td class="c-ck">
315+</td>
286316 <td class="c-who dim">T</td>
287317 <td class="c-when nowrap dim">
288318 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -304,6 +334,8 @@
304334 <td class="c-subject">
305335 <a href="/long/commit/SHA40">commit number 39</a>
306336 </td>
337+<td class="c-ck">
338+</td>
307339 <td class="c-who dim">T</td>
308340 <td class="c-when nowrap dim">
309341 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -316,6 +348,8 @@
316348 <td class="c-subject">
317349 <a href="/long/commit/SHA40">commit number 38</a>
318350 </td>
351+<td class="c-ck">
352+</td>
319353 <td class="c-who dim">T</td>
320354 <td class="c-when nowrap dim">
321355 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -337,6 +371,8 @@
337371 <td class="c-subject">
338372 <a href="/long/commit/SHA40">commit number 37</a>
339373 </td>
374+<td class="c-ck">
375+</td>
340376 <td class="c-who dim">T</td>
341377 <td class="c-when nowrap dim">
342378 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -349,6 +385,8 @@
349385 <td class="c-subject">
350386 <a href="/long/commit/SHA40">commit number 36</a>
351387 </td>
388+<td class="c-ck">
389+</td>
352390 <td class="c-who dim">T</td>
353391 <td class="c-when nowrap dim">
354392 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -370,6 +408,8 @@
370408 <td class="c-subject">
371409 <a href="/long/commit/SHA40">commit number 35</a>
372410 </td>
411+<td class="c-ck">
412+</td>
373413 <td class="c-who dim">T</td>
374414 <td class="c-when nowrap dim">
375415 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -382,6 +422,8 @@
382422 <td class="c-subject">
383423 <a href="/long/commit/SHA40">commit number 34</a>
384424 </td>
425+<td class="c-ck">
426+</td>
385427 <td class="c-who dim">T</td>
386428 <td class="c-when nowrap dim">
387429 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -403,6 +445,8 @@
403445 <td class="c-subject">
404446 <a href="/long/commit/SHA40">commit number 33</a>
405447 </td>
448+<td class="c-ck">
449+</td>
406450 <td class="c-who dim">T</td>
407451 <td class="c-when nowrap dim">
408452 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -415,6 +459,8 @@
415459 <td class="c-subject">
416460 <a href="/long/commit/SHA40">commit number 32</a>
417461 </td>
462+<td class="c-ck">
463+</td>
418464 <td class="c-who dim">T</td>
419465 <td class="c-when nowrap dim">
420466 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -436,6 +482,8 @@
436482 <td class="c-subject">
437483 <a href="/long/commit/SHA40">commit number 31</a>
438484 </td>
485+<td class="c-ck">
486+</td>
439487 <td class="c-who dim">T</td>
440488 <td class="c-when nowrap dim">
441489 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -448,6 +496,8 @@
448496 <td class="c-subject">
449497 <a href="/long/commit/SHA40">commit number 30</a>
450498 </td>
499+<td class="c-ck">
500+</td>
451501 <td class="c-who dim">T</td>
452502 <td class="c-when nowrap dim">
453503 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -469,6 +519,8 @@
469519 <td class="c-subject">
470520 <a href="/long/commit/SHA40">commit number 29</a>
471521 </td>
522+<td class="c-ck">
523+</td>
472524 <td class="c-who dim">T</td>
473525 <td class="c-when nowrap dim">
474526 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -481,6 +533,8 @@
481533 <td class="c-subject">
482534 <a href="/long/commit/SHA40">commit number 28</a>
483535 </td>
536+<td class="c-ck">
537+</td>
484538 <td class="c-who dim">T</td>
485539 <td class="c-when nowrap dim">
486540 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -502,6 +556,8 @@
502556 <td class="c-subject">
503557 <a href="/long/commit/SHA40">commit number 27</a>
504558 </td>
559+<td class="c-ck">
560+</td>
505561 <td class="c-who dim">T</td>
506562 <td class="c-when nowrap dim">
507563 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -514,6 +570,8 @@
514570 <td class="c-subject">
515571 <a href="/long/commit/SHA40">commit number 26</a>
516572 </td>
573+<td class="c-ck">
574+</td>
517575 <td class="c-who dim">T</td>
518576 <td class="c-when nowrap dim">
519577 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -535,6 +593,8 @@
535593 <td class="c-subject">
536594 <a href="/long/commit/SHA40">commit number 25</a>
537595 </td>
596+<td class="c-ck">
597+</td>
538598 <td class="c-who dim">T</td>
539599 <td class="c-when nowrap dim">
540600 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -547,6 +607,8 @@
547607 <td class="c-subject">
548608 <a href="/long/commit/SHA40">commit number 24</a>
549609 </td>
610+<td class="c-ck">
611+</td>
550612 <td class="c-who dim">T</td>
551613 <td class="c-when nowrap dim">
552614 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -568,6 +630,8 @@
568630 <td class="c-subject">
569631 <a href="/long/commit/SHA40">commit number 23</a>
570632 </td>
633+<td class="c-ck">
634+</td>
571635 <td class="c-who dim">T</td>
572636 <td class="c-when nowrap dim">
573637 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -580,6 +644,8 @@
580644 <td class="c-subject">
581645 <a href="/long/commit/SHA40">commit number 22</a>
582646 </td>
647+<td class="c-ck">
648+</td>
583649 <td class="c-who dim">T</td>
584650 <td class="c-when nowrap dim">
585651 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -601,6 +667,8 @@
601667 <td class="c-subject">
602668 <a href="/long/commit/SHA40">commit number 21</a>
603669 </td>
670+<td class="c-ck">
671+</td>
604672 <td class="c-who dim">T</td>
605673 <td class="c-when nowrap dim">
606674 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -613,6 +681,8 @@
613681 <td class="c-subject">
614682 <a href="/long/commit/SHA40">commit number 20</a>
615683 </td>
684+<td class="c-ck">
685+</td>
616686 <td class="c-who dim">T</td>
617687 <td class="c-when nowrap dim">
618688 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -634,6 +704,8 @@
634704 <td class="c-subject">
635705 <a href="/long/commit/SHA40">commit number 19</a>
636706 </td>
707+<td class="c-ck">
708+</td>
637709 <td class="c-who dim">T</td>
638710 <td class="c-when nowrap dim">
639711 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -646,6 +718,8 @@
646718 <td class="c-subject">
647719 <a href="/long/commit/SHA40">commit number 18</a>
648720 </td>
721+<td class="c-ck">
722+</td>
649723 <td class="c-who dim">T</td>
650724 <td class="c-when nowrap dim">
651725 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -667,6 +741,8 @@
667741 <td class="c-subject">
668742 <a href="/long/commit/SHA40">commit number 17</a>
669743 </td>
744+<td class="c-ck">
745+</td>
670746 <td class="c-who dim">T</td>
671747 <td class="c-when nowrap dim">
672748 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -679,6 +755,8 @@
679755 <td class="c-subject">
680756 <a href="/long/commit/SHA40">commit number 16</a>
681757 </td>
758+<td class="c-ck">
759+</td>
682760 <td class="c-who dim">T</td>
683761 <td class="c-when nowrap dim">
684762 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -700,6 +778,8 @@
700778 <td class="c-subject">
701779 <a href="/long/commit/SHA40">commit number 15</a>
702780 </td>
781+<td class="c-ck">
782+</td>
703783 <td class="c-who dim">T</td>
704784 <td class="c-when nowrap dim">
705785 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -712,6 +792,8 @@
712792 <td class="c-subject">
713793 <a href="/long/commit/SHA40">commit number 14</a>
714794 </td>
795+<td class="c-ck">
796+</td>
715797 <td class="c-who dim">T</td>
716798 <td class="c-when nowrap dim">
717799 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -733,6 +815,8 @@
733815 <td class="c-subject">
734816 <a href="/long/commit/SHA40">commit number 13</a>
735817 </td>
818+<td class="c-ck">
819+</td>
736820 <td class="c-who dim">T</td>
737821 <td class="c-when nowrap dim">
738822 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -745,6 +829,8 @@
745829 <td class="c-subject">
746830 <a href="/long/commit/SHA40">commit number 12</a>
747831 </td>
832+<td class="c-ck">
833+</td>
748834 <td class="c-who dim">T</td>
749835 <td class="c-when nowrap dim">
750836 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -766,6 +852,8 @@
766852 <td class="c-subject">
767853 <a href="/long/commit/SHA40">commit number 11</a>
768854 </td>
855+<td class="c-ck">
856+</td>
769857 <td class="c-who dim">T</td>
770858 <td class="c-when nowrap dim">
771859 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -778,6 +866,8 @@
778866 <td class="c-subject">
779867 <a href="/long/commit/SHA40">commit number 10</a>
780868 </td>
869+<td class="c-ck">
870+</td>
781871 <td class="c-who dim">T</td>
782872 <td class="c-when nowrap dim">
783873 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -799,6 +889,8 @@
799889 <td class="c-subject">
800890 <a href="/long/commit/SHA40">commit number 9</a>
801891 </td>
892+<td class="c-ck">
893+</td>
802894 <td class="c-who dim">T</td>
803895 <td class="c-when nowrap dim">
804896 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -811,6 +903,8 @@
811903 <td class="c-subject">
812904 <a href="/long/commit/SHA40">commit number 8</a>
813905 </td>
906+<td class="c-ck">
907+</td>
814908 <td class="c-who dim">T</td>
815909 <td class="c-when nowrap dim">
816910 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -832,6 +926,8 @@
832926 <td class="c-subject">
833927 <a href="/long/commit/SHA40">commit number 7</a>
834928 </td>
929+<td class="c-ck">
930+</td>
835931 <td class="c-who dim">T</td>
836932 <td class="c-when nowrap dim">
837933 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -844,6 +940,8 @@
844940 <td class="c-subject">
845941 <a href="/long/commit/SHA40">commit number 6</a>
846942 </td>
943+<td class="c-ck">
944+</td>
847945 <td class="c-who dim">T</td>
848946 <td class="c-when nowrap dim">
849947 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -865,6 +963,8 @@
865963 <td class="c-subject">
866964 <a href="/long/commit/SHA40">commit number 5</a>
867965 </td>
966+<td class="c-ck">
967+</td>
868968 <td class="c-who dim">T</td>
869969 <td class="c-when nowrap dim">
870970 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -877,6 +977,8 @@
877977 <td class="c-subject">
878978 <a href="/long/commit/SHA40">commit number 4</a>
879979 </td>
980+<td class="c-ck">
981+</td>
880982 <td class="c-who dim">T</td>
881983 <td class="c-when nowrap dim">
882984 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -898,6 +1000,8 @@
8981000 <td class="c-subject">
8991001 <a href="/long/commit/SHA40">commit number 3</a>
9001002 </td>
1003+<td class="c-ck">
1004+</td>
9011005 <td class="c-who dim">T</td>
9021006 <td class="c-when nowrap dim">
9031007 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -910,6 +1014,8 @@
9101014 <td class="c-subject">
9111015 <a href="/long/commit/SHA40">commit number 2</a>
9121016 </td>
1017+<td class="c-ck">
1018+</td>
9131019 <td class="c-who dim">T</td>
9141020 <td class="c-when nowrap dim">
9151021 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -959,7 +1065,7 @@
9591065 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
9601066 htmx:sse:error -> ermiteLive.error(event.detail.status);
9611067 htmx:sse:before:connection -> ermiteLive.gate(event);
962- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
1068+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
9631069 gap -> ermiteLive.reload()">
9641070 </span>
9651071 </div>
@@ -1131,6 +1237,12 @@
11311237 <span class="k">section</span>
11321238 </a>
11331239 </li>
1240+<li data-k="section actions">
1241+<a href="/long/actions" tabindex="-1">
1242+<span class="lbl">actions</span>
1243+<span class="k">section</span>
1244+</a>
1245+</li>
11341246 <li data-k="branch main">
11351247 <a href="/long/tree/main" tabindex="-1">
11361248 <span class="lbl">main</span>
M internal/app/testdata/web/commits-small.html +29 -1
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -106,6 +112,8 @@
106112 <a href="/diffs/commit/SHA40">add a very large file</a>
107113 <span class="pill">main</span>
108114 </td>
115+<td class="c-ck">
116+</td>
109117 <td class="c-who dim">T</td>
110118 <td class="c-when nowrap dim">
111119 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -127,6 +135,8 @@
127135 <td class="c-subject">
128136 <a href="/diffs/commit/SHA40">add evil.go</a>
129137 </td>
138+<td class="c-ck">
139+</td>
130140 <td class="c-who dim">T</td>
131141 <td class="c-when nowrap dim">
132142 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -149,6 +159,8 @@
149159 <a href="/diffs/commit/SHA40">merge the side branch</a>
150160 <span class="pill">merge</span>
151161 </td>
162+<td class="c-ck">
163+</td>
152164 <td class="c-who dim">T</td>
153165 <td class="c-when nowrap dim">
154166 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -171,6 +183,8 @@
171183 <a href="/diffs/commit/SHA40">work on the side</a>
172184 <span class="pill">side</span>
173185 </td>
186+<td class="c-ck">
187+</td>
174188 <td class="c-who dim">T</td>
175189 <td class="c-when nowrap dim">
176190 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -192,6 +206,8 @@
192206 <td class="c-subject">
193207 <a href="/diffs/commit/SHA40">change a binary file</a>
194208 </td>
209+<td class="c-ck">
210+</td>
195211 <td class="c-who dim">T</td>
196212 <td class="c-when nowrap dim">
197213 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -213,6 +229,8 @@
213229 <td class="c-subject">
214230 <a href="/diffs/commit/SHA40">rename a file and delete another</a>
215231 </td>
232+<td class="c-ck">
233+</td>
216234 <td class="c-who dim">T</td>
217235 <td class="c-when nowrap dim">
218236 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -236,6 +254,8 @@
236254 <span class="pill tag">
237255 <span class="sr">tag </span>v1.0.0</span>
238256 </td>
257+<td class="c-ck">
258+</td>
239259 <td class="c-who dim">T</td>
240260 <td class="c-when nowrap dim">
241261 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -257,6 +277,8 @@
257277 <td class="c-subject">
258278 <a href="/diffs/commit/SHA40">root: the first files</a>
259279 </td>
280+<td class="c-ck">
281+</td>
260282 <td class="c-who dim">T</td>
261283 <td class="c-when nowrap dim">
262284 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -304,7 +326,7 @@
304326 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
305327 htmx:sse:error -> ermiteLive.error(event.detail.status);
306328 htmx:sse:before:connection -> ermiteLive.gate(event);
307- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
329+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
308330 gap -> ermiteLive.reload()">
309331 </span>
310332 </div>
@@ -484,6 +506,12 @@
484506 <span class="k">section</span>
485507 </a>
486508 </li>
509+<li data-k="section actions">
510+<a href="/diffs/actions" tabindex="-1">
511+<span class="lbl">actions</span>
512+<span class="k">section</span>
513+</a>
514+</li>
487515 <li data-k="branch main">
488516 <a href="/diffs/tree/main" tabindex="-1">
489517 <span class="lbl">main</span>
M internal/app/testdata/web/empty.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/home.html +3 -1
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
@@ -113,7 +115,7 @@
113115 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
114116 htmx:sse:error -> ermiteLive.error(event.detail.status);
115117 htmx:sse:before:connection -> ermiteLive.gate(event);
116- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
118+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
117119 gap -> ermiteLive.reload()">
118120 </span>
119121 </div>
M internal/app/testdata/web/not-found-page.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/not-found.html +2 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="" data-sha="" data-sel="" data-nav="">
2426 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/refs.html +13 -1
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -77,6 +79,10 @@
7779 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
7880 </a>
7981 </li>
82+<li>
83+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
84+</a>
85+</li>
8086 </ul>
8187 </nav>
8288 </header>
@@ -196,7 +202,7 @@
196202 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
197203 htmx:sse:error -> ermiteLive.error(event.detail.status);
198204 htmx:sse:before:connection -> ermiteLive.gate(event);
199- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
205+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
200206 gap -> ermiteLive.reload()">
201207 </span>
202208 </div>
@@ -380,6 +386,12 @@
380386 <span class="k">section</span>
381387 </a>
382388 </li>
389+<li data-k="section actions">
390+<a href="/demo/actions" tabindex="-1">
391+<span class="lbl">actions</span>
392+<span class="k">section</span>
393+</a>
394+</li>
383395 <li data-k="branch feature/x">
384396 <a href="/demo/tree/feature/x" tabindex="-1">
385397 <span class="lbl">feature/x</span>
M internal/app/testdata/web/summary.html +17 -1
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -74,6 +76,10 @@
7476 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
7577 </a>
7678 </li>
79+<li>
80+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
81+</a>
82+</li>
7783 </ul>
7884 </nav>
7985 </header>
@@ -166,6 +172,8 @@
166172 <span class="pill tag">
167173 <span class="sr">tag </span>v0.2.0</span>
168174 </td>
175+<td class="c-ck">
176+</td>
169177 <td class="c-who dim">T</td>
170178 <td class="c-when nowrap dim">
171179 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -180,6 +188,8 @@
180188 <span class="pill tag">
181189 <span class="sr">tag </span>v0.1.0</span>
182190 </td>
191+<td class="c-ck">
192+</td>
183193 <td class="c-who dim">T</td>
184194 <td class="c-when nowrap dim">
185195 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -215,7 +225,7 @@
215225 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
216226 htmx:sse:error -> ermiteLive.error(event.detail.status);
217227 htmx:sse:before:connection -> ermiteLive.gate(event);
218- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
228+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
219229 gap -> ermiteLive.reload()">
220230 </span>
221231 </div>
@@ -399,6 +409,12 @@
399409 <span class="k">section</span>
400410 </a>
401411 </li>
412+<li data-k="section actions">
413+<a href="/demo/actions" tabindex="-1">
414+<span class="lbl">actions</span>
415+<span class="k">section</span>
416+</a>
417+</li>
402418 <li data-k="branch feature/x">
403419 <a href="/demo/tree/feature/x" tabindex="-1">
404420 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-nested.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -86,6 +88,10 @@
8688 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8789 </a>
8890 </li>
91+<li>
92+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
93+</a>
94+</li>
8995 </ul>
9096 </nav>
9197 </header>
@@ -328,6 +334,12 @@
328334 <span class="k">section</span>
329335 </a>
330336 </li>
337+<li data-k="section actions">
338+<a href="/files/actions" tabindex="-1">
339+<span class="lbl">actions</span>
340+<span class="k">section</span>
341+</a>
342+</li>
331343 <li data-k="branch feature/x">
332344 <a href="/files/tree/feature/x" tabindex="-1">
333345 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-root.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -80,6 +82,10 @@
8082 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8183 </a>
8284 </li>
85+<li>
86+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
87+</a>
88+</li>
8389 </ul>
8490 </nav>
8591 </header>
@@ -555,6 +561,12 @@
555561 <span class="k">section</span>
556562 </a>
557563 </li>
564+<li data-k="section actions">
565+<a href="/files/actions" tabindex="-1">
566+<span class="lbl">actions</span>
567+<span class="k">section</span>
568+</a>
569+</li>
558570 <li data-k="branch feature/x">
559571 <a href="/files/tree/feature/x" tabindex="-1">
560572 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-slash-ref.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -325,6 +331,12 @@
325331 <span class="k">section</span>
326332 </a>
327333 </li>
334+<li data-k="section actions">
335+<a href="/files/actions" tabindex="-1">
336+<span class="lbl">actions</span>
337+<span class="k">section</span>
338+</a>
339+</li>
328340 <li data-k="branch feature/x">
329341 <a href="/files/tree/feature/x" tabindex="-1">
330342 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-subdir.html +12 -0
@@ -19,6 +19,8 @@
1919 </script>
2020 <script src="/-/static/js/files.HASH.js" defer>
2121 </script>
22+<script src="/-/static/js/log.HASH.js" defer>
23+</script>
2224 </head>
2325 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2426 <a class="skip" href="#main">skip to content</a>
@@ -83,6 +85,10 @@
8385 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8486 </a>
8587 </li>
88+<li>
89+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
90+</a>
91+</li>
8692 </ul>
8793 </nav>
8894 </header>
@@ -350,6 +356,12 @@
350356 <span class="k">section</span>
351357 </a>
352358 </li>
359+<li data-k="section actions">
360+<a href="/files/actions" tabindex="-1">
361+<span class="lbl">actions</span>
362+<span class="k">section</span>
363+</a>
364+</li>
353365 <li data-k="branch feature/x">
354366 <a href="/files/tree/feature/x" tabindex="-1">
355367 <span class="lbl">feature/x</span>
M internal/app/web.go +1 -0
@@ -33,6 +33,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
3333 Repos: f.Repos,
3434 Browse: f.Browse,
3535 MergeRequests: f.MergeRequests,
36+ Runs: f.CI,
3637 Events: f.EventLog,
3738 PublicURL: cfg.PublicURL,
3839 SourceURL: cfg.SourceURL,
M internal/config/config.go +51 -0
@@ -14,6 +14,7 @@ import (
1414 "path/filepath"
1515 "strconv"
1616 "strings"
17+ "time"
1718 "unicode/utf8"
1819 )
1920
@@ -33,6 +34,11 @@ const (
3334 // commits ermite creates (squash merges, ADR 0022).
3435 EnvCommitterName = "ERMITE_COMMITTER_NAME"
3536 EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL"
37+ // EnvOwner is the owner login CI shows in the github context
38+ // ("<owner>/<repo>"); it follows the rules of repository names.
39+ EnvOwner = "ERMITE_OWNER"
40+ // EnvCILogRetention is how long CI runs, jobs, checks and logs are kept.
41+ EnvCILogRetention = "ERMITE_CI_LOG_RETENTION"
3642 )
3743
3844 // Defaults.
@@ -44,6 +50,8 @@ const (
4450 DefaultLogFormat = "json"
4551 // DefaultCommitterName is the committer name of the commits ermite creates.
4652 DefaultCommitterName = "ermite"
53+ // DefaultOwner is the owner login when ERMITE_OWNER is not set.
54+ DefaultOwner = "owner"
4755 )
4856
4957 // Values of ERMITE_GIT_READER.
@@ -85,6 +93,11 @@ type Config struct {
8593 // commits ermite creates. The email defaults to "ermite@<host of
8694 // PublicURL>".
8795 CommitterName, CommitterEmail string
96+ // Owner is the owner login of the github context of CI runs.
97+ Owner string
98+ // CIRetention is how long CI data is kept; 0 (variable unset) leaves the
99+ // default to the CI use case.
100+ CIRetention time.Duration
88101 }
89102
90103 // Error lists every configuration problem found, in variable order.
@@ -218,12 +231,50 @@ func Load(get func(string) string) (Config, error) {
218231 c.CommitterEmail = "ermite@" + u.Hostname()
219232 }
220233
234+ // The name rules of the owner are those of repository names (the domain);
235+ // config imports nothing, so internal/app checks them (see loadConfig).
236+ c.Owner = strings.TrimSpace(get(EnvOwner))
237+ if c.Owner == "" {
238+ c.Owner = DefaultOwner
239+ }
240+
241+ if raw := strings.TrimSpace(get(EnvCILogRetention)); raw != "" {
242+ d, err := parseRetention(raw)
243+ if err != nil {
244+ bad(EnvCILogRetention, "%v", err)
245+ } else {
246+ c.CIRetention = d
247+ }
248+ }
249+
221250 if len(problems) > 0 {
222251 return Config{}, &Error{Problems: problems}
223252 }
224253 return c, nil
225254 }
226255
256+// parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"),
257+// at least one hour.
258+func parseRetention(raw string) (time.Duration, error) {
259+ var d time.Duration
260+ if days, ok := strings.CutSuffix(raw, "d"); ok {
261+ n, err := strconv.Atoi(days)
262+ if err != nil || n < 1 || n > 36500 {
263+ return 0, fmt.Errorf("must be a number of days such as 90d (got %q)", raw)
264+ }
265+ d = time.Duration(n) * 24 * time.Hour
266+ } else {
267+ var err error
268+ if d, err = time.ParseDuration(raw); err != nil {
269+ return 0, fmt.Errorf("must be a duration such as 90d or 720h (got %q)", raw)
270+ }
271+ }
272+ if d < time.Hour {
273+ return 0, fmt.Errorf("must be at least one hour (got %q)", raw)
274+ }
275+ return d, nil
276+}
277+
227278 // validateIdentity accepts a name or an email address for a Git identity: valid
228279 // UTF-8 without control characters or the angle brackets and line breaks that
229280 // Git does not allow in an identity.
M internal/config/config_test.go +45 -0
@@ -8,6 +8,7 @@ import (
88 "net/netip"
99 "strings"
1010 "testing"
11+ "time"
1112
1213 "github.com/stretchr/testify/assert"
1314 "github.com/stretchr/testify/require"
@@ -208,3 +209,47 @@ func TestLoadGitReader(t *testing.T) {
208209 assert.Equal(t, want, c.GitReader, in)
209210 }
210211 }
212+
213+func TestOwnerAndCIRetention(t *testing.T) {
214+ base := map[string]string{
215+ config.EnvDataDir: "/data", config.EnvPublicURL: "https://forge.example.org",
216+ config.EnvAdminToken: "0123456789abcdef0123456789abcdef",
217+ }
218+ load := func(extra map[string]string) (config.Config, error) {
219+ env := map[string]string{}
220+ for k, v := range base {
221+ env[k] = v
222+ }
223+ for k, v := range extra {
224+ env[k] = v
225+ }
226+ return config.Load(func(k string) string { return env[k] })
227+ }
228+
229+ c, err := load(nil)
230+ if err != nil {
231+ t.Fatal(err)
232+ }
233+ if c.Owner != "owner" || c.CIRetention != 0 {
234+ t.Fatalf("defaults (retention is left to the use case): owner %q, retention %v", c.Owner, c.CIRetention)
235+ }
236+
237+ c, err = load(map[string]string{config.EnvOwner: "bnema", config.EnvCILogRetention: "30d"})
238+ if err != nil {
239+ t.Fatal(err)
240+ }
241+ if c.Owner != "bnema" || c.CIRetention != 30*24*time.Hour {
242+ t.Fatalf("set: owner %q, retention %v", c.Owner, c.CIRetention)
243+ }
244+ if c, err = load(map[string]string{config.EnvCILogRetention: "48h"}); err != nil || c.CIRetention != 48*time.Hour {
245+ t.Fatalf("go duration: %v, %v", c.CIRetention, err)
246+ }
247+
248+ for _, extra := range []map[string]string{
249+ {config.EnvCILogRetention: "soon"}, {config.EnvCILogRetention: "0d"}, {config.EnvCILogRetention: "10m"},
250+ } {
251+ if _, err := load(extra); err == nil {
252+ t.Errorf("%v: want an error", extra)
253+ }
254+ }
255+}
A internal/domain/ci.go +589 -0
@@ -0,0 +1,589 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package domain
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "regexp"
9+ "slices"
10+ "strconv"
11+ "strings"
12+ "time"
13+)
14+
15+// CIStatus is where a run, a job or a check is in its life (GitHub's status).
16+type CIStatus string
17+
18+// Statuses.
19+const (
20+ CIQueued CIStatus = "queued"
21+ CIInProgress CIStatus = "in_progress"
22+ CICompleted CIStatus = "completed"
23+)
24+
25+// CIConclusion is how a run, a job, a step or a check ended, with GitHub's
26+// values. It is empty until the status is completed.
27+type CIConclusion string
28+
29+// Conclusions.
30+const (
31+ CINoConclusion CIConclusion = ""
32+ CISuccess CIConclusion = "success"
33+ CIFailure CIConclusion = "failure"
34+ CICancelled CIConclusion = "cancelled"
35+ CISkipped CIConclusion = "skipped"
36+ CITimedOut CIConclusion = "timed_out"
37+ CINeutral CIConclusion = "neutral"
38+ CIActionRequired CIConclusion = "action_required"
39+)
40+
41+// PassesMerge reports whether a check that ended this way lets a merge go
42+// through under FailingChecksBlock: success, skipped and neutral do.
43+func (c CIConclusion) PassesMerge() bool {
44+ return c == CISuccess || c == CISkipped || c == CINeutral
45+}
46+
47+// IsFailure reports whether the conclusion is a failure or a timeout.
48+func (c CIConclusion) IsFailure() bool { return c == CIFailure || c == CITimedOut }
49+
50+// CIStepState is where a step is in its life.
51+type CIStepState string
52+
53+// Step states.
54+const (
55+ CIStepPending CIStepState = "pending"
56+ CIStepInProgress CIStepState = "in_progress"
57+ CIStepCompleted CIStepState = "completed"
58+)
59+
60+// CIJobPhase is where a job is for the server, finer than the status owners
61+// see.
62+type CIJobPhase string
63+
64+// Job phases.
65+const (
66+ // CIPhaseWaiting: waits for the jobs it needs or for a max-parallel slot.
67+ CIPhaseWaiting CIJobPhase = "waiting"
68+ // CIPhaseReady: planned, waits for a runner.
69+ CIPhaseReady CIJobPhase = "ready"
70+ // CIPhaseAssigned: given to a runner that has not acknowledged it.
71+ CIPhaseAssigned CIJobPhase = "assigned"
72+ // CIPhaseRunning: acknowledged by its runner.
73+ CIPhaseRunning CIJobPhase = "running"
74+ // CIPhaseEnded: over.
75+ CIPhaseEnded CIJobPhase = "ended"
76+)
77+
78+// CIIsolation is how a runner isolates jobs.
79+type CIIsolation string
80+
81+// Isolations.
82+const (
83+ CIIsolationNone CIIsolation = ""
84+ CIIsolationVM CIIsolation = "vm"
85+ CIIsolationContainerSysbox CIIsolation = "container-sysbox"
86+ CIIsolationContainerPrivileged CIIsolation = "container-privileged"
87+)
88+
89+// CICancelReason says why a job is asked to stop.
90+type CICancelReason string
91+
92+// Cancel reasons.
93+const (
94+ CICancelNone CICancelReason = ""
95+ CICancelUser CICancelReason = "user"
96+ CICancelFailFast CICancelReason = "fail-fast"
97+ CICancelTimeout CICancelReason = "timeout"
98+)
99+
100+// Limits and durations of the CI protocol (runner.proto).
101+const (
102+ // CIMaxLogBytes is the size of a job log, as stored (512 MiB).
103+ CIMaxLogBytes = 512 << 20
104+ // CIMaxOutputBytes bounds one output and all outputs together (1 MiB).
105+ CIMaxOutputBytes = 1 << 20
106+ // CIMaxLogLines and CIMaxLogChunkBytes bound one AppendLog request.
107+ CIMaxLogLines = 1000
108+ CIMaxLogChunkBytes = 64 << 10
109+ // CIMaxSteps bounds the steps of a job.
110+ CIMaxSteps = 1000
111+ // CIMaxStepName bounds a step name, in characters.
112+ CIMaxStepName = 1024
113+ // CIMaxLabels and CIMaxLabelLen bound the labels of a runner.
114+ CIMaxLabels = 64
115+ CIMaxLabelLen = 64
116+ // CIDefaultTimeoutMinutes is the timeout of a job without timeout-minutes.
117+ CIDefaultTimeoutMinutes = 360
118+ // CIMaxAssignCancels is how many times in a row an assignment may be
119+ // cancelled before the job fails.
120+ CIMaxAssignCancels = 3
121+ // CIRetentionDays is the retention of runs, jobs, checks and logs.
122+ CIRetentionDays = 90
123+)
124+
125+// Durations of the CI protocol.
126+const (
127+ // CIAckTimeout is how long a runner has to send its first heartbeat.
128+ CIAckTimeout = 30 * time.Second
129+ // CIHeartbeatDeadline is how long a running job may go without heartbeat.
130+ CIHeartbeatDeadline = 2 * time.Minute
131+ // CICancelGrace is how long a runner has to conclude after a cancel request.
132+ CICancelGrace = 60 * time.Second
133+ // CITimeoutSlack is added to timeout-minutes by the server.
134+ CITimeoutSlack = 60 * time.Second
135+ // CIQueueTimeout is how long a job waits for a runner.
136+ CIQueueTimeout = 24 * time.Hour
137+ // CITokenGrace is how long a job token is recognised after the job ended.
138+ CITokenGrace = 24 * time.Hour
139+)
140+
141+// Messages of jobs ended by the server, GitHub's where it has them.
142+const (
143+ // CIQueueTimeoutMessage is GitHub's message for a job that no runner took.
144+ CIQueueTimeoutMessage = "The job was not acquired by Runner of type self-hosted even after multiple attempts"
145+ // CIShutdownMessage is GitHub's message for a runner that stops.
146+ CIShutdownMessage = "The runner has received a shutdown signal. This can happen when the runner service is stopped, or a manually started runner is canceled."
147+ // CILogLimitMessage ends a log that reached its limit.
148+ CILogLimitMessage = "The log of this job reached the limit of 512 MiB; the job is stopped."
149+)
150+
151+// CILostMessage is GitHub's message for a runner that stopped sending heartbeats.
152+func CILostMessage(runner string) string {
153+ return "The self-hosted runner: " + runner + " lost communication with the server. Verify the machine is running and has a healthy network connection. Anything in your workflow that terminates the runner process, starves it for CPU/Memory, or blocks its network access can cause this error."
154+}
155+
156+// CIRun is one execution of one workflow file for one event.
157+type CIRun struct {
158+ Repo RepoName
159+ ID int64
160+ Number int64
161+ Attempt int32
162+ WorkflowPath string
163+ WorkflowName string
164+ Title string
165+ Event string
166+ Ref string
167+ Sha string
168+ Actor string
169+ Status CIStatus
170+ Conclusion CIConclusion
171+ Error string
172+ CreatedAt time.Time
173+ StartedAt time.Time
174+ CompletedAt time.Time
175+ CancelRequested bool
176+ CancelRequestedAt time.Time
177+ // Version is the version of the stored record, which grows at every
178+ // change and never goes back.
179+ Version uint64
180+}
181+
182+// CIRunSource is what a run was planned from: the workflow file at the sha of
183+// the run, and the "github" context without the parts that depend on the job
184+// and the attempt.
185+type CIRunSource struct {
186+ RunID int64
187+ Repo RepoName
188+ WorkflowYAML string
189+ GithubJSON string
190+}
191+
192+// CIStep is a step of a job with where its lines are in the job log.
193+type CIStep struct {
194+ Number int32
195+ Name string
196+ State CIStepState
197+ Conclusion CIConclusion
198+ StartedAt time.Time
199+ CompletedAt time.Time
200+ LogStart int64
201+ // HasLines is set once a line of the step reached the log; LogStart is
202+ // then the number of its first line. LogEnd is not stored: see StepRanges.
203+ HasLines bool
204+}
205+
206+// CIJob is one job instance (one matrix combination) in one attempt.
207+type CIJob struct {
208+ ID string
209+ RunID int64
210+ // Attempt is the attempt in which the job executed, RunAttempt the attempt
211+ // of the run the record belongs to: they differ for a job kept by a re-run
212+ // of the failed jobs.
213+ Attempt int32
214+ RunAttempt int32
215+ Repo RepoName
216+ Key string
217+ Name string
218+ Phase CIJobPhase
219+ Conclusion CIConclusion
220+ Error string
221+ Labels []string
222+ Needs []string
223+ RunnerID string
224+ RunnerName string
225+ Isolation CIIsolation
226+ Steps []CIStep
227+
228+ CreatedAt time.Time
229+ StartedAt time.Time
230+ CompletedAt time.Time
231+
232+ // Index is the position of the job in the workflow file, Combo the
233+ // position of the instance in its matrix.
234+ Index int
235+ Combo int
236+ // Planned is set once runs-on, the name and the contexts are evaluated.
237+ // A job that is not planned has not been expanded yet.
238+ Planned bool
239+ MatrixJSON string
240+ // ContextsJSON is the JSON object of contexts given to the runner, computed
241+ // when the job is planned.
242+ ContextsJSON string
243+ // ComboTotal is the number of instances of the job's matrix.
244+ ComboTotal int
245+ // TimeoutMinutes is the timeout of the job, at least 1.
246+ TimeoutMinutes int32
247+ FailFast bool
248+ // MaxParallel is the max-parallel of the strategy, 0 for no limit.
249+ MaxParallel int
250+ Outputs map[string]string
251+
252+ // TokenHash is the hash of the job token of the current assignment; zero
253+ // when there is none.
254+ TokenHash TokenHash
255+ TokenValidUntil time.Time
256+ ReadyAt time.Time
257+ AssignedAt time.Time
258+ AckedAt time.Time
259+ // AssignCancels counts the assignments cancelled in a row.
260+ AssignCancels int
261+
262+ CancelReason CICancelReason
263+ CancelMessage string
264+ CancelRequestedAt time.Time
265+
266+ Version uint64
267+}
268+
269+// Status is the status owners see: a job is queued until its runner
270+// acknowledged it.
271+func (j CIJob) Status() CIStatus {
272+ switch j.Phase {
273+ case CIPhaseRunning:
274+ return CIInProgress
275+ case CIPhaseEnded:
276+ return CICompleted
277+ }
278+ return CIQueued
279+}
280+
281+// Active reports whether the job is given to a runner and not over.
282+func (j CIJob) Active() bool { return j.Phase == CIPhaseAssigned || j.Phase == CIPhaseRunning }
283+
284+// Ended reports whether the job is over.
285+func (j CIJob) Ended() bool { return j.Phase == CIPhaseEnded }
286+
287+// HasToken reports whether a job token is set.
288+func (j CIJob) HasToken() bool { return j.TokenHash != TokenHash{} }
289+
290+// CICheck is the result of one job on one commit.
291+type CICheck struct {
292+ ID string
293+ Repo RepoName
294+ Sha string
295+ Name string
296+ Status CIStatus
297+ Conclusion CIConclusion
298+ RunID int64
299+ JobID string
300+ Attempt int32
301+ WorkflowName string
302+ CreatedAt time.Time
303+ StartedAt time.Time
304+ CompletedAt time.Time
305+ Version uint64
306+}
307+
308+// CIRunner is a registered CI runner. TokenHash is never sent to clients.
309+type CIRunner struct {
310+ ID string
311+ Name string
312+ Labels []string
313+ Isolation CIIsolation
314+ Version string
315+ Arch string
316+ OS string
317+ RegisteredAt time.Time
318+ TokenHash TokenHash
319+}
320+
321+// RunnerTokenPrefix and JobTokenPrefix start the secrets of runners and jobs.
322+const (
323+ RunnerTokenPrefix = "ermiterun_"
324+ JobTokenPrefix = "ermitejob_"
325+ // CITokenRandomLen is the length of the base64url part of both.
326+ CITokenRandomLen = 43
327+)
328+
329+// IsRunnerToken and IsJobToken report whether s has the shape of the secret.
330+func IsRunnerToken(s string) bool { return hasTokenShape(s, RunnerTokenPrefix) }
331+func IsJobToken(s string) bool { return hasTokenShape(s, JobTokenPrefix) }
332+
333+func hasTokenShape(s, prefix string) bool {
334+ rest, ok := strings.CutPrefix(s, prefix)
335+ if !ok || len(rest) != CITokenRandomLen {
336+ return false
337+ }
338+ for _, r := range rest {
339+ switch {
340+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
341+ default:
342+ return false
343+ }
344+ }
345+ return true
346+}
347+
348+// NewJobID names a job instance: "<run>-<index>-<combo>". It is the same in
349+// every attempt of the run.
350+func NewJobID(runID int64, index, combo int) string {
351+ return fmt.Sprintf("%d-%d-%d", runID, index, combo)
352+}
353+
354+// ParseJobID returns the run of a job id, or ok false when s is not one.
355+func ParseJobID(s string) (runID int64, index, combo int, ok bool) {
356+ parts := strings.Split(s, "-")
357+ if len(parts) != 3 {
358+ return 0, 0, 0, false
359+ }
360+ r, err1 := strconv.ParseInt(parts[0], 10, 64)
361+ i, err2 := strconv.Atoi(parts[1])
362+ c, err3 := strconv.Atoi(parts[2])
363+ if err1 != nil || err2 != nil || err3 != nil || r < 1 || i < 0 || c < 0 || NewJobID(r, i, c) != s {
364+ return 0, 0, 0, false
365+ }
366+ return r, i, c, true
367+}
368+
369+var labelRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)
370+
371+// ParseRunnerName validates a runner name.
372+func ParseRunnerName(s string) error {
373+ if !labelRE.MatchString(s) {
374+ return fmt.Errorf("%w: a runner name has 1 to 64 letters, digits, '.', '_' or '-'", ErrInvalid)
375+ }
376+ return nil
377+}
378+
379+// ParseRunnerLabels validates labels and returns them lowercased, without
380+// duplicates, in order of first appearance.
381+func ParseRunnerLabels(labels []string) ([]string, error) {
382+ if len(labels) > CIMaxLabels {
383+ return nil, fmt.Errorf("%w: at most %d labels", ErrInvalid, CIMaxLabels)
384+ }
385+ var out []string
386+ for _, l := range labels {
387+ if !labelRE.MatchString(l) {
388+ return nil, fmt.Errorf("%w: label %q: 1 to %d letters, digits, '.', '_' or '-'", ErrInvalid, l, CIMaxLabelLen)
389+ }
390+ l = strings.ToLower(l)
391+ if !slices.Contains(out, l) {
392+ out = append(out, l)
393+ }
394+ }
395+ return out, nil
396+}
397+
398+// RunnerLabels returns the labels a runner matches: the ones it chose plus
399+// the implicit "self-hosted", its os and its arch, lowercase and sorted.
400+func RunnerLabels(chosen []string, os, arch string) []string {
401+ out := slices.Clone(chosen)
402+ for _, l := range []string{"self-hosted", strings.ToLower(os), strings.ToLower(arch)} {
403+ if !slices.Contains(out, l) {
404+ out = append(out, l)
405+ }
406+ }
407+ slices.Sort(out)
408+ return out
409+}
410+
411+// LabelsMatch reports whether every wanted label is among have, ignoring case.
412+func LabelsMatch(want, have []string) bool {
413+ for _, w := range want {
414+ if !slices.ContainsFunc(have, func(h string) bool { return strings.EqualFold(h, w) }) {
415+ return false
416+ }
417+ }
418+ return true
419+}
420+
421+var outputNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]{0,254}$`)
422+
423+// CheckOutputs validates the outputs of a job: names, case-insensitive
424+// uniqueness and the 1 MiB limits.
425+func CheckOutputs(outputs map[string]string) error {
426+ seen := make(map[string]bool, len(outputs))
427+ total := 0
428+ for k, v := range outputs {
429+ if !outputNameRE.MatchString(k) {
430+ return fmt.Errorf("%w: output name %q: 1 to 255 letters, digits, '_' or '-', starting with a letter or '_'", ErrInvalid, k)
431+ }
432+ lk := strings.ToLower(k)
433+ if seen[lk] {
434+ return fmt.Errorf("%w: output names differ only by case: %q", ErrInvalid, k)
435+ }
436+ seen[lk] = true
437+ if len(v) > CIMaxOutputBytes {
438+ return fmt.Errorf("%w: output %q is larger than 1 MiB", ErrInvalid, k)
439+ }
440+ total += len(k) + len(v)
441+ }
442+ if total > CIMaxOutputBytes {
443+ return fmt.Errorf("%w: the outputs are larger than 1 MiB together", ErrInvalid)
444+ }
445+ return nil
446+}
447+
448+// JobEndedError is returned for a call that needs a running job when the job
449+// has ended. It matches ErrPreconditionFailed; the API adds the JobEnded
450+// detail.
451+type JobEndedError struct {
452+ Conclusion CIConclusion
453+ Message string
454+}
455+
456+// Error implements error.
457+func (e *JobEndedError) Error() string {
458+ return fmt.Sprintf("%v: the job has ended (%s)", ErrPreconditionFailed, e.Conclusion)
459+}
460+
461+// Is makes errors.Is(err, ErrPreconditionFailed) true.
462+func (e *JobEndedError) Is(target error) bool { return target == ErrPreconditionFailed }
463+
464+// IsJobEnded returns the JobEndedError in err.
465+func IsJobEnded(err error) (*JobEndedError, bool) {
466+ var e *JobEndedError
467+ if errors.As(err, &e) {
468+ return e, true
469+ }
470+ return nil, false
471+}
472+
473+// ErrOutOfRange means an offset is beyond what the server holds.
474+var ErrOutOfRange = errors.New("out of range")
475+
476+// CILogLine is one line of a job log.
477+type CILogLine struct {
478+ Time time.Time
479+ // Text has no line feed.
480+ Text string
481+ // Continued marks a piece of a line that was too long to send whole.
482+ Continued bool
483+}
484+
485+// StepRange is where the lines of a step are in the job log.
486+type StepRange struct {
487+ Start, End int64
488+}
489+
490+// StepRanges returns the range of every step of the job for a log of lines
491+// lines: a step with lines ends where the next step with lines begins (the
492+// last ends at the end of the log); a step without lines is empty, placed
493+// where the previous step ends.
494+func StepRanges(steps []CIStep, lines int64) []StepRange {
495+ out := make([]StepRange, len(steps))
496+ var pos int64
497+ for i, s := range steps {
498+ if !s.HasLines {
499+ out[i] = StepRange{Start: pos, End: pos}
500+ continue
501+ }
502+ end := lines
503+ for _, n := range steps[i+1:] {
504+ if n.HasLines {
505+ end = n.LogStart
506+ break
507+ }
508+ }
509+ out[i] = StepRange{Start: s.LogStart, End: max(end, s.LogStart)}
510+ pos = out[i].End
511+ }
512+ return out
513+}
514+
515+// StepOfLine returns the number of the step a log line belongs to, 0 when no
516+// step has that line.
517+func StepOfLine(steps []CIStep, line int64) int32 {
518+ var found int32
519+ for _, s := range steps {
520+ if s.HasLines && s.LogStart <= line {
521+ found = s.Number
522+ }
523+ }
524+ return found
525+}
526+
527+// RunUpdated is published when a CI run is created or changes. It carries the
528+// visibility and the creation time of the repository, like PushEvent.
529+type RunUpdated struct {
530+ Run CIRun
531+ At time.Time
532+ Visibility Visibility
533+ CreatedAt time.Time
534+}
535+
536+// EventKind implements Event.
537+func (RunUpdated) EventKind() string { return "ci.run_updated" }
538+
539+// JobUpdated is published when a CI job is created or changes (status,
540+// conclusion, runner or steps; never per log line). Lines is the number of
541+// lines of the job log at that time, for the end of the step in progress.
542+type JobUpdated struct {
543+ Job CIJob
544+ Lines int64
545+ At time.Time
546+ Visibility Visibility
547+ CreatedAt time.Time
548+}
549+
550+// EventKind implements Event.
551+func (JobUpdated) EventKind() string { return "ci.job_updated" }
552+
553+// CheckState is the combined state of the checks of a commit.
554+type CheckState string
555+
556+// Combined check states.
557+const (
558+ // CheckStateNone: the commit has no check.
559+ CheckStateNone CheckState = ""
560+ // CheckStatePending: a check is queued or in progress, none has failed.
561+ CheckStatePending CheckState = "pending"
562+ // CheckStateFailure: a check failed (or needs an action), whatever the others.
563+ CheckStateFailure CheckState = "failure"
564+ // CheckStateSuccess: every check passed.
565+ CheckStateSuccess CheckState = "success"
566+)
567+
568+// CombineChecks reduces the checks of a commit to one state, the one the
569+// merge rule uses too: a failed check makes the commit failing even while
570+// others are pending; else a pending check makes it pending; else it is a
571+// success.
572+func CombineChecks(checks []CICheck) CheckState {
573+ if len(checks) == 0 {
574+ return CheckStateNone
575+ }
576+ pending := false
577+ for _, c := range checks {
578+ switch {
579+ case c.Status != CICompleted:
580+ pending = true
581+ case !c.Conclusion.PassesMerge():
582+ return CheckStateFailure
583+ }
584+ }
585+ if pending {
586+ return CheckStatePending
587+ }
588+ return CheckStateSuccess
589+}
A internal/ports/ci.go +382 -0
@@ -0,0 +1,382 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package ports
4+
5+import (
6+ "context"
7+ "errors"
8+ "strconv"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+)
13+
14+// CIRunDraft is what CreateRun stores: a run with its source, its jobs and
15+// their checks, built once the store has chosen the ids.
16+type CIRunDraft struct {
17+ Run domain.CIRun
18+ Source domain.CIRunSource
19+ Jobs []domain.CIJob
20+ Checks []domain.CICheck
21+}
22+
23+// CIWrite is a set of records written in one atomic batch. A run or a job with
24+// Version 0 must not exist yet; any other must be unchanged since it was read.
25+// Checks are derived from the jobs and upserted without a precondition.
26+// Apply returns domain.ErrConflict otherwise and domain.ErrNotFound when the
27+// repository no longer exists.
28+type CIWrite struct {
29+ Runs []domain.CIRun
30+ Jobs []domain.CIJob
31+ Checks []domain.CICheck
32+}
33+
34+// CIStore keeps CI runs, jobs and checks. The store derives its own indexes
35+// (the queue of ready jobs, the active jobs, the open runs, the job tokens)
36+// from the records it writes.
37+type CIStore interface {
38+ // CreateRun takes the next run id and the next number of the workflow in
39+ // the repository (both never reused), calls build with them and stores
40+ // the draft it returns, in one batch that fails with domain.ErrNotFound
41+ // once the repository is deleted. The records come back with their
42+ // versions.
43+ CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) CIRunDraft) (CIRunDraft, error)
44+ // Apply writes the records in one batch and returns the version they all
45+ // have afterwards.
46+ Apply(ctx context.Context, w CIWrite) (version uint64, err error)
47+ // GetRun returns the run, or domain.ErrNotFound.
48+ GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error)
49+ // GetRunSource returns what the run was planned from, or
50+ // domain.ErrNotFound. It is how a run is found from its id alone.
51+ GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error)
52+ // ListRuns returns the runs of the repository with an id below before (0
53+ // for no bound), newest first, at most limit.
54+ ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error)
55+ // RunIDs returns the ids of the runs of the repository.
56+ RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
57+ // ListOpenRuns returns the runs that have not completed.
58+ ListOpenRuns(ctx context.Context) ([]domain.CIRun, error)
59+ // GetJob returns a job of a run at an attempt of the run, or
60+ // domain.ErrNotFound.
61+ GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error)
62+ // ListJobs returns the jobs of a run at an attempt, ordered by index then
63+ // combination.
64+ ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error)
65+ // FindJobByToken returns the job whose current token has that hash and
66+ // whose token is still valid at now, or domain.ErrNotFound.
67+ FindJobByToken(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error)
68+ // ListReadyJobs returns the jobs that wait for a runner, oldest first, at
69+ // most limit.
70+ ListReadyJobs(ctx context.Context, limit int) ([]domain.CIJob, error)
71+ // ListActiveJobs returns the jobs given to a runner and not over.
72+ ListActiveJobs(ctx context.Context) ([]domain.CIJob, error)
73+ // ListChecks returns the checks of a commit, at most limit; more is true
74+ // when there are more.
75+ ListChecks(ctx context.Context, repo domain.RepoName, sha string, limit int) (checks []domain.CICheck, more bool, err error)
76+ // Sweep removes the runs completed before the time, with their jobs,
77+ // checks and sources (not their numbers), and returns their ids so that
78+ // the logs can go too.
79+ Sweep(ctx context.Context, before time.Time) ([]int64, error)
80+ // CreateRunner stores the runner and the index of its token. A name in use
81+ // is domain.ErrAlreadyExists, or with replace the old runner is removed in
82+ // the same batch and returned.
83+ CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) (removed []domain.CIRunner, err error)
84+ // GetRunner returns the runner, or domain.ErrNotFound.
85+ GetRunner(ctx context.Context, id string) (domain.CIRunner, error)
86+ // FindRunnerByToken returns the runner whose token has that hash, or
87+ // domain.ErrNotFound.
88+ FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error)
89+ // ListRunners returns every runner ordered by name.
90+ ListRunners(ctx context.Context) ([]domain.CIRunner, error)
91+ // RemoveRunner removes the runner and its token, or returns
92+ // domain.ErrNotFound.
93+ RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error)
94+}
95+
96+// CIRunCleaner is what repository deletion needs from CI: the runs of the
97+// repository before its records go, and the removal of their logs after.
98+// *usecase.CI implements it.
99+type CIRunCleaner interface {
100+ RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error)
101+ RemoveLogs(ctx context.Context, runIDs []int64) error
102+}
103+
104+// CIChecksReader is the read side of the checks, for the merge rule and the
105+// pages that show them. *usecase.CI implements it.
106+type CIChecksReader interface {
107+ // CommitChecks returns the checks of a commit, without access control (the
108+ // caller has checked it). More than 1,000 is domain.ErrResourceExhausted,
109+ // not a part of them: a rule must not decide on some of the checks.
110+ CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error)
111+}
112+
113+// CILogRef names the log of one execution of a job.
114+type CILogRef struct {
115+ RunID int64
116+ JobID string
117+ Attempt int32
118+}
119+
120+// ErrCILogLimit is returned by CILogStore.Append when the lines do not fit in
121+// the log limit; nothing was appended.
122+var ErrCILogLimit = errors.New("log limit reached")
123+
124+// ErrCILogGap wraps domain.ErrOutOfRange when an offset is above the count.
125+type ErrCILogGap struct{ Count int64 }
126+
127+// Error implements error.
128+func (e *ErrCILogGap) Error() string {
129+ return "out of range: the log holds lines up to the offset of " + strconv.FormatInt(e.Count, 10)
130+}
131+
132+// Is makes errors.Is(err, domain.ErrOutOfRange) true.
133+func (e *ErrCILogGap) Is(target error) bool { return target == domain.ErrOutOfRange }
134+
135+// CILogStore keeps the logs of the jobs: append-only files, one line per log
136+// line, never in the metadata store. Lines are numbered from 0.
137+type CILogStore interface {
138+ // Append appends the lines at offset (the number of the first line), with
139+ // the idempotence of AppendLog: lines already stored are skipped, a gap is
140+ // an *ErrCILogGap. It returns the number of lines after the call and how
141+ // many of the request were new. A log that would exceed limit bytes gets
142+ // nothing and ErrCILogLimit.
143+ Append(ctx context.Context, ref CILogRef, offset int64, lines []domain.CILogLine, limit int64) (next int64, added int, err error)
144+ // AppendForced appends lines whatever the limit (the last line of a log
145+ // that reached it).
146+ AppendForced(ctx context.Context, ref CILogRef, lines []domain.CILogLine) (next int64, err error)
147+ // Count returns the number of lines held, 0 for a log that does not exist.
148+ Count(ctx context.Context, ref CILogRef) (int64, error)
149+ // Read returns lines from the one numbered from, at most maxLines lines and
150+ // about maxBytes of text. It returns domain.ErrNotFound when the log does
151+ // not exist and domain.ErrOutOfRange when from is above the count.
152+ Read(ctx context.Context, ref CILogRef, from int64, maxLines, maxBytes int) ([]domain.CILogLine, error)
153+ // Changed returns a channel that is closed at the next append to the log.
154+ Changed(ref CILogRef) <-chan struct{}
155+ // Reset empties the log.
156+ Reset(ctx context.Context, ref CILogRef) error
157+ // RemoveRuns deletes the logs of the runs.
158+ RemoveRuns(ctx context.Context, runIDs []int64) error
159+ // RunIDs lists the runs that have a log directory, for the orphan sweep.
160+ RunIDs(ctx context.Context) ([]int64, error)
161+}
162+
163+// WorkflowTrigger starts the workflows that match pushed refs. OnPush only
164+// queues the work and returns: planning happens in the background and its
165+// errors never reach the push.
166+type WorkflowTrigger interface {
167+ OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal)
168+}
169+
170+// CIGit is what planning needs from Git beyond RepoReader and MergeGit.
171+type CIGit interface {
172+ // ChangedPaths returns the paths changed from base to head (a zero base
173+ // means the empty tree), in the order of the diff and without renames
174+ // detection, at most max; truncated is true when there were more.
175+ ChangedPaths(ctx context.Context, repo domain.RepoName, base, head domain.ObjectID, max int) (paths []string, truncated bool, err error)
176+}
177+
178+// --- inbound ---
179+
180+// RegisterRunnerParams describe a runner to register.
181+type RegisterRunnerParams struct {
182+ Name string
183+ Labels []string
184+ Version string
185+ Isolation domain.CIIsolation
186+ Arch, OS string
187+ Replace bool
188+}
189+
190+// RegisteredRunner is a new runner with its secret, shown once.
191+type RegisteredRunner struct {
192+ Runner domain.CIRunner
193+ Token string
194+}
195+
196+// RunnerJob is a job given to a runner.
197+type RunnerJob struct {
198+ Job domain.CIJob
199+ Token string
200+ Repo domain.RepoName
201+ Sha, Ref string
202+ WorkflowPath string
203+ WorkflowYAML string
204+}
205+
206+// FetchJobParams selects the job a runner asks for.
207+type FetchJobParams struct {
208+ Labels []string
209+ PollID string
210+}
211+
212+// FetchJobResult is the answer of a poll.
213+type FetchJobResult struct {
214+ Job *RunnerJob
215+ // RetryAfter is how long the runner waits before polling again.
216+ RetryAfter time.Duration
217+}
218+
219+// StepUpdate is the state of one step as a runner reports it.
220+type StepUpdate struct {
221+ Number int32
222+ Name string
223+ State domain.CIStepState
224+ Conclusion domain.CIConclusion
225+ StartedAt time.Time
226+ CompletedAt time.Time
227+}
228+
229+// CompleteJobParams end a job.
230+type CompleteJobParams struct {
231+ JobID string
232+ Conclusion domain.CIConclusion
233+ Outputs map[string]string
234+ RunnerShutdown bool
235+ Steps []StepUpdate
236+}
237+
238+// CompleteJobResult is the stored result of a job.
239+type CompleteJobResult struct {
240+ Conclusion domain.CIConclusion
241+ Error string
242+ Applied bool
243+}
244+
245+// JobSignal is one message of WatchJob.
246+type JobSignal struct {
247+ Keepalive bool
248+ // Cancel is set for a cancel request.
249+ Cancel *JobCancel
250+ // Ended is set when the job has ended.
251+ Ended *domain.JobEndedError
252+}
253+
254+// JobCancel is a cancel request for a running job.
255+type JobCancel struct {
256+ Reason domain.CICancelReason
257+ Message string
258+ RequestedAt time.Time
259+}
260+
261+// RunnerService is what runners call. The secret is the bearer secret of the
262+// call: the runner token for FetchJob, the job token for the other calls.
263+// A bad secret is domain.ErrUnauthenticated; a job token of another job than
264+// the one named is domain.ErrForbidden. *usecase.CI implements it.
265+type RunnerService interface {
266+ Register(ctx context.Context, p domain.Principal, params RegisterRunnerParams) (RegisteredRunner, error)
267+ FetchJob(ctx context.Context, secret string, params FetchJobParams) (FetchJobResult, error)
268+ UpdateJob(ctx context.Context, secret, jobID string, steps []StepUpdate) error
269+ AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (next int64, err error)
270+ Heartbeat(ctx context.Context, secret, jobID string) error
271+ // WatchJob calls ready once the job token is valid and the stream is
272+ // registered, before any message: the adapter flushes the response headers
273+ // then, because a client's call returns only when they arrive.
274+ WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(JobSignal) error) error
275+ CompleteJob(ctx context.Context, secret string, params CompleteJobParams) (CompleteJobResult, error)
276+}
277+
278+// JobView is a job as owners see it.
279+type JobView struct {
280+ domain.CIJob
281+ // Lines is the number of lines of its log now.
282+ Lines int64
283+ // RunnerGone is set when the runner of the job was removed since.
284+ RunnerGone bool
285+}
286+
287+// StepRanges returns where the lines of every step are in the log.
288+func (v JobView) StepRanges() []domain.StepRange { return domain.StepRanges(v.Steps, v.Lines) }
289+
290+// ListRunsParams select a page of runs.
291+type ListRunsParams struct {
292+ PageSize int
293+ PageToken string
294+ // Workflow restricts the result to a workflow file: its path or its file
295+ // name. Branch restricts it to the runs of a branch, by short name. Status
296+ // restricts it to the runs of that status. Empty means no restriction.
297+ Workflow string
298+ Branch string
299+ Status domain.CIStatus
300+}
301+
302+// RunPage is a page of runs, newest first.
303+type RunPage struct {
304+ Runs []domain.CIRun
305+ NextPageToken string
306+}
307+
308+// RunDetail is a run with its jobs.
309+type RunDetail struct {
310+ Run domain.CIRun
311+ Jobs []JobView
312+ LatestAttempt int32
313+}
314+
315+// LogBatch is a message of a log stream: consecutive lines from Offset.
316+type LogBatch struct {
317+ Offset int64
318+ Lines []JobLogLine
319+}
320+
321+// JobLogLine is a log line with its step.
322+type JobLogLine struct {
323+ Step int32
324+ domain.CILogLine
325+}
326+
327+// GetJobLogParams select the log to stream.
328+type GetJobLogParams struct {
329+ JobID string
330+ // Attempt is the attempt of the job, 0 for the latest.
331+ Attempt int32
332+ Offset int64
333+ Follow bool
334+ // Ready, when not nil, is called once the access is checked and the
335+ // stream is counted, before any batch: the adapter flushes the response
336+ // headers then (a follow on a job with no line yet would keep the client's
337+ // call waiting for the first one).
338+ Ready func()
339+ // Client identifies where the call comes from, for the limit of streams
340+ // per client; empty counts against the total only.
341+ Client string
342+}
343+
344+// CheckView is a check with what owners need to read it.
345+type CheckView struct {
346+ domain.CICheck
347+ // WaitingForRunner is set for a queued check whose job no registered
348+ // runner can take (no runner has all its labels).
349+ WaitingForRunner bool
350+}
351+
352+// ChecksResult are the checks of a commit.
353+type ChecksResult struct {
354+ Checks []CheckView
355+ Truncated bool
356+}
357+
358+// RunnerPage is a page of runners.
359+type RunnerPage struct {
360+ Runners []RunnerView
361+ NextPageToken string
362+}
363+
364+// RunnerView is a runner with what is known of it now.
365+type RunnerView struct {
366+ domain.CIRunner
367+ LastSeen time.Time
368+ Busy bool
369+}
370+
371+// RunService is the owner side of CI. *usecase.CI implements it.
372+type RunService interface {
373+ ListRuns(ctx context.Context, p domain.Principal, repo domain.RepoName, params ListRunsParams) (RunPage, error)
374+ GetRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32) (RunDetail, error)
375+ GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (JobView, error)
376+ GetJobLog(ctx context.Context, p domain.Principal, repo domain.RepoName, params GetJobLogParams, send func(LogBatch) error) error
377+ CancelRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error)
378+ RerunRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error)
379+ ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ChecksResult, error)
380+ ListRunners(ctx context.Context, p domain.Principal, size int, token string) (RunnerPage, error)
381+ RemoveRunner(ctx context.Context, p domain.Principal, id string) error
382+}
M internal/ports/dto.go +2 -2
@@ -137,8 +137,8 @@ type WatchEvent struct {
137137 // Event is nil then.
138138 Gap bool
139139 // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,
140- // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or
141- // CommentAdded.
140+ // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed,
141+ // CommentAdded, RunUpdated or JobUpdated.
142142 Event domain.Event
143143 // Repo is the state of the repository at delivery, for RepoCreated and
144144 // RepoUpdated.
A internal/ports/mocks/CIChecksReader.go +122 -0
@@ -0,0 +1,122 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCIChecksReader creates a new instance of CIChecksReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCIChecksReader(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CIChecksReader {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CIChecksReader{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CIChecksReader is an autogenerated mock type for the CIChecksReader type
38+type CIChecksReader struct {
39+ mock.Mock
40+}
41+
42+type CIChecksReader_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CIChecksReader) EXPECT() *CIChecksReader_Expecter {
47+ return &CIChecksReader_Expecter{mock: &_m.Mock}
48+}
49+
50+// CommitChecks provides a mock function for the type CIChecksReader
51+func (_mock *CIChecksReader) CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) {
52+ ret := _mock.Called(ctx, repo, sha)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for CommitChecks")
56+ }
57+
58+ var r0 []domain.CICheck
59+ var r1 error
60+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) ([]domain.CICheck, error)); ok {
61+ return returnFunc(ctx, repo, sha)
62+ }
63+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string) []domain.CICheck); ok {
64+ r0 = returnFunc(ctx, repo, sha)
65+ } else {
66+ if ret.Get(0) != nil {
67+ r0 = ret.Get(0).([]domain.CICheck)
68+ }
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string) error); ok {
71+ r1 = returnFunc(ctx, repo, sha)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// CIChecksReader_CommitChecks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CommitChecks'
79+type CIChecksReader_CommitChecks_Call struct {
80+ *mock.Call
81+}
82+
83+// CommitChecks is a helper method to define mock.On call
84+// - ctx context.Context
85+// - repo domain.RepoName
86+// - sha string
87+func (_e *CIChecksReader_Expecter) CommitChecks(ctx any, repo any, sha any) *CIChecksReader_CommitChecks_Call {
88+ return &CIChecksReader_CommitChecks_Call{Call: _e.mock.On("CommitChecks", ctx, repo, sha)}
89+}
90+
91+func (_c *CIChecksReader_CommitChecks_Call) Run(run func(ctx context.Context, repo domain.RepoName, sha string)) *CIChecksReader_CommitChecks_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.RepoName
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.RepoName)
100+ }
101+ var arg2 string
102+ if args[2] != nil {
103+ arg2 = args[2].(string)
104+ }
105+ run(
106+ arg0,
107+ arg1,
108+ arg2,
109+ )
110+ })
111+ return _c
112+}
113+
114+func (_c *CIChecksReader_CommitChecks_Call) Return(cIChecks []domain.CICheck, err error) *CIChecksReader_CommitChecks_Call {
115+ _c.Call.Return(cIChecks, err)
116+ return _c
117+}
118+
119+func (_c *CIChecksReader_CommitChecks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error)) *CIChecksReader_CommitChecks_Call {
120+ _c.Call.Return(run)
121+ return _c
122+}
A internal/ports/mocks/CIGit.go +140 -0
@@ -0,0 +1,140 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCIGit creates a new instance of CIGit. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCIGit(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CIGit {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CIGit{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CIGit is an autogenerated mock type for the CIGit type
38+type CIGit struct {
39+ mock.Mock
40+}
41+
42+type CIGit_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CIGit) EXPECT() *CIGit_Expecter {
47+ return &CIGit_Expecter{mock: &_m.Mock}
48+}
49+
50+// ChangedPaths provides a mock function for the type CIGit
51+func (_mock *CIGit) ChangedPaths(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID, max int) ([]string, bool, error) {
52+ ret := _mock.Called(ctx, repo, base, head, max)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for ChangedPaths")
56+ }
57+
58+ var r0 []string
59+ var r1 bool
60+ var r2 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) ([]string, bool, error)); ok {
62+ return returnFunc(ctx, repo, base, head, max)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) []string); ok {
65+ r0 = returnFunc(ctx, repo, base, head, max)
66+ } else {
67+ if ret.Get(0) != nil {
68+ r0 = ret.Get(0).([]string)
69+ }
70+ }
71+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) bool); ok {
72+ r1 = returnFunc(ctx, repo, base, head, max)
73+ } else {
74+ r1 = ret.Get(1).(bool)
75+ }
76+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, domain.ObjectID, domain.ObjectID, int) error); ok {
77+ r2 = returnFunc(ctx, repo, base, head, max)
78+ } else {
79+ r2 = ret.Error(2)
80+ }
81+ return r0, r1, r2
82+}
83+
84+// CIGit_ChangedPaths_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ChangedPaths'
85+type CIGit_ChangedPaths_Call struct {
86+ *mock.Call
87+}
88+
89+// ChangedPaths is a helper method to define mock.On call
90+// - ctx context.Context
91+// - repo domain.RepoName
92+// - base domain.ObjectID
93+// - head domain.ObjectID
94+// - max int
95+func (_e *CIGit_Expecter) ChangedPaths(ctx any, repo any, base any, head any, max any) *CIGit_ChangedPaths_Call {
96+ return &CIGit_ChangedPaths_Call{Call: _e.mock.On("ChangedPaths", ctx, repo, base, head, max)}
97+}
98+
99+func (_c *CIGit_ChangedPaths_Call) Run(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID, max int)) *CIGit_ChangedPaths_Call {
100+ _c.Call.Run(func(args mock.Arguments) {
101+ var arg0 context.Context
102+ if args[0] != nil {
103+ arg0 = args[0].(context.Context)
104+ }
105+ var arg1 domain.RepoName
106+ if args[1] != nil {
107+ arg1 = args[1].(domain.RepoName)
108+ }
109+ var arg2 domain.ObjectID
110+ if args[2] != nil {
111+ arg2 = args[2].(domain.ObjectID)
112+ }
113+ var arg3 domain.ObjectID
114+ if args[3] != nil {
115+ arg3 = args[3].(domain.ObjectID)
116+ }
117+ var arg4 int
118+ if args[4] != nil {
119+ arg4 = args[4].(int)
120+ }
121+ run(
122+ arg0,
123+ arg1,
124+ arg2,
125+ arg3,
126+ arg4,
127+ )
128+ })
129+ return _c
130+}
131+
132+func (_c *CIGit_ChangedPaths_Call) Return(paths []string, truncated bool, err error) *CIGit_ChangedPaths_Call {
133+ _c.Call.Return(paths, truncated, err)
134+ return _c
135+}
136+
137+func (_c *CIGit_ChangedPaths_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, base domain.ObjectID, head domain.ObjectID, max int) ([]string, bool, error)) *CIGit_ChangedPaths_Call {
138+ _c.Call.Return(run)
139+ return _c
140+}
A internal/ports/mocks/CILogStore.go +592 -0
@@ -0,0 +1,592 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewCILogStore creates a new instance of CILogStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewCILogStore(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *CILogStore {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &CILogStore{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// CILogStore is an autogenerated mock type for the CILogStore type
39+type CILogStore struct {
40+ mock.Mock
41+}
42+
43+type CILogStore_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *CILogStore) EXPECT() *CILogStore_Expecter {
48+ return &CILogStore_Expecter{mock: &_m.Mock}
49+}
50+
51+// Append provides a mock function for the type CILogStore
52+func (_mock *CILogStore) Append(ctx context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64) (int64, int, error) {
53+ ret := _mock.Called(ctx, ref, offset, lines, limit)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for Append")
57+ }
58+
59+ var r0 int64
60+ var r1 int
61+ var r2 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int64, []domain.CILogLine, int64) (int64, int, error)); ok {
63+ return returnFunc(ctx, ref, offset, lines, limit)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int64, []domain.CILogLine, int64) int64); ok {
66+ r0 = returnFunc(ctx, ref, offset, lines, limit)
67+ } else {
68+ r0 = ret.Get(0).(int64)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int64, []domain.CILogLine, int64) int); ok {
71+ r1 = returnFunc(ctx, ref, offset, lines, limit)
72+ } else {
73+ r1 = ret.Get(1).(int)
74+ }
75+ if returnFunc, ok := ret.Get(2).(func(context.Context, ports.CILogRef, int64, []domain.CILogLine, int64) error); ok {
76+ r2 = returnFunc(ctx, ref, offset, lines, limit)
77+ } else {
78+ r2 = ret.Error(2)
79+ }
80+ return r0, r1, r2
81+}
82+
83+// CILogStore_Append_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Append'
84+type CILogStore_Append_Call struct {
85+ *mock.Call
86+}
87+
88+// Append is a helper method to define mock.On call
89+// - ctx context.Context
90+// - ref ports.CILogRef
91+// - offset int64
92+// - lines []domain.CILogLine
93+// - limit int64
94+func (_e *CILogStore_Expecter) Append(ctx any, ref any, offset any, lines any, limit any) *CILogStore_Append_Call {
95+ return &CILogStore_Append_Call{Call: _e.mock.On("Append", ctx, ref, offset, lines, limit)}
96+}
97+
98+func (_c *CILogStore_Append_Call) Run(run func(ctx context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64)) *CILogStore_Append_Call {
99+ _c.Call.Run(func(args mock.Arguments) {
100+ var arg0 context.Context
101+ if args[0] != nil {
102+ arg0 = args[0].(context.Context)
103+ }
104+ var arg1 ports.CILogRef
105+ if args[1] != nil {
106+ arg1 = args[1].(ports.CILogRef)
107+ }
108+ var arg2 int64
109+ if args[2] != nil {
110+ arg2 = args[2].(int64)
111+ }
112+ var arg3 []domain.CILogLine
113+ if args[3] != nil {
114+ arg3 = args[3].([]domain.CILogLine)
115+ }
116+ var arg4 int64
117+ if args[4] != nil {
118+ arg4 = args[4].(int64)
119+ }
120+ run(
121+ arg0,
122+ arg1,
123+ arg2,
124+ arg3,
125+ arg4,
126+ )
127+ })
128+ return _c
129+}
130+
131+func (_c *CILogStore_Append_Call) Return(next int64, added int, err error) *CILogStore_Append_Call {
132+ _c.Call.Return(next, added, err)
133+ return _c
134+}
135+
136+func (_c *CILogStore_Append_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, offset int64, lines []domain.CILogLine, limit int64) (int64, int, error)) *CILogStore_Append_Call {
137+ _c.Call.Return(run)
138+ return _c
139+}
140+
141+// AppendForced provides a mock function for the type CILogStore
142+func (_mock *CILogStore) AppendForced(ctx context.Context, ref ports.CILogRef, lines []domain.CILogLine) (int64, error) {
143+ ret := _mock.Called(ctx, ref, lines)
144+
145+ if len(ret) == 0 {
146+ panic("no return value specified for AppendForced")
147+ }
148+
149+ var r0 int64
150+ var r1 error
151+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, []domain.CILogLine) (int64, error)); ok {
152+ return returnFunc(ctx, ref, lines)
153+ }
154+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, []domain.CILogLine) int64); ok {
155+ r0 = returnFunc(ctx, ref, lines)
156+ } else {
157+ r0 = ret.Get(0).(int64)
158+ }
159+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, []domain.CILogLine) error); ok {
160+ r1 = returnFunc(ctx, ref, lines)
161+ } else {
162+ r1 = ret.Error(1)
163+ }
164+ return r0, r1
165+}
166+
167+// CILogStore_AppendForced_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppendForced'
168+type CILogStore_AppendForced_Call struct {
169+ *mock.Call
170+}
171+
172+// AppendForced is a helper method to define mock.On call
173+// - ctx context.Context
174+// - ref ports.CILogRef
175+// - lines []domain.CILogLine
176+func (_e *CILogStore_Expecter) AppendForced(ctx any, ref any, lines any) *CILogStore_AppendForced_Call {
177+ return &CILogStore_AppendForced_Call{Call: _e.mock.On("AppendForced", ctx, ref, lines)}
178+}
179+
180+func (_c *CILogStore_AppendForced_Call) Run(run func(ctx context.Context, ref ports.CILogRef, lines []domain.CILogLine)) *CILogStore_AppendForced_Call {
181+ _c.Call.Run(func(args mock.Arguments) {
182+ var arg0 context.Context
183+ if args[0] != nil {
184+ arg0 = args[0].(context.Context)
185+ }
186+ var arg1 ports.CILogRef
187+ if args[1] != nil {
188+ arg1 = args[1].(ports.CILogRef)
189+ }
190+ var arg2 []domain.CILogLine
191+ if args[2] != nil {
192+ arg2 = args[2].([]domain.CILogLine)
193+ }
194+ run(
195+ arg0,
196+ arg1,
197+ arg2,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *CILogStore_AppendForced_Call) Return(next int64, err error) *CILogStore_AppendForced_Call {
204+ _c.Call.Return(next, err)
205+ return _c
206+}
207+
208+func (_c *CILogStore_AppendForced_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, lines []domain.CILogLine) (int64, error)) *CILogStore_AppendForced_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
213+// Changed provides a mock function for the type CILogStore
214+func (_mock *CILogStore) Changed(ref ports.CILogRef) <-chan struct{} {
215+ ret := _mock.Called(ref)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for Changed")
219+ }
220+
221+ var r0 <-chan struct{}
222+ if returnFunc, ok := ret.Get(0).(func(ports.CILogRef) <-chan struct{}); ok {
223+ r0 = returnFunc(ref)
224+ } else {
225+ if ret.Get(0) != nil {
226+ r0 = ret.Get(0).(<-chan struct{})
227+ }
228+ }
229+ return r0
230+}
231+
232+// CILogStore_Changed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Changed'
233+type CILogStore_Changed_Call struct {
234+ *mock.Call
235+}
236+
237+// Changed is a helper method to define mock.On call
238+// - ref ports.CILogRef
239+func (_e *CILogStore_Expecter) Changed(ref any) *CILogStore_Changed_Call {
240+ return &CILogStore_Changed_Call{Call: _e.mock.On("Changed", ref)}
241+}
242+
243+func (_c *CILogStore_Changed_Call) Run(run func(ref ports.CILogRef)) *CILogStore_Changed_Call {
244+ _c.Call.Run(func(args mock.Arguments) {
245+ var arg0 ports.CILogRef
246+ if args[0] != nil {
247+ arg0 = args[0].(ports.CILogRef)
248+ }
249+ run(
250+ arg0,
251+ )
252+ })
253+ return _c
254+}
255+
256+func (_c *CILogStore_Changed_Call) Return(valCh <-chan struct{}) *CILogStore_Changed_Call {
257+ _c.Call.Return(valCh)
258+ return _c
259+}
260+
261+func (_c *CILogStore_Changed_Call) RunAndReturn(run func(ref ports.CILogRef) <-chan struct{}) *CILogStore_Changed_Call {
262+ _c.Call.Return(run)
263+ return _c
264+}
265+
266+// Count provides a mock function for the type CILogStore
267+func (_mock *CILogStore) Count(ctx context.Context, ref ports.CILogRef) (int64, error) {
268+ ret := _mock.Called(ctx, ref)
269+
270+ if len(ret) == 0 {
271+ panic("no return value specified for Count")
272+ }
273+
274+ var r0 int64
275+ var r1 error
276+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef) (int64, error)); ok {
277+ return returnFunc(ctx, ref)
278+ }
279+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef) int64); ok {
280+ r0 = returnFunc(ctx, ref)
281+ } else {
282+ r0 = ret.Get(0).(int64)
283+ }
284+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef) error); ok {
285+ r1 = returnFunc(ctx, ref)
286+ } else {
287+ r1 = ret.Error(1)
288+ }
289+ return r0, r1
290+}
291+
292+// CILogStore_Count_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Count'
293+type CILogStore_Count_Call struct {
294+ *mock.Call
295+}
296+
297+// Count is a helper method to define mock.On call
298+// - ctx context.Context
299+// - ref ports.CILogRef
300+func (_e *CILogStore_Expecter) Count(ctx any, ref any) *CILogStore_Count_Call {
301+ return &CILogStore_Count_Call{Call: _e.mock.On("Count", ctx, ref)}
302+}
303+
304+func (_c *CILogStore_Count_Call) Run(run func(ctx context.Context, ref ports.CILogRef)) *CILogStore_Count_Call {
305+ _c.Call.Run(func(args mock.Arguments) {
306+ var arg0 context.Context
307+ if args[0] != nil {
308+ arg0 = args[0].(context.Context)
309+ }
310+ var arg1 ports.CILogRef
311+ if args[1] != nil {
312+ arg1 = args[1].(ports.CILogRef)
313+ }
314+ run(
315+ arg0,
316+ arg1,
317+ )
318+ })
319+ return _c
320+}
321+
322+func (_c *CILogStore_Count_Call) Return(n int64, err error) *CILogStore_Count_Call {
323+ _c.Call.Return(n, err)
324+ return _c
325+}
326+
327+func (_c *CILogStore_Count_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef) (int64, error)) *CILogStore_Count_Call {
328+ _c.Call.Return(run)
329+ return _c
330+}
331+
332+// Read provides a mock function for the type CILogStore
333+func (_mock *CILogStore) Read(ctx context.Context, ref ports.CILogRef, from int64, maxLines int, maxBytes int) ([]domain.CILogLine, error) {
334+ ret := _mock.Called(ctx, ref, from, maxLines, maxBytes)
335+
336+ if len(ret) == 0 {
337+ panic("no return value specified for Read")
338+ }
339+
340+ var r0 []domain.CILogLine
341+ var r1 error
342+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int64, int, int) ([]domain.CILogLine, error)); ok {
343+ return returnFunc(ctx, ref, from, maxLines, maxBytes)
344+ }
345+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef, int64, int, int) []domain.CILogLine); ok {
346+ r0 = returnFunc(ctx, ref, from, maxLines, maxBytes)
347+ } else {
348+ if ret.Get(0) != nil {
349+ r0 = ret.Get(0).([]domain.CILogLine)
350+ }
351+ }
352+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CILogRef, int64, int, int) error); ok {
353+ r1 = returnFunc(ctx, ref, from, maxLines, maxBytes)
354+ } else {
355+ r1 = ret.Error(1)
356+ }
357+ return r0, r1
358+}
359+
360+// CILogStore_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
361+type CILogStore_Read_Call struct {
362+ *mock.Call
363+}
364+
365+// Read is a helper method to define mock.On call
366+// - ctx context.Context
367+// - ref ports.CILogRef
368+// - from int64
369+// - maxLines int
370+// - maxBytes int
371+func (_e *CILogStore_Expecter) Read(ctx any, ref any, from any, maxLines any, maxBytes any) *CILogStore_Read_Call {
372+ return &CILogStore_Read_Call{Call: _e.mock.On("Read", ctx, ref, from, maxLines, maxBytes)}
373+}
374+
375+func (_c *CILogStore_Read_Call) Run(run func(ctx context.Context, ref ports.CILogRef, from int64, maxLines int, maxBytes int)) *CILogStore_Read_Call {
376+ _c.Call.Run(func(args mock.Arguments) {
377+ var arg0 context.Context
378+ if args[0] != nil {
379+ arg0 = args[0].(context.Context)
380+ }
381+ var arg1 ports.CILogRef
382+ if args[1] != nil {
383+ arg1 = args[1].(ports.CILogRef)
384+ }
385+ var arg2 int64
386+ if args[2] != nil {
387+ arg2 = args[2].(int64)
388+ }
389+ var arg3 int
390+ if args[3] != nil {
391+ arg3 = args[3].(int)
392+ }
393+ var arg4 int
394+ if args[4] != nil {
395+ arg4 = args[4].(int)
396+ }
397+ run(
398+ arg0,
399+ arg1,
400+ arg2,
401+ arg3,
402+ arg4,
403+ )
404+ })
405+ return _c
406+}
407+
408+func (_c *CILogStore_Read_Call) Return(cILogLines []domain.CILogLine, err error) *CILogStore_Read_Call {
409+ _c.Call.Return(cILogLines, err)
410+ return _c
411+}
412+
413+func (_c *CILogStore_Read_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef, from int64, maxLines int, maxBytes int) ([]domain.CILogLine, error)) *CILogStore_Read_Call {
414+ _c.Call.Return(run)
415+ return _c
416+}
417+
418+// RemoveRuns provides a mock function for the type CILogStore
419+func (_mock *CILogStore) RemoveRuns(ctx context.Context, runIDs []int64) error {
420+ ret := _mock.Called(ctx, runIDs)
421+
422+ if len(ret) == 0 {
423+ panic("no return value specified for RemoveRuns")
424+ }
425+
426+ var r0 error
427+ if returnFunc, ok := ret.Get(0).(func(context.Context, []int64) error); ok {
428+ r0 = returnFunc(ctx, runIDs)
429+ } else {
430+ r0 = ret.Error(0)
431+ }
432+ return r0
433+}
434+
435+// CILogStore_RemoveRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRuns'
436+type CILogStore_RemoveRuns_Call struct {
437+ *mock.Call
438+}
439+
440+// RemoveRuns is a helper method to define mock.On call
441+// - ctx context.Context
442+// - runIDs []int64
443+func (_e *CILogStore_Expecter) RemoveRuns(ctx any, runIDs any) *CILogStore_RemoveRuns_Call {
444+ return &CILogStore_RemoveRuns_Call{Call: _e.mock.On("RemoveRuns", ctx, runIDs)}
445+}
446+
447+func (_c *CILogStore_RemoveRuns_Call) Run(run func(ctx context.Context, runIDs []int64)) *CILogStore_RemoveRuns_Call {
448+ _c.Call.Run(func(args mock.Arguments) {
449+ var arg0 context.Context
450+ if args[0] != nil {
451+ arg0 = args[0].(context.Context)
452+ }
453+ var arg1 []int64
454+ if args[1] != nil {
455+ arg1 = args[1].([]int64)
456+ }
457+ run(
458+ arg0,
459+ arg1,
460+ )
461+ })
462+ return _c
463+}
464+
465+func (_c *CILogStore_RemoveRuns_Call) Return(err error) *CILogStore_RemoveRuns_Call {
466+ _c.Call.Return(err)
467+ return _c
468+}
469+
470+func (_c *CILogStore_RemoveRuns_Call) RunAndReturn(run func(ctx context.Context, runIDs []int64) error) *CILogStore_RemoveRuns_Call {
471+ _c.Call.Return(run)
472+ return _c
473+}
474+
475+// Reset provides a mock function for the type CILogStore
476+func (_mock *CILogStore) Reset(ctx context.Context, ref ports.CILogRef) error {
477+ ret := _mock.Called(ctx, ref)
478+
479+ if len(ret) == 0 {
480+ panic("no return value specified for Reset")
481+ }
482+
483+ var r0 error
484+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CILogRef) error); ok {
485+ r0 = returnFunc(ctx, ref)
486+ } else {
487+ r0 = ret.Error(0)
488+ }
489+ return r0
490+}
491+
492+// CILogStore_Reset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reset'
493+type CILogStore_Reset_Call struct {
494+ *mock.Call
495+}
496+
497+// Reset is a helper method to define mock.On call
498+// - ctx context.Context
499+// - ref ports.CILogRef
500+func (_e *CILogStore_Expecter) Reset(ctx any, ref any) *CILogStore_Reset_Call {
501+ return &CILogStore_Reset_Call{Call: _e.mock.On("Reset", ctx, ref)}
502+}
503+
504+func (_c *CILogStore_Reset_Call) Run(run func(ctx context.Context, ref ports.CILogRef)) *CILogStore_Reset_Call {
505+ _c.Call.Run(func(args mock.Arguments) {
506+ var arg0 context.Context
507+ if args[0] != nil {
508+ arg0 = args[0].(context.Context)
509+ }
510+ var arg1 ports.CILogRef
511+ if args[1] != nil {
512+ arg1 = args[1].(ports.CILogRef)
513+ }
514+ run(
515+ arg0,
516+ arg1,
517+ )
518+ })
519+ return _c
520+}
521+
522+func (_c *CILogStore_Reset_Call) Return(err error) *CILogStore_Reset_Call {
523+ _c.Call.Return(err)
524+ return _c
525+}
526+
527+func (_c *CILogStore_Reset_Call) RunAndReturn(run func(ctx context.Context, ref ports.CILogRef) error) *CILogStore_Reset_Call {
528+ _c.Call.Return(run)
529+ return _c
530+}
531+
532+// RunIDs provides a mock function for the type CILogStore
533+func (_mock *CILogStore) RunIDs(ctx context.Context) ([]int64, error) {
534+ ret := _mock.Called(ctx)
535+
536+ if len(ret) == 0 {
537+ panic("no return value specified for RunIDs")
538+ }
539+
540+ var r0 []int64
541+ var r1 error
542+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]int64, error)); ok {
543+ return returnFunc(ctx)
544+ }
545+ if returnFunc, ok := ret.Get(0).(func(context.Context) []int64); ok {
546+ r0 = returnFunc(ctx)
547+ } else {
548+ if ret.Get(0) != nil {
549+ r0 = ret.Get(0).([]int64)
550+ }
551+ }
552+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
553+ r1 = returnFunc(ctx)
554+ } else {
555+ r1 = ret.Error(1)
556+ }
557+ return r0, r1
558+}
559+
560+// CILogStore_RunIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunIDs'
561+type CILogStore_RunIDs_Call struct {
562+ *mock.Call
563+}
564+
565+// RunIDs is a helper method to define mock.On call
566+// - ctx context.Context
567+func (_e *CILogStore_Expecter) RunIDs(ctx any) *CILogStore_RunIDs_Call {
568+ return &CILogStore_RunIDs_Call{Call: _e.mock.On("RunIDs", ctx)}
569+}
570+
571+func (_c *CILogStore_RunIDs_Call) Run(run func(ctx context.Context)) *CILogStore_RunIDs_Call {
572+ _c.Call.Run(func(args mock.Arguments) {
573+ var arg0 context.Context
574+ if args[0] != nil {
575+ arg0 = args[0].(context.Context)
576+ }
577+ run(
578+ arg0,
579+ )
580+ })
581+ return _c
582+}
583+
584+func (_c *CILogStore_RunIDs_Call) Return(int64s []int64, err error) *CILogStore_RunIDs_Call {
585+ _c.Call.Return(int64s, err)
586+ return _c
587+}
588+
589+func (_c *CILogStore_RunIDs_Call) RunAndReturn(run func(ctx context.Context) ([]int64, error)) *CILogStore_RunIDs_Call {
590+ _c.Call.Return(run)
591+ return _c
592+}
A internal/ports/mocks/CIRunCleaner.go +173 -0
@@ -0,0 +1,173 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewCIRunCleaner creates a new instance of CIRunCleaner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewCIRunCleaner(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *CIRunCleaner {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &CIRunCleaner{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// CIRunCleaner is an autogenerated mock type for the CIRunCleaner type
38+type CIRunCleaner struct {
39+ mock.Mock
40+}
41+
42+type CIRunCleaner_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *CIRunCleaner) EXPECT() *CIRunCleaner_Expecter {
47+ return &CIRunCleaner_Expecter{mock: &_m.Mock}
48+}
49+
50+// RemoveLogs provides a mock function for the type CIRunCleaner
51+func (_mock *CIRunCleaner) RemoveLogs(ctx context.Context, runIDs []int64) error {
52+ ret := _mock.Called(ctx, runIDs)
53+
54+ if len(ret) == 0 {
55+ panic("no return value specified for RemoveLogs")
56+ }
57+
58+ var r0 error
59+ if returnFunc, ok := ret.Get(0).(func(context.Context, []int64) error); ok {
60+ r0 = returnFunc(ctx, runIDs)
61+ } else {
62+ r0 = ret.Error(0)
63+ }
64+ return r0
65+}
66+
67+// CIRunCleaner_RemoveLogs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveLogs'
68+type CIRunCleaner_RemoveLogs_Call struct {
69+ *mock.Call
70+}
71+
72+// RemoveLogs is a helper method to define mock.On call
73+// - ctx context.Context
74+// - runIDs []int64
75+func (_e *CIRunCleaner_Expecter) RemoveLogs(ctx any, runIDs any) *CIRunCleaner_RemoveLogs_Call {
76+ return &CIRunCleaner_RemoveLogs_Call{Call: _e.mock.On("RemoveLogs", ctx, runIDs)}
77+}
78+
79+func (_c *CIRunCleaner_RemoveLogs_Call) Run(run func(ctx context.Context, runIDs []int64)) *CIRunCleaner_RemoveLogs_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 []int64
86+ if args[1] != nil {
87+ arg1 = args[1].([]int64)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *CIRunCleaner_RemoveLogs_Call) Return(err error) *CIRunCleaner_RemoveLogs_Call {
98+ _c.Call.Return(err)
99+ return _c
100+}
101+
102+func (_c *CIRunCleaner_RemoveLogs_Call) RunAndReturn(run func(ctx context.Context, runIDs []int64) error) *CIRunCleaner_RemoveLogs_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
106+
107+// RunIDs provides a mock function for the type CIRunCleaner
108+func (_mock *CIRunCleaner) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) {
109+ ret := _mock.Called(ctx, repo)
110+
111+ if len(ret) == 0 {
112+ panic("no return value specified for RunIDs")
113+ }
114+
115+ var r0 []int64
116+ var r1 error
117+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]int64, error)); ok {
118+ return returnFunc(ctx, repo)
119+ }
120+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []int64); ok {
121+ r0 = returnFunc(ctx, repo)
122+ } else {
123+ if ret.Get(0) != nil {
124+ r0 = ret.Get(0).([]int64)
125+ }
126+ }
127+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
128+ r1 = returnFunc(ctx, repo)
129+ } else {
130+ r1 = ret.Error(1)
131+ }
132+ return r0, r1
133+}
134+
135+// CIRunCleaner_RunIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunIDs'
136+type CIRunCleaner_RunIDs_Call struct {
137+ *mock.Call
138+}
139+
140+// RunIDs is a helper method to define mock.On call
141+// - ctx context.Context
142+// - repo domain.RepoName
143+func (_e *CIRunCleaner_Expecter) RunIDs(ctx any, repo any) *CIRunCleaner_RunIDs_Call {
144+ return &CIRunCleaner_RunIDs_Call{Call: _e.mock.On("RunIDs", ctx, repo)}
145+}
146+
147+func (_c *CIRunCleaner_RunIDs_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *CIRunCleaner_RunIDs_Call {
148+ _c.Call.Run(func(args mock.Arguments) {
149+ var arg0 context.Context
150+ if args[0] != nil {
151+ arg0 = args[0].(context.Context)
152+ }
153+ var arg1 domain.RepoName
154+ if args[1] != nil {
155+ arg1 = args[1].(domain.RepoName)
156+ }
157+ run(
158+ arg0,
159+ arg1,
160+ )
161+ })
162+ return _c
163+}
164+
165+func (_c *CIRunCleaner_RunIDs_Call) Return(int64s []int64, err error) *CIRunCleaner_RunIDs_Call {
166+ _c.Call.Return(int64s, err)
167+ return _c
168+}
169+
170+func (_c *CIRunCleaner_RunIDs_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]int64, error)) *CIRunCleaner_RunIDs_Call {
171+ _c.Call.Return(run)
172+ return _c
173+}
A internal/ports/mocks/CIStore.go +1384 -0
@@ -0,0 +1,1384 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "time"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+ mock "github.com/stretchr/testify/mock"
14+)
15+
16+// NewCIStore creates a new instance of CIStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
17+// The first argument is typically a *testing.T value.
18+func NewCIStore(t interface {
19+ mock.TestingT
20+ Cleanup(func())
21+}) *CIStore {
22+ if helper, ok := t.(interface{ Helper() }); ok {
23+ helper.Helper()
24+ }
25+
26+ mock := &CIStore{}
27+ mock.Mock.Test(t)
28+
29+ t.Cleanup(func() {
30+ if helper, ok := t.(interface{ Helper() }); ok {
31+ helper.Helper()
32+ }
33+ mock.AssertExpectations(t)
34+ })
35+
36+ return mock
37+}
38+
39+// CIStore is an autogenerated mock type for the CIStore type
40+type CIStore struct {
41+ mock.Mock
42+}
43+
44+type CIStore_Expecter struct {
45+ mock *mock.Mock
46+}
47+
48+func (_m *CIStore) EXPECT() *CIStore_Expecter {
49+ return &CIStore_Expecter{mock: &_m.Mock}
50+}
51+
52+// Apply provides a mock function for the type CIStore
53+func (_mock *CIStore) Apply(ctx context.Context, w ports.CIWrite) (uint64, error) {
54+ ret := _mock.Called(ctx, w)
55+
56+ if len(ret) == 0 {
57+ panic("no return value specified for Apply")
58+ }
59+
60+ var r0 uint64
61+ var r1 error
62+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIWrite) (uint64, error)); ok {
63+ return returnFunc(ctx, w)
64+ }
65+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CIWrite) uint64); ok {
66+ r0 = returnFunc(ctx, w)
67+ } else {
68+ r0 = ret.Get(0).(uint64)
69+ }
70+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CIWrite) error); ok {
71+ r1 = returnFunc(ctx, w)
72+ } else {
73+ r1 = ret.Error(1)
74+ }
75+ return r0, r1
76+}
77+
78+// CIStore_Apply_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Apply'
79+type CIStore_Apply_Call struct {
80+ *mock.Call
81+}
82+
83+// Apply is a helper method to define mock.On call
84+// - ctx context.Context
85+// - w ports.CIWrite
86+func (_e *CIStore_Expecter) Apply(ctx any, w any) *CIStore_Apply_Call {
87+ return &CIStore_Apply_Call{Call: _e.mock.On("Apply", ctx, w)}
88+}
89+
90+func (_c *CIStore_Apply_Call) Run(run func(ctx context.Context, w ports.CIWrite)) *CIStore_Apply_Call {
91+ _c.Call.Run(func(args mock.Arguments) {
92+ var arg0 context.Context
93+ if args[0] != nil {
94+ arg0 = args[0].(context.Context)
95+ }
96+ var arg1 ports.CIWrite
97+ if args[1] != nil {
98+ arg1 = args[1].(ports.CIWrite)
99+ }
100+ run(
101+ arg0,
102+ arg1,
103+ )
104+ })
105+ return _c
106+}
107+
108+func (_c *CIStore_Apply_Call) Return(version uint64, err error) *CIStore_Apply_Call {
109+ _c.Call.Return(version, err)
110+ return _c
111+}
112+
113+func (_c *CIStore_Apply_Call) RunAndReturn(run func(ctx context.Context, w ports.CIWrite) (uint64, error)) *CIStore_Apply_Call {
114+ _c.Call.Return(run)
115+ return _c
116+}
117+
118+// CreateRun provides a mock function for the type CIStore
119+func (_mock *CIStore) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id int64, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
120+ ret := _mock.Called(ctx, repo, workflowPath, build)
121+
122+ if len(ret) == 0 {
123+ panic("no return value specified for CreateRun")
124+ }
125+
126+ var r0 ports.CIRunDraft
127+ var r1 error
128+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, func(id int64, number int64) ports.CIRunDraft) (ports.CIRunDraft, error)); ok {
129+ return returnFunc(ctx, repo, workflowPath, build)
130+ }
131+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, func(id int64, number int64) ports.CIRunDraft) ports.CIRunDraft); ok {
132+ r0 = returnFunc(ctx, repo, workflowPath, build)
133+ } else {
134+ r0 = ret.Get(0).(ports.CIRunDraft)
135+ }
136+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string, func(id int64, number int64) ports.CIRunDraft) error); ok {
137+ r1 = returnFunc(ctx, repo, workflowPath, build)
138+ } else {
139+ r1 = ret.Error(1)
140+ }
141+ return r0, r1
142+}
143+
144+// CIStore_CreateRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRun'
145+type CIStore_CreateRun_Call struct {
146+ *mock.Call
147+}
148+
149+// CreateRun is a helper method to define mock.On call
150+// - ctx context.Context
151+// - repo domain.RepoName
152+// - workflowPath string
153+// - build func(id int64, number int64) ports.CIRunDraft
154+func (_e *CIStore_Expecter) CreateRun(ctx any, repo any, workflowPath any, build any) *CIStore_CreateRun_Call {
155+ return &CIStore_CreateRun_Call{Call: _e.mock.On("CreateRun", ctx, repo, workflowPath, build)}
156+}
157+
158+func (_c *CIStore_CreateRun_Call) Run(run func(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id int64, number int64) ports.CIRunDraft)) *CIStore_CreateRun_Call {
159+ _c.Call.Run(func(args mock.Arguments) {
160+ var arg0 context.Context
161+ if args[0] != nil {
162+ arg0 = args[0].(context.Context)
163+ }
164+ var arg1 domain.RepoName
165+ if args[1] != nil {
166+ arg1 = args[1].(domain.RepoName)
167+ }
168+ var arg2 string
169+ if args[2] != nil {
170+ arg2 = args[2].(string)
171+ }
172+ var arg3 func(id int64, number int64) ports.CIRunDraft
173+ if args[3] != nil {
174+ arg3 = args[3].(func(id int64, number int64) ports.CIRunDraft)
175+ }
176+ run(
177+ arg0,
178+ arg1,
179+ arg2,
180+ arg3,
181+ )
182+ })
183+ return _c
184+}
185+
186+func (_c *CIStore_CreateRun_Call) Return(cIRunDraft ports.CIRunDraft, err error) *CIStore_CreateRun_Call {
187+ _c.Call.Return(cIRunDraft, err)
188+ return _c
189+}
190+
191+func (_c *CIStore_CreateRun_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id int64, number int64) ports.CIRunDraft) (ports.CIRunDraft, error)) *CIStore_CreateRun_Call {
192+ _c.Call.Return(run)
193+ return _c
194+}
195+
196+// CreateRunner provides a mock function for the type CIStore
197+func (_mock *CIStore) CreateRunner(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error) {
198+ ret := _mock.Called(ctx, r, replace)
199+
200+ if len(ret) == 0 {
201+ panic("no return value specified for CreateRunner")
202+ }
203+
204+ var r0 []domain.CIRunner
205+ var r1 error
206+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.CIRunner, bool) ([]domain.CIRunner, error)); ok {
207+ return returnFunc(ctx, r, replace)
208+ }
209+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.CIRunner, bool) []domain.CIRunner); ok {
210+ r0 = returnFunc(ctx, r, replace)
211+ } else {
212+ if ret.Get(0) != nil {
213+ r0 = ret.Get(0).([]domain.CIRunner)
214+ }
215+ }
216+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.CIRunner, bool) error); ok {
217+ r1 = returnFunc(ctx, r, replace)
218+ } else {
219+ r1 = ret.Error(1)
220+ }
221+ return r0, r1
222+}
223+
224+// CIStore_CreateRunner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateRunner'
225+type CIStore_CreateRunner_Call struct {
226+ *mock.Call
227+}
228+
229+// CreateRunner is a helper method to define mock.On call
230+// - ctx context.Context
231+// - r domain.CIRunner
232+// - replace bool
233+func (_e *CIStore_Expecter) CreateRunner(ctx any, r any, replace any) *CIStore_CreateRunner_Call {
234+ return &CIStore_CreateRunner_Call{Call: _e.mock.On("CreateRunner", ctx, r, replace)}
235+}
236+
237+func (_c *CIStore_CreateRunner_Call) Run(run func(ctx context.Context, r domain.CIRunner, replace bool)) *CIStore_CreateRunner_Call {
238+ _c.Call.Run(func(args mock.Arguments) {
239+ var arg0 context.Context
240+ if args[0] != nil {
241+ arg0 = args[0].(context.Context)
242+ }
243+ var arg1 domain.CIRunner
244+ if args[1] != nil {
245+ arg1 = args[1].(domain.CIRunner)
246+ }
247+ var arg2 bool
248+ if args[2] != nil {
249+ arg2 = args[2].(bool)
250+ }
251+ run(
252+ arg0,
253+ arg1,
254+ arg2,
255+ )
256+ })
257+ return _c
258+}
259+
260+func (_c *CIStore_CreateRunner_Call) Return(removed []domain.CIRunner, err error) *CIStore_CreateRunner_Call {
261+ _c.Call.Return(removed, err)
262+ return _c
263+}
264+
265+func (_c *CIStore_CreateRunner_Call) RunAndReturn(run func(ctx context.Context, r domain.CIRunner, replace bool) ([]domain.CIRunner, error)) *CIStore_CreateRunner_Call {
266+ _c.Call.Return(run)
267+ return _c
268+}
269+
270+// FindJobByToken provides a mock function for the type CIStore
271+func (_mock *CIStore) FindJobByToken(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error) {
272+ ret := _mock.Called(ctx, hash, now)
273+
274+ if len(ret) == 0 {
275+ panic("no return value specified for FindJobByToken")
276+ }
277+
278+ var r0 domain.CIJob
279+ var r1 error
280+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash, time.Time) (domain.CIJob, error)); ok {
281+ return returnFunc(ctx, hash, now)
282+ }
283+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash, time.Time) domain.CIJob); ok {
284+ r0 = returnFunc(ctx, hash, now)
285+ } else {
286+ r0 = ret.Get(0).(domain.CIJob)
287+ }
288+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.TokenHash, time.Time) error); ok {
289+ r1 = returnFunc(ctx, hash, now)
290+ } else {
291+ r1 = ret.Error(1)
292+ }
293+ return r0, r1
294+}
295+
296+// CIStore_FindJobByToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindJobByToken'
297+type CIStore_FindJobByToken_Call struct {
298+ *mock.Call
299+}
300+
301+// FindJobByToken is a helper method to define mock.On call
302+// - ctx context.Context
303+// - hash domain.TokenHash
304+// - now time.Time
305+func (_e *CIStore_Expecter) FindJobByToken(ctx any, hash any, now any) *CIStore_FindJobByToken_Call {
306+ return &CIStore_FindJobByToken_Call{Call: _e.mock.On("FindJobByToken", ctx, hash, now)}
307+}
308+
309+func (_c *CIStore_FindJobByToken_Call) Run(run func(ctx context.Context, hash domain.TokenHash, now time.Time)) *CIStore_FindJobByToken_Call {
310+ _c.Call.Run(func(args mock.Arguments) {
311+ var arg0 context.Context
312+ if args[0] != nil {
313+ arg0 = args[0].(context.Context)
314+ }
315+ var arg1 domain.TokenHash
316+ if args[1] != nil {
317+ arg1 = args[1].(domain.TokenHash)
318+ }
319+ var arg2 time.Time
320+ if args[2] != nil {
321+ arg2 = args[2].(time.Time)
322+ }
323+ run(
324+ arg0,
325+ arg1,
326+ arg2,
327+ )
328+ })
329+ return _c
330+}
331+
332+func (_c *CIStore_FindJobByToken_Call) Return(cIJob domain.CIJob, err error) *CIStore_FindJobByToken_Call {
333+ _c.Call.Return(cIJob, err)
334+ return _c
335+}
336+
337+func (_c *CIStore_FindJobByToken_Call) RunAndReturn(run func(ctx context.Context, hash domain.TokenHash, now time.Time) (domain.CIJob, error)) *CIStore_FindJobByToken_Call {
338+ _c.Call.Return(run)
339+ return _c
340+}
341+
342+// FindRunnerByToken provides a mock function for the type CIStore
343+func (_mock *CIStore) FindRunnerByToken(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error) {
344+ ret := _mock.Called(ctx, hash)
345+
346+ if len(ret) == 0 {
347+ panic("no return value specified for FindRunnerByToken")
348+ }
349+
350+ var r0 domain.CIRunner
351+ var r1 error
352+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) (domain.CIRunner, error)); ok {
353+ return returnFunc(ctx, hash)
354+ }
355+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.TokenHash) domain.CIRunner); ok {
356+ r0 = returnFunc(ctx, hash)
357+ } else {
358+ r0 = ret.Get(0).(domain.CIRunner)
359+ }
360+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.TokenHash) error); ok {
361+ r1 = returnFunc(ctx, hash)
362+ } else {
363+ r1 = ret.Error(1)
364+ }
365+ return r0, r1
366+}
367+
368+// CIStore_FindRunnerByToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRunnerByToken'
369+type CIStore_FindRunnerByToken_Call struct {
370+ *mock.Call
371+}
372+
373+// FindRunnerByToken is a helper method to define mock.On call
374+// - ctx context.Context
375+// - hash domain.TokenHash
376+func (_e *CIStore_Expecter) FindRunnerByToken(ctx any, hash any) *CIStore_FindRunnerByToken_Call {
377+ return &CIStore_FindRunnerByToken_Call{Call: _e.mock.On("FindRunnerByToken", ctx, hash)}
378+}
379+
380+func (_c *CIStore_FindRunnerByToken_Call) Run(run func(ctx context.Context, hash domain.TokenHash)) *CIStore_FindRunnerByToken_Call {
381+ _c.Call.Run(func(args mock.Arguments) {
382+ var arg0 context.Context
383+ if args[0] != nil {
384+ arg0 = args[0].(context.Context)
385+ }
386+ var arg1 domain.TokenHash
387+ if args[1] != nil {
388+ arg1 = args[1].(domain.TokenHash)
389+ }
390+ run(
391+ arg0,
392+ arg1,
393+ )
394+ })
395+ return _c
396+}
397+
398+func (_c *CIStore_FindRunnerByToken_Call) Return(cIRunner domain.CIRunner, err error) *CIStore_FindRunnerByToken_Call {
399+ _c.Call.Return(cIRunner, err)
400+ return _c
401+}
402+
403+func (_c *CIStore_FindRunnerByToken_Call) RunAndReturn(run func(ctx context.Context, hash domain.TokenHash) (domain.CIRunner, error)) *CIStore_FindRunnerByToken_Call {
404+ _c.Call.Return(run)
405+ return _c
406+}
407+
408+// GetJob provides a mock function for the type CIStore
409+func (_mock *CIStore) GetJob(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error) {
410+ ret := _mock.Called(ctx, runID, runAttempt, jobID)
411+
412+ if len(ret) == 0 {
413+ panic("no return value specified for GetJob")
414+ }
415+
416+ var r0 domain.CIJob
417+ var r1 error
418+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) (domain.CIJob, error)); ok {
419+ return returnFunc(ctx, runID, runAttempt, jobID)
420+ }
421+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32, string) domain.CIJob); ok {
422+ r0 = returnFunc(ctx, runID, runAttempt, jobID)
423+ } else {
424+ r0 = ret.Get(0).(domain.CIJob)
425+ }
426+ if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int32, string) error); ok {
427+ r1 = returnFunc(ctx, runID, runAttempt, jobID)
428+ } else {
429+ r1 = ret.Error(1)
430+ }
431+ return r0, r1
432+}
433+
434+// CIStore_GetJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJob'
435+type CIStore_GetJob_Call struct {
436+ *mock.Call
437+}
438+
439+// GetJob is a helper method to define mock.On call
440+// - ctx context.Context
441+// - runID int64
442+// - runAttempt int32
443+// - jobID string
444+func (_e *CIStore_Expecter) GetJob(ctx any, runID any, runAttempt any, jobID any) *CIStore_GetJob_Call {
445+ return &CIStore_GetJob_Call{Call: _e.mock.On("GetJob", ctx, runID, runAttempt, jobID)}
446+}
447+
448+func (_c *CIStore_GetJob_Call) Run(run func(ctx context.Context, runID int64, runAttempt int32, jobID string)) *CIStore_GetJob_Call {
449+ _c.Call.Run(func(args mock.Arguments) {
450+ var arg0 context.Context
451+ if args[0] != nil {
452+ arg0 = args[0].(context.Context)
453+ }
454+ var arg1 int64
455+ if args[1] != nil {
456+ arg1 = args[1].(int64)
457+ }
458+ var arg2 int32
459+ if args[2] != nil {
460+ arg2 = args[2].(int32)
461+ }
462+ var arg3 string
463+ if args[3] != nil {
464+ arg3 = args[3].(string)
465+ }
466+ run(
467+ arg0,
468+ arg1,
469+ arg2,
470+ arg3,
471+ )
472+ })
473+ return _c
474+}
475+
476+func (_c *CIStore_GetJob_Call) Return(cIJob domain.CIJob, err error) *CIStore_GetJob_Call {
477+ _c.Call.Return(cIJob, err)
478+ return _c
479+}
480+
481+func (_c *CIStore_GetJob_Call) RunAndReturn(run func(ctx context.Context, runID int64, runAttempt int32, jobID string) (domain.CIJob, error)) *CIStore_GetJob_Call {
482+ _c.Call.Return(run)
483+ return _c
484+}
485+
486+// GetRun provides a mock function for the type CIStore
487+func (_mock *CIStore) GetRun(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error) {
488+ ret := _mock.Called(ctx, repo, id)
489+
490+ if len(ret) == 0 {
491+ panic("no return value specified for GetRun")
492+ }
493+
494+ var r0 domain.CIRun
495+ var r1 error
496+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) (domain.CIRun, error)); ok {
497+ return returnFunc(ctx, repo, id)
498+ }
499+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64) domain.CIRun); ok {
500+ r0 = returnFunc(ctx, repo, id)
501+ } else {
502+ r0 = ret.Get(0).(domain.CIRun)
503+ }
504+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64) error); ok {
505+ r1 = returnFunc(ctx, repo, id)
506+ } else {
507+ r1 = ret.Error(1)
508+ }
509+ return r0, r1
510+}
511+
512+// CIStore_GetRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRun'
513+type CIStore_GetRun_Call struct {
514+ *mock.Call
515+}
516+
517+// GetRun is a helper method to define mock.On call
518+// - ctx context.Context
519+// - repo domain.RepoName
520+// - id int64
521+func (_e *CIStore_Expecter) GetRun(ctx any, repo any, id any) *CIStore_GetRun_Call {
522+ return &CIStore_GetRun_Call{Call: _e.mock.On("GetRun", ctx, repo, id)}
523+}
524+
525+func (_c *CIStore_GetRun_Call) Run(run func(ctx context.Context, repo domain.RepoName, id int64)) *CIStore_GetRun_Call {
526+ _c.Call.Run(func(args mock.Arguments) {
527+ var arg0 context.Context
528+ if args[0] != nil {
529+ arg0 = args[0].(context.Context)
530+ }
531+ var arg1 domain.RepoName
532+ if args[1] != nil {
533+ arg1 = args[1].(domain.RepoName)
534+ }
535+ var arg2 int64
536+ if args[2] != nil {
537+ arg2 = args[2].(int64)
538+ }
539+ run(
540+ arg0,
541+ arg1,
542+ arg2,
543+ )
544+ })
545+ return _c
546+}
547+
548+func (_c *CIStore_GetRun_Call) Return(cIRun domain.CIRun, err error) *CIStore_GetRun_Call {
549+ _c.Call.Return(cIRun, err)
550+ return _c
551+}
552+
553+func (_c *CIStore_GetRun_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, id int64) (domain.CIRun, error)) *CIStore_GetRun_Call {
554+ _c.Call.Return(run)
555+ return _c
556+}
557+
558+// GetRunSource provides a mock function for the type CIStore
559+func (_mock *CIStore) GetRunSource(ctx context.Context, runID int64) (domain.CIRunSource, error) {
560+ ret := _mock.Called(ctx, runID)
561+
562+ if len(ret) == 0 {
563+ panic("no return value specified for GetRunSource")
564+ }
565+
566+ var r0 domain.CIRunSource
567+ var r1 error
568+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64) (domain.CIRunSource, error)); ok {
569+ return returnFunc(ctx, runID)
570+ }
571+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64) domain.CIRunSource); ok {
572+ r0 = returnFunc(ctx, runID)
573+ } else {
574+ r0 = ret.Get(0).(domain.CIRunSource)
575+ }
576+ if returnFunc, ok := ret.Get(1).(func(context.Context, int64) error); ok {
577+ r1 = returnFunc(ctx, runID)
578+ } else {
579+ r1 = ret.Error(1)
580+ }
581+ return r0, r1
582+}
583+
584+// CIStore_GetRunSource_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRunSource'
585+type CIStore_GetRunSource_Call struct {
586+ *mock.Call
587+}
588+
589+// GetRunSource is a helper method to define mock.On call
590+// - ctx context.Context
591+// - runID int64
592+func (_e *CIStore_Expecter) GetRunSource(ctx any, runID any) *CIStore_GetRunSource_Call {
593+ return &CIStore_GetRunSource_Call{Call: _e.mock.On("GetRunSource", ctx, runID)}
594+}
595+
596+func (_c *CIStore_GetRunSource_Call) Run(run func(ctx context.Context, runID int64)) *CIStore_GetRunSource_Call {
597+ _c.Call.Run(func(args mock.Arguments) {
598+ var arg0 context.Context
599+ if args[0] != nil {
600+ arg0 = args[0].(context.Context)
601+ }
602+ var arg1 int64
603+ if args[1] != nil {
604+ arg1 = args[1].(int64)
605+ }
606+ run(
607+ arg0,
608+ arg1,
609+ )
610+ })
611+ return _c
612+}
613+
614+func (_c *CIStore_GetRunSource_Call) Return(cIRunSource domain.CIRunSource, err error) *CIStore_GetRunSource_Call {
615+ _c.Call.Return(cIRunSource, err)
616+ return _c
617+}
618+
619+func (_c *CIStore_GetRunSource_Call) RunAndReturn(run func(ctx context.Context, runID int64) (domain.CIRunSource, error)) *CIStore_GetRunSource_Call {
620+ _c.Call.Return(run)
621+ return _c
622+}
623+
624+// GetRunner provides a mock function for the type CIStore
625+func (_mock *CIStore) GetRunner(ctx context.Context, id string) (domain.CIRunner, error) {
626+ ret := _mock.Called(ctx, id)
627+
628+ if len(ret) == 0 {
629+ panic("no return value specified for GetRunner")
630+ }
631+
632+ var r0 domain.CIRunner
633+ var r1 error
634+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.CIRunner, error)); ok {
635+ return returnFunc(ctx, id)
636+ }
637+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.CIRunner); ok {
638+ r0 = returnFunc(ctx, id)
639+ } else {
640+ r0 = ret.Get(0).(domain.CIRunner)
641+ }
642+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
643+ r1 = returnFunc(ctx, id)
644+ } else {
645+ r1 = ret.Error(1)
646+ }
647+ return r0, r1
648+}
649+
650+// CIStore_GetRunner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRunner'
651+type CIStore_GetRunner_Call struct {
652+ *mock.Call
653+}
654+
655+// GetRunner is a helper method to define mock.On call
656+// - ctx context.Context
657+// - id string
658+func (_e *CIStore_Expecter) GetRunner(ctx any, id any) *CIStore_GetRunner_Call {
659+ return &CIStore_GetRunner_Call{Call: _e.mock.On("GetRunner", ctx, id)}
660+}
661+
662+func (_c *CIStore_GetRunner_Call) Run(run func(ctx context.Context, id string)) *CIStore_GetRunner_Call {
663+ _c.Call.Run(func(args mock.Arguments) {
664+ var arg0 context.Context
665+ if args[0] != nil {
666+ arg0 = args[0].(context.Context)
667+ }
668+ var arg1 string
669+ if args[1] != nil {
670+ arg1 = args[1].(string)
671+ }
672+ run(
673+ arg0,
674+ arg1,
675+ )
676+ })
677+ return _c
678+}
679+
680+func (_c *CIStore_GetRunner_Call) Return(cIRunner domain.CIRunner, err error) *CIStore_GetRunner_Call {
681+ _c.Call.Return(cIRunner, err)
682+ return _c
683+}
684+
685+func (_c *CIStore_GetRunner_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.CIRunner, error)) *CIStore_GetRunner_Call {
686+ _c.Call.Return(run)
687+ return _c
688+}
689+
690+// ListActiveJobs provides a mock function for the type CIStore
691+func (_mock *CIStore) ListActiveJobs(ctx context.Context) ([]domain.CIJob, error) {
692+ ret := _mock.Called(ctx)
693+
694+ if len(ret) == 0 {
695+ panic("no return value specified for ListActiveJobs")
696+ }
697+
698+ var r0 []domain.CIJob
699+ var r1 error
700+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CIJob, error)); ok {
701+ return returnFunc(ctx)
702+ }
703+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CIJob); ok {
704+ r0 = returnFunc(ctx)
705+ } else {
706+ if ret.Get(0) != nil {
707+ r0 = ret.Get(0).([]domain.CIJob)
708+ }
709+ }
710+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
711+ r1 = returnFunc(ctx)
712+ } else {
713+ r1 = ret.Error(1)
714+ }
715+ return r0, r1
716+}
717+
718+// CIStore_ListActiveJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListActiveJobs'
719+type CIStore_ListActiveJobs_Call struct {
720+ *mock.Call
721+}
722+
723+// ListActiveJobs is a helper method to define mock.On call
724+// - ctx context.Context
725+func (_e *CIStore_Expecter) ListActiveJobs(ctx any) *CIStore_ListActiveJobs_Call {
726+ return &CIStore_ListActiveJobs_Call{Call: _e.mock.On("ListActiveJobs", ctx)}
727+}
728+
729+func (_c *CIStore_ListActiveJobs_Call) Run(run func(ctx context.Context)) *CIStore_ListActiveJobs_Call {
730+ _c.Call.Run(func(args mock.Arguments) {
731+ var arg0 context.Context
732+ if args[0] != nil {
733+ arg0 = args[0].(context.Context)
734+ }
735+ run(
736+ arg0,
737+ )
738+ })
739+ return _c
740+}
741+
742+func (_c *CIStore_ListActiveJobs_Call) Return(cIJobs []domain.CIJob, err error) *CIStore_ListActiveJobs_Call {
743+ _c.Call.Return(cIJobs, err)
744+ return _c
745+}
746+
747+func (_c *CIStore_ListActiveJobs_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CIJob, error)) *CIStore_ListActiveJobs_Call {
748+ _c.Call.Return(run)
749+ return _c
750+}
751+
752+// ListChecks provides a mock function for the type CIStore
753+func (_mock *CIStore) ListChecks(ctx context.Context, repo domain.RepoName, sha string, limit int) ([]domain.CICheck, bool, error) {
754+ ret := _mock.Called(ctx, repo, sha, limit)
755+
756+ if len(ret) == 0 {
757+ panic("no return value specified for ListChecks")
758+ }
759+
760+ var r0 []domain.CICheck
761+ var r1 bool
762+ var r2 error
763+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, int) ([]domain.CICheck, bool, error)); ok {
764+ return returnFunc(ctx, repo, sha, limit)
765+ }
766+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, string, int) []domain.CICheck); ok {
767+ r0 = returnFunc(ctx, repo, sha, limit)
768+ } else {
769+ if ret.Get(0) != nil {
770+ r0 = ret.Get(0).([]domain.CICheck)
771+ }
772+ }
773+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, string, int) bool); ok {
774+ r1 = returnFunc(ctx, repo, sha, limit)
775+ } else {
776+ r1 = ret.Get(1).(bool)
777+ }
778+ if returnFunc, ok := ret.Get(2).(func(context.Context, domain.RepoName, string, int) error); ok {
779+ r2 = returnFunc(ctx, repo, sha, limit)
780+ } else {
781+ r2 = ret.Error(2)
782+ }
783+ return r0, r1, r2
784+}
785+
786+// CIStore_ListChecks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListChecks'
787+type CIStore_ListChecks_Call struct {
788+ *mock.Call
789+}
790+
791+// ListChecks is a helper method to define mock.On call
792+// - ctx context.Context
793+// - repo domain.RepoName
794+// - sha string
795+// - limit int
796+func (_e *CIStore_Expecter) ListChecks(ctx any, repo any, sha any, limit any) *CIStore_ListChecks_Call {
797+ return &CIStore_ListChecks_Call{Call: _e.mock.On("ListChecks", ctx, repo, sha, limit)}
798+}
799+
800+func (_c *CIStore_ListChecks_Call) Run(run func(ctx context.Context, repo domain.RepoName, sha string, limit int)) *CIStore_ListChecks_Call {
801+ _c.Call.Run(func(args mock.Arguments) {
802+ var arg0 context.Context
803+ if args[0] != nil {
804+ arg0 = args[0].(context.Context)
805+ }
806+ var arg1 domain.RepoName
807+ if args[1] != nil {
808+ arg1 = args[1].(domain.RepoName)
809+ }
810+ var arg2 string
811+ if args[2] != nil {
812+ arg2 = args[2].(string)
813+ }
814+ var arg3 int
815+ if args[3] != nil {
816+ arg3 = args[3].(int)
817+ }
818+ run(
819+ arg0,
820+ arg1,
821+ arg2,
822+ arg3,
823+ )
824+ })
825+ return _c
826+}
827+
828+func (_c *CIStore_ListChecks_Call) Return(checks []domain.CICheck, more bool, err error) *CIStore_ListChecks_Call {
829+ _c.Call.Return(checks, more, err)
830+ return _c
831+}
832+
833+func (_c *CIStore_ListChecks_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, sha string, limit int) ([]domain.CICheck, bool, error)) *CIStore_ListChecks_Call {
834+ _c.Call.Return(run)
835+ return _c
836+}
837+
838+// ListJobs provides a mock function for the type CIStore
839+func (_mock *CIStore) ListJobs(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error) {
840+ ret := _mock.Called(ctx, runID, runAttempt)
841+
842+ if len(ret) == 0 {
843+ panic("no return value specified for ListJobs")
844+ }
845+
846+ var r0 []domain.CIJob
847+ var r1 error
848+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32) ([]domain.CIJob, error)); ok {
849+ return returnFunc(ctx, runID, runAttempt)
850+ }
851+ if returnFunc, ok := ret.Get(0).(func(context.Context, int64, int32) []domain.CIJob); ok {
852+ r0 = returnFunc(ctx, runID, runAttempt)
853+ } else {
854+ if ret.Get(0) != nil {
855+ r0 = ret.Get(0).([]domain.CIJob)
856+ }
857+ }
858+ if returnFunc, ok := ret.Get(1).(func(context.Context, int64, int32) error); ok {
859+ r1 = returnFunc(ctx, runID, runAttempt)
860+ } else {
861+ r1 = ret.Error(1)
862+ }
863+ return r0, r1
864+}
865+
866+// CIStore_ListJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListJobs'
867+type CIStore_ListJobs_Call struct {
868+ *mock.Call
869+}
870+
871+// ListJobs is a helper method to define mock.On call
872+// - ctx context.Context
873+// - runID int64
874+// - runAttempt int32
875+func (_e *CIStore_Expecter) ListJobs(ctx any, runID any, runAttempt any) *CIStore_ListJobs_Call {
876+ return &CIStore_ListJobs_Call{Call: _e.mock.On("ListJobs", ctx, runID, runAttempt)}
877+}
878+
879+func (_c *CIStore_ListJobs_Call) Run(run func(ctx context.Context, runID int64, runAttempt int32)) *CIStore_ListJobs_Call {
880+ _c.Call.Run(func(args mock.Arguments) {
881+ var arg0 context.Context
882+ if args[0] != nil {
883+ arg0 = args[0].(context.Context)
884+ }
885+ var arg1 int64
886+ if args[1] != nil {
887+ arg1 = args[1].(int64)
888+ }
889+ var arg2 int32
890+ if args[2] != nil {
891+ arg2 = args[2].(int32)
892+ }
893+ run(
894+ arg0,
895+ arg1,
896+ arg2,
897+ )
898+ })
899+ return _c
900+}
901+
902+func (_c *CIStore_ListJobs_Call) Return(cIJobs []domain.CIJob, err error) *CIStore_ListJobs_Call {
903+ _c.Call.Return(cIJobs, err)
904+ return _c
905+}
906+
907+func (_c *CIStore_ListJobs_Call) RunAndReturn(run func(ctx context.Context, runID int64, runAttempt int32) ([]domain.CIJob, error)) *CIStore_ListJobs_Call {
908+ _c.Call.Return(run)
909+ return _c
910+}
911+
912+// ListOpenRuns provides a mock function for the type CIStore
913+func (_mock *CIStore) ListOpenRuns(ctx context.Context) ([]domain.CIRun, error) {
914+ ret := _mock.Called(ctx)
915+
916+ if len(ret) == 0 {
917+ panic("no return value specified for ListOpenRuns")
918+ }
919+
920+ var r0 []domain.CIRun
921+ var r1 error
922+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CIRun, error)); ok {
923+ return returnFunc(ctx)
924+ }
925+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CIRun); ok {
926+ r0 = returnFunc(ctx)
927+ } else {
928+ if ret.Get(0) != nil {
929+ r0 = ret.Get(0).([]domain.CIRun)
930+ }
931+ }
932+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
933+ r1 = returnFunc(ctx)
934+ } else {
935+ r1 = ret.Error(1)
936+ }
937+ return r0, r1
938+}
939+
940+// CIStore_ListOpenRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOpenRuns'
941+type CIStore_ListOpenRuns_Call struct {
942+ *mock.Call
943+}
944+
945+// ListOpenRuns is a helper method to define mock.On call
946+// - ctx context.Context
947+func (_e *CIStore_Expecter) ListOpenRuns(ctx any) *CIStore_ListOpenRuns_Call {
948+ return &CIStore_ListOpenRuns_Call{Call: _e.mock.On("ListOpenRuns", ctx)}
949+}
950+
951+func (_c *CIStore_ListOpenRuns_Call) Run(run func(ctx context.Context)) *CIStore_ListOpenRuns_Call {
952+ _c.Call.Run(func(args mock.Arguments) {
953+ var arg0 context.Context
954+ if args[0] != nil {
955+ arg0 = args[0].(context.Context)
956+ }
957+ run(
958+ arg0,
959+ )
960+ })
961+ return _c
962+}
963+
964+func (_c *CIStore_ListOpenRuns_Call) Return(cIRuns []domain.CIRun, err error) *CIStore_ListOpenRuns_Call {
965+ _c.Call.Return(cIRuns, err)
966+ return _c
967+}
968+
969+func (_c *CIStore_ListOpenRuns_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CIRun, error)) *CIStore_ListOpenRuns_Call {
970+ _c.Call.Return(run)
971+ return _c
972+}
973+
974+// ListReadyJobs provides a mock function for the type CIStore
975+func (_mock *CIStore) ListReadyJobs(ctx context.Context, limit int) ([]domain.CIJob, error) {
976+ ret := _mock.Called(ctx, limit)
977+
978+ if len(ret) == 0 {
979+ panic("no return value specified for ListReadyJobs")
980+ }
981+
982+ var r0 []domain.CIJob
983+ var r1 error
984+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]domain.CIJob, error)); ok {
985+ return returnFunc(ctx, limit)
986+ }
987+ if returnFunc, ok := ret.Get(0).(func(context.Context, int) []domain.CIJob); ok {
988+ r0 = returnFunc(ctx, limit)
989+ } else {
990+ if ret.Get(0) != nil {
991+ r0 = ret.Get(0).([]domain.CIJob)
992+ }
993+ }
994+ if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
995+ r1 = returnFunc(ctx, limit)
996+ } else {
997+ r1 = ret.Error(1)
998+ }
999+ return r0, r1
1000+}
1001+
1002+// CIStore_ListReadyJobs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListReadyJobs'
1003+type CIStore_ListReadyJobs_Call struct {
1004+ *mock.Call
1005+}
1006+
1007+// ListReadyJobs is a helper method to define mock.On call
1008+// - ctx context.Context
1009+// - limit int
1010+func (_e *CIStore_Expecter) ListReadyJobs(ctx any, limit any) *CIStore_ListReadyJobs_Call {
1011+ return &CIStore_ListReadyJobs_Call{Call: _e.mock.On("ListReadyJobs", ctx, limit)}
1012+}
1013+
1014+func (_c *CIStore_ListReadyJobs_Call) Run(run func(ctx context.Context, limit int)) *CIStore_ListReadyJobs_Call {
1015+ _c.Call.Run(func(args mock.Arguments) {
1016+ var arg0 context.Context
1017+ if args[0] != nil {
1018+ arg0 = args[0].(context.Context)
1019+ }
1020+ var arg1 int
1021+ if args[1] != nil {
1022+ arg1 = args[1].(int)
1023+ }
1024+ run(
1025+ arg0,
1026+ arg1,
1027+ )
1028+ })
1029+ return _c
1030+}
1031+
1032+func (_c *CIStore_ListReadyJobs_Call) Return(cIJobs []domain.CIJob, err error) *CIStore_ListReadyJobs_Call {
1033+ _c.Call.Return(cIJobs, err)
1034+ return _c
1035+}
1036+
1037+func (_c *CIStore_ListReadyJobs_Call) RunAndReturn(run func(ctx context.Context, limit int) ([]domain.CIJob, error)) *CIStore_ListReadyJobs_Call {
1038+ _c.Call.Return(run)
1039+ return _c
1040+}
1041+
1042+// ListRunners provides a mock function for the type CIStore
1043+func (_mock *CIStore) ListRunners(ctx context.Context) ([]domain.CIRunner, error) {
1044+ ret := _mock.Called(ctx)
1045+
1046+ if len(ret) == 0 {
1047+ panic("no return value specified for ListRunners")
1048+ }
1049+
1050+ var r0 []domain.CIRunner
1051+ var r1 error
1052+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]domain.CIRunner, error)); ok {
1053+ return returnFunc(ctx)
1054+ }
1055+ if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.CIRunner); ok {
1056+ r0 = returnFunc(ctx)
1057+ } else {
1058+ if ret.Get(0) != nil {
1059+ r0 = ret.Get(0).([]domain.CIRunner)
1060+ }
1061+ }
1062+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1063+ r1 = returnFunc(ctx)
1064+ } else {
1065+ r1 = ret.Error(1)
1066+ }
1067+ return r0, r1
1068+}
1069+
1070+// CIStore_ListRunners_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRunners'
1071+type CIStore_ListRunners_Call struct {
1072+ *mock.Call
1073+}
1074+
1075+// ListRunners is a helper method to define mock.On call
1076+// - ctx context.Context
1077+func (_e *CIStore_Expecter) ListRunners(ctx any) *CIStore_ListRunners_Call {
1078+ return &CIStore_ListRunners_Call{Call: _e.mock.On("ListRunners", ctx)}
1079+}
1080+
1081+func (_c *CIStore_ListRunners_Call) Run(run func(ctx context.Context)) *CIStore_ListRunners_Call {
1082+ _c.Call.Run(func(args mock.Arguments) {
1083+ var arg0 context.Context
1084+ if args[0] != nil {
1085+ arg0 = args[0].(context.Context)
1086+ }
1087+ run(
1088+ arg0,
1089+ )
1090+ })
1091+ return _c
1092+}
1093+
1094+func (_c *CIStore_ListRunners_Call) Return(cIRunners []domain.CIRunner, err error) *CIStore_ListRunners_Call {
1095+ _c.Call.Return(cIRunners, err)
1096+ return _c
1097+}
1098+
1099+func (_c *CIStore_ListRunners_Call) RunAndReturn(run func(ctx context.Context) ([]domain.CIRunner, error)) *CIStore_ListRunners_Call {
1100+ _c.Call.Return(run)
1101+ return _c
1102+}
1103+
1104+// ListRuns provides a mock function for the type CIStore
1105+func (_mock *CIStore) ListRuns(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error) {
1106+ ret := _mock.Called(ctx, repo, before, limit)
1107+
1108+ if len(ret) == 0 {
1109+ panic("no return value specified for ListRuns")
1110+ }
1111+
1112+ var r0 []domain.CIRun
1113+ var r1 error
1114+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) ([]domain.CIRun, error)); ok {
1115+ return returnFunc(ctx, repo, before, limit)
1116+ }
1117+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName, int64, int) []domain.CIRun); ok {
1118+ r0 = returnFunc(ctx, repo, before, limit)
1119+ } else {
1120+ if ret.Get(0) != nil {
1121+ r0 = ret.Get(0).([]domain.CIRun)
1122+ }
1123+ }
1124+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName, int64, int) error); ok {
1125+ r1 = returnFunc(ctx, repo, before, limit)
1126+ } else {
1127+ r1 = ret.Error(1)
1128+ }
1129+ return r0, r1
1130+}
1131+
1132+// CIStore_ListRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRuns'
1133+type CIStore_ListRuns_Call struct {
1134+ *mock.Call
1135+}
1136+
1137+// ListRuns is a helper method to define mock.On call
1138+// - ctx context.Context
1139+// - repo domain.RepoName
1140+// - before int64
1141+// - limit int
1142+func (_e *CIStore_Expecter) ListRuns(ctx any, repo any, before any, limit any) *CIStore_ListRuns_Call {
1143+ return &CIStore_ListRuns_Call{Call: _e.mock.On("ListRuns", ctx, repo, before, limit)}
1144+}
1145+
1146+func (_c *CIStore_ListRuns_Call) Run(run func(ctx context.Context, repo domain.RepoName, before int64, limit int)) *CIStore_ListRuns_Call {
1147+ _c.Call.Run(func(args mock.Arguments) {
1148+ var arg0 context.Context
1149+ if args[0] != nil {
1150+ arg0 = args[0].(context.Context)
1151+ }
1152+ var arg1 domain.RepoName
1153+ if args[1] != nil {
1154+ arg1 = args[1].(domain.RepoName)
1155+ }
1156+ var arg2 int64
1157+ if args[2] != nil {
1158+ arg2 = args[2].(int64)
1159+ }
1160+ var arg3 int
1161+ if args[3] != nil {
1162+ arg3 = args[3].(int)
1163+ }
1164+ run(
1165+ arg0,
1166+ arg1,
1167+ arg2,
1168+ arg3,
1169+ )
1170+ })
1171+ return _c
1172+}
1173+
1174+func (_c *CIStore_ListRuns_Call) Return(cIRuns []domain.CIRun, err error) *CIStore_ListRuns_Call {
1175+ _c.Call.Return(cIRuns, err)
1176+ return _c
1177+}
1178+
1179+func (_c *CIStore_ListRuns_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, before int64, limit int) ([]domain.CIRun, error)) *CIStore_ListRuns_Call {
1180+ _c.Call.Return(run)
1181+ return _c
1182+}
1183+
1184+// RemoveRunner provides a mock function for the type CIStore
1185+func (_mock *CIStore) RemoveRunner(ctx context.Context, id string) (domain.CIRunner, error) {
1186+ ret := _mock.Called(ctx, id)
1187+
1188+ if len(ret) == 0 {
1189+ panic("no return value specified for RemoveRunner")
1190+ }
1191+
1192+ var r0 domain.CIRunner
1193+ var r1 error
1194+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (domain.CIRunner, error)); ok {
1195+ return returnFunc(ctx, id)
1196+ }
1197+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) domain.CIRunner); ok {
1198+ r0 = returnFunc(ctx, id)
1199+ } else {
1200+ r0 = ret.Get(0).(domain.CIRunner)
1201+ }
1202+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
1203+ r1 = returnFunc(ctx, id)
1204+ } else {
1205+ r1 = ret.Error(1)
1206+ }
1207+ return r0, r1
1208+}
1209+
1210+// CIStore_RemoveRunner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRunner'
1211+type CIStore_RemoveRunner_Call struct {
1212+ *mock.Call
1213+}
1214+
1215+// RemoveRunner is a helper method to define mock.On call
1216+// - ctx context.Context
1217+// - id string
1218+func (_e *CIStore_Expecter) RemoveRunner(ctx any, id any) *CIStore_RemoveRunner_Call {
1219+ return &CIStore_RemoveRunner_Call{Call: _e.mock.On("RemoveRunner", ctx, id)}
1220+}
1221+
1222+func (_c *CIStore_RemoveRunner_Call) Run(run func(ctx context.Context, id string)) *CIStore_RemoveRunner_Call {
1223+ _c.Call.Run(func(args mock.Arguments) {
1224+ var arg0 context.Context
1225+ if args[0] != nil {
1226+ arg0 = args[0].(context.Context)
1227+ }
1228+ var arg1 string
1229+ if args[1] != nil {
1230+ arg1 = args[1].(string)
1231+ }
1232+ run(
1233+ arg0,
1234+ arg1,
1235+ )
1236+ })
1237+ return _c
1238+}
1239+
1240+func (_c *CIStore_RemoveRunner_Call) Return(cIRunner domain.CIRunner, err error) *CIStore_RemoveRunner_Call {
1241+ _c.Call.Return(cIRunner, err)
1242+ return _c
1243+}
1244+
1245+func (_c *CIStore_RemoveRunner_Call) RunAndReturn(run func(ctx context.Context, id string) (domain.CIRunner, error)) *CIStore_RemoveRunner_Call {
1246+ _c.Call.Return(run)
1247+ return _c
1248+}
1249+
1250+// RunIDs provides a mock function for the type CIStore
1251+func (_mock *CIStore) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) {
1252+ ret := _mock.Called(ctx, repo)
1253+
1254+ if len(ret) == 0 {
1255+ panic("no return value specified for RunIDs")
1256+ }
1257+
1258+ var r0 []int64
1259+ var r1 error
1260+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) ([]int64, error)); ok {
1261+ return returnFunc(ctx, repo)
1262+ }
1263+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RepoName) []int64); ok {
1264+ r0 = returnFunc(ctx, repo)
1265+ } else {
1266+ if ret.Get(0) != nil {
1267+ r0 = ret.Get(0).([]int64)
1268+ }
1269+ }
1270+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RepoName) error); ok {
1271+ r1 = returnFunc(ctx, repo)
1272+ } else {
1273+ r1 = ret.Error(1)
1274+ }
1275+ return r0, r1
1276+}
1277+
1278+// CIStore_RunIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RunIDs'
1279+type CIStore_RunIDs_Call struct {
1280+ *mock.Call
1281+}
1282+
1283+// RunIDs is a helper method to define mock.On call
1284+// - ctx context.Context
1285+// - repo domain.RepoName
1286+func (_e *CIStore_Expecter) RunIDs(ctx any, repo any) *CIStore_RunIDs_Call {
1287+ return &CIStore_RunIDs_Call{Call: _e.mock.On("RunIDs", ctx, repo)}
1288+}
1289+
1290+func (_c *CIStore_RunIDs_Call) Run(run func(ctx context.Context, repo domain.RepoName)) *CIStore_RunIDs_Call {
1291+ _c.Call.Run(func(args mock.Arguments) {
1292+ var arg0 context.Context
1293+ if args[0] != nil {
1294+ arg0 = args[0].(context.Context)
1295+ }
1296+ var arg1 domain.RepoName
1297+ if args[1] != nil {
1298+ arg1 = args[1].(domain.RepoName)
1299+ }
1300+ run(
1301+ arg0,
1302+ arg1,
1303+ )
1304+ })
1305+ return _c
1306+}
1307+
1308+func (_c *CIStore_RunIDs_Call) Return(int64s []int64, err error) *CIStore_RunIDs_Call {
1309+ _c.Call.Return(int64s, err)
1310+ return _c
1311+}
1312+
1313+func (_c *CIStore_RunIDs_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName) ([]int64, error)) *CIStore_RunIDs_Call {
1314+ _c.Call.Return(run)
1315+ return _c
1316+}
1317+
1318+// Sweep provides a mock function for the type CIStore
1319+func (_mock *CIStore) Sweep(ctx context.Context, before time.Time) ([]int64, error) {
1320+ ret := _mock.Called(ctx, before)
1321+
1322+ if len(ret) == 0 {
1323+ panic("no return value specified for Sweep")
1324+ }
1325+
1326+ var r0 []int64
1327+ var r1 error
1328+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) ([]int64, error)); ok {
1329+ return returnFunc(ctx, before)
1330+ }
1331+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) []int64); ok {
1332+ r0 = returnFunc(ctx, before)
1333+ } else {
1334+ if ret.Get(0) != nil {
1335+ r0 = ret.Get(0).([]int64)
1336+ }
1337+ }
1338+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok {
1339+ r1 = returnFunc(ctx, before)
1340+ } else {
1341+ r1 = ret.Error(1)
1342+ }
1343+ return r0, r1
1344+}
1345+
1346+// CIStore_Sweep_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sweep'
1347+type CIStore_Sweep_Call struct {
1348+ *mock.Call
1349+}
1350+
1351+// Sweep is a helper method to define mock.On call
1352+// - ctx context.Context
1353+// - before time.Time
1354+func (_e *CIStore_Expecter) Sweep(ctx any, before any) *CIStore_Sweep_Call {
1355+ return &CIStore_Sweep_Call{Call: _e.mock.On("Sweep", ctx, before)}
1356+}
1357+
1358+func (_c *CIStore_Sweep_Call) Run(run func(ctx context.Context, before time.Time)) *CIStore_Sweep_Call {
1359+ _c.Call.Run(func(args mock.Arguments) {
1360+ var arg0 context.Context
1361+ if args[0] != nil {
1362+ arg0 = args[0].(context.Context)
1363+ }
1364+ var arg1 time.Time
1365+ if args[1] != nil {
1366+ arg1 = args[1].(time.Time)
1367+ }
1368+ run(
1369+ arg0,
1370+ arg1,
1371+ )
1372+ })
1373+ return _c
1374+}
1375+
1376+func (_c *CIStore_Sweep_Call) Return(int64s []int64, err error) *CIStore_Sweep_Call {
1377+ _c.Call.Return(int64s, err)
1378+ return _c
1379+}
1380+
1381+func (_c *CIStore_Sweep_Call) RunAndReturn(run func(ctx context.Context, before time.Time) ([]int64, error)) *CIStore_Sweep_Call {
1382+ _c.Call.Return(run)
1383+ return _c
1384+}
A internal/ports/mocks/RunService.go +751 -0
@@ -0,0 +1,751 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewRunService creates a new instance of RunService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewRunService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *RunService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &RunService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// RunService is an autogenerated mock type for the RunService type
39+type RunService struct {
40+ mock.Mock
41+}
42+
43+type RunService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *RunService) EXPECT() *RunService_Expecter {
48+ return &RunService_Expecter{mock: &_m.Mock}
49+}
50+
51+// CancelRun provides a mock function for the type RunService
52+func (_mock *RunService) CancelRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error) {
53+ ret := _mock.Called(ctx, p, repo, runID)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for CancelRun")
57+ }
58+
59+ var r0 domain.CIRun
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) (domain.CIRun, error)); ok {
62+ return returnFunc(ctx, p, repo, runID)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64) domain.CIRun); ok {
65+ r0 = returnFunc(ctx, p, repo, runID)
66+ } else {
67+ r0 = ret.Get(0).(domain.CIRun)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64) error); ok {
70+ r1 = returnFunc(ctx, p, repo, runID)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// RunService_CancelRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CancelRun'
78+type RunService_CancelRun_Call struct {
79+ *mock.Call
80+}
81+
82+// CancelRun is a helper method to define mock.On call
83+// - ctx context.Context
84+// - p domain.Principal
85+// - repo domain.RepoName
86+// - runID int64
87+func (_e *RunService_Expecter) CancelRun(ctx any, p any, repo any, runID any) *RunService_CancelRun_Call {
88+ return &RunService_CancelRun_Call{Call: _e.mock.On("CancelRun", ctx, p, repo, runID)}
89+}
90+
91+func (_c *RunService_CancelRun_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64)) *RunService_CancelRun_Call {
92+ _c.Call.Run(func(args mock.Arguments) {
93+ var arg0 context.Context
94+ if args[0] != nil {
95+ arg0 = args[0].(context.Context)
96+ }
97+ var arg1 domain.Principal
98+ if args[1] != nil {
99+ arg1 = args[1].(domain.Principal)
100+ }
101+ var arg2 domain.RepoName
102+ if args[2] != nil {
103+ arg2 = args[2].(domain.RepoName)
104+ }
105+ var arg3 int64
106+ if args[3] != nil {
107+ arg3 = args[3].(int64)
108+ }
109+ run(
110+ arg0,
111+ arg1,
112+ arg2,
113+ arg3,
114+ )
115+ })
116+ return _c
117+}
118+
119+func (_c *RunService_CancelRun_Call) Return(cIRun domain.CIRun, err error) *RunService_CancelRun_Call {
120+ _c.Call.Return(cIRun, err)
121+ return _c
122+}
123+
124+func (_c *RunService_CancelRun_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64) (domain.CIRun, error)) *RunService_CancelRun_Call {
125+ _c.Call.Return(run)
126+ return _c
127+}
128+
129+// GetJob provides a mock function for the type RunService
130+func (_mock *RunService) GetJob(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error) {
131+ ret := _mock.Called(ctx, p, repo, jobID, attempt)
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for GetJob")
135+ }
136+
137+ var r0 ports.JobView
138+ var r1 error
139+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) (ports.JobView, error)); ok {
140+ return returnFunc(ctx, p, repo, jobID, attempt)
141+ }
142+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string, int32) ports.JobView); ok {
143+ r0 = returnFunc(ctx, p, repo, jobID, attempt)
144+ } else {
145+ r0 = ret.Get(0).(ports.JobView)
146+ }
147+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string, int32) error); ok {
148+ r1 = returnFunc(ctx, p, repo, jobID, attempt)
149+ } else {
150+ r1 = ret.Error(1)
151+ }
152+ return r0, r1
153+}
154+
155+// RunService_GetJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJob'
156+type RunService_GetJob_Call struct {
157+ *mock.Call
158+}
159+
160+// GetJob is a helper method to define mock.On call
161+// - ctx context.Context
162+// - p domain.Principal
163+// - repo domain.RepoName
164+// - jobID string
165+// - attempt int32
166+func (_e *RunService_Expecter) GetJob(ctx any, p any, repo any, jobID any, attempt any) *RunService_GetJob_Call {
167+ return &RunService_GetJob_Call{Call: _e.mock.On("GetJob", ctx, p, repo, jobID, attempt)}
168+}
169+
170+func (_c *RunService_GetJob_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32)) *RunService_GetJob_Call {
171+ _c.Call.Run(func(args mock.Arguments) {
172+ var arg0 context.Context
173+ if args[0] != nil {
174+ arg0 = args[0].(context.Context)
175+ }
176+ var arg1 domain.Principal
177+ if args[1] != nil {
178+ arg1 = args[1].(domain.Principal)
179+ }
180+ var arg2 domain.RepoName
181+ if args[2] != nil {
182+ arg2 = args[2].(domain.RepoName)
183+ }
184+ var arg3 string
185+ if args[3] != nil {
186+ arg3 = args[3].(string)
187+ }
188+ var arg4 int32
189+ if args[4] != nil {
190+ arg4 = args[4].(int32)
191+ }
192+ run(
193+ arg0,
194+ arg1,
195+ arg2,
196+ arg3,
197+ arg4,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *RunService_GetJob_Call) Return(jobView ports.JobView, err error) *RunService_GetJob_Call {
204+ _c.Call.Return(jobView, err)
205+ return _c
206+}
207+
208+func (_c *RunService_GetJob_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, jobID string, attempt int32) (ports.JobView, error)) *RunService_GetJob_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
213+// GetJobLog provides a mock function for the type RunService
214+func (_mock *RunService) GetJobLog(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.GetJobLogParams, send func(ports.LogBatch) error) error {
215+ ret := _mock.Called(ctx, p, repo, params, send)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for GetJobLog")
219+ }
220+
221+ var r0 error
222+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.GetJobLogParams, func(ports.LogBatch) error) error); ok {
223+ r0 = returnFunc(ctx, p, repo, params, send)
224+ } else {
225+ r0 = ret.Error(0)
226+ }
227+ return r0
228+}
229+
230+// RunService_GetJobLog_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetJobLog'
231+type RunService_GetJobLog_Call struct {
232+ *mock.Call
233+}
234+
235+// GetJobLog is a helper method to define mock.On call
236+// - ctx context.Context
237+// - p domain.Principal
238+// - repo domain.RepoName
239+// - params ports.GetJobLogParams
240+// - send func(ports.LogBatch) error
241+func (_e *RunService_Expecter) GetJobLog(ctx any, p any, repo any, params any, send any) *RunService_GetJobLog_Call {
242+ return &RunService_GetJobLog_Call{Call: _e.mock.On("GetJobLog", ctx, p, repo, params, send)}
243+}
244+
245+func (_c *RunService_GetJobLog_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.GetJobLogParams, send func(ports.LogBatch) error)) *RunService_GetJobLog_Call {
246+ _c.Call.Run(func(args mock.Arguments) {
247+ var arg0 context.Context
248+ if args[0] != nil {
249+ arg0 = args[0].(context.Context)
250+ }
251+ var arg1 domain.Principal
252+ if args[1] != nil {
253+ arg1 = args[1].(domain.Principal)
254+ }
255+ var arg2 domain.RepoName
256+ if args[2] != nil {
257+ arg2 = args[2].(domain.RepoName)
258+ }
259+ var arg3 ports.GetJobLogParams
260+ if args[3] != nil {
261+ arg3 = args[3].(ports.GetJobLogParams)
262+ }
263+ var arg4 func(ports.LogBatch) error
264+ if args[4] != nil {
265+ arg4 = args[4].(func(ports.LogBatch) error)
266+ }
267+ run(
268+ arg0,
269+ arg1,
270+ arg2,
271+ arg3,
272+ arg4,
273+ )
274+ })
275+ return _c
276+}
277+
278+func (_c *RunService_GetJobLog_Call) Return(err error) *RunService_GetJobLog_Call {
279+ _c.Call.Return(err)
280+ return _c
281+}
282+
283+func (_c *RunService_GetJobLog_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.GetJobLogParams, send func(ports.LogBatch) error) error) *RunService_GetJobLog_Call {
284+ _c.Call.Return(run)
285+ return _c
286+}
287+
288+// GetRun provides a mock function for the type RunService
289+func (_mock *RunService) GetRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32) (ports.RunDetail, error) {
290+ ret := _mock.Called(ctx, p, repo, runID, attempt)
291+
292+ if len(ret) == 0 {
293+ panic("no return value specified for GetRun")
294+ }
295+
296+ var r0 ports.RunDetail
297+ var r1 error
298+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32) (ports.RunDetail, error)); ok {
299+ return returnFunc(ctx, p, repo, runID, attempt)
300+ }
301+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, int32) ports.RunDetail); ok {
302+ r0 = returnFunc(ctx, p, repo, runID, attempt)
303+ } else {
304+ r0 = ret.Get(0).(ports.RunDetail)
305+ }
306+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, int32) error); ok {
307+ r1 = returnFunc(ctx, p, repo, runID, attempt)
308+ } else {
309+ r1 = ret.Error(1)
310+ }
311+ return r0, r1
312+}
313+
314+// RunService_GetRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRun'
315+type RunService_GetRun_Call struct {
316+ *mock.Call
317+}
318+
319+// GetRun is a helper method to define mock.On call
320+// - ctx context.Context
321+// - p domain.Principal
322+// - repo domain.RepoName
323+// - runID int64
324+// - attempt int32
325+func (_e *RunService_Expecter) GetRun(ctx any, p any, repo any, runID any, attempt any) *RunService_GetRun_Call {
326+ return &RunService_GetRun_Call{Call: _e.mock.On("GetRun", ctx, p, repo, runID, attempt)}
327+}
328+
329+func (_c *RunService_GetRun_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32)) *RunService_GetRun_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ var arg0 context.Context
332+ if args[0] != nil {
333+ arg0 = args[0].(context.Context)
334+ }
335+ var arg1 domain.Principal
336+ if args[1] != nil {
337+ arg1 = args[1].(domain.Principal)
338+ }
339+ var arg2 domain.RepoName
340+ if args[2] != nil {
341+ arg2 = args[2].(domain.RepoName)
342+ }
343+ var arg3 int64
344+ if args[3] != nil {
345+ arg3 = args[3].(int64)
346+ }
347+ var arg4 int32
348+ if args[4] != nil {
349+ arg4 = args[4].(int32)
350+ }
351+ run(
352+ arg0,
353+ arg1,
354+ arg2,
355+ arg3,
356+ arg4,
357+ )
358+ })
359+ return _c
360+}
361+
362+func (_c *RunService_GetRun_Call) Return(runDetail ports.RunDetail, err error) *RunService_GetRun_Call {
363+ _c.Call.Return(runDetail, err)
364+ return _c
365+}
366+
367+func (_c *RunService_GetRun_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, attempt int32) (ports.RunDetail, error)) *RunService_GetRun_Call {
368+ _c.Call.Return(run)
369+ return _c
370+}
371+
372+// ListChecks provides a mock function for the type RunService
373+func (_mock *RunService) ListChecks(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ports.ChecksResult, error) {
374+ ret := _mock.Called(ctx, p, repo, sha)
375+
376+ if len(ret) == 0 {
377+ panic("no return value specified for ListChecks")
378+ }
379+
380+ var r0 ports.ChecksResult
381+ var r1 error
382+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) (ports.ChecksResult, error)); ok {
383+ return returnFunc(ctx, p, repo, sha)
384+ }
385+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, string) ports.ChecksResult); ok {
386+ r0 = returnFunc(ctx, p, repo, sha)
387+ } else {
388+ r0 = ret.Get(0).(ports.ChecksResult)
389+ }
390+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, string) error); ok {
391+ r1 = returnFunc(ctx, p, repo, sha)
392+ } else {
393+ r1 = ret.Error(1)
394+ }
395+ return r0, r1
396+}
397+
398+// RunService_ListChecks_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListChecks'
399+type RunService_ListChecks_Call struct {
400+ *mock.Call
401+}
402+
403+// ListChecks is a helper method to define mock.On call
404+// - ctx context.Context
405+// - p domain.Principal
406+// - repo domain.RepoName
407+// - sha string
408+func (_e *RunService_Expecter) ListChecks(ctx any, p any, repo any, sha any) *RunService_ListChecks_Call {
409+ return &RunService_ListChecks_Call{Call: _e.mock.On("ListChecks", ctx, p, repo, sha)}
410+}
411+
412+func (_c *RunService_ListChecks_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string)) *RunService_ListChecks_Call {
413+ _c.Call.Run(func(args mock.Arguments) {
414+ var arg0 context.Context
415+ if args[0] != nil {
416+ arg0 = args[0].(context.Context)
417+ }
418+ var arg1 domain.Principal
419+ if args[1] != nil {
420+ arg1 = args[1].(domain.Principal)
421+ }
422+ var arg2 domain.RepoName
423+ if args[2] != nil {
424+ arg2 = args[2].(domain.RepoName)
425+ }
426+ var arg3 string
427+ if args[3] != nil {
428+ arg3 = args[3].(string)
429+ }
430+ run(
431+ arg0,
432+ arg1,
433+ arg2,
434+ arg3,
435+ )
436+ })
437+ return _c
438+}
439+
440+func (_c *RunService_ListChecks_Call) Return(checksResult ports.ChecksResult, err error) *RunService_ListChecks_Call {
441+ _c.Call.Return(checksResult, err)
442+ return _c
443+}
444+
445+func (_c *RunService_ListChecks_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, sha string) (ports.ChecksResult, error)) *RunService_ListChecks_Call {
446+ _c.Call.Return(run)
447+ return _c
448+}
449+
450+// ListRunners provides a mock function for the type RunService
451+func (_mock *RunService) ListRunners(ctx context.Context, p domain.Principal, size int, token string) (ports.RunnerPage, error) {
452+ ret := _mock.Called(ctx, p, size, token)
453+
454+ if len(ret) == 0 {
455+ panic("no return value specified for ListRunners")
456+ }
457+
458+ var r0 ports.RunnerPage
459+ var r1 error
460+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) (ports.RunnerPage, error)); ok {
461+ return returnFunc(ctx, p, size, token)
462+ }
463+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, int, string) ports.RunnerPage); ok {
464+ r0 = returnFunc(ctx, p, size, token)
465+ } else {
466+ r0 = ret.Get(0).(ports.RunnerPage)
467+ }
468+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, int, string) error); ok {
469+ r1 = returnFunc(ctx, p, size, token)
470+ } else {
471+ r1 = ret.Error(1)
472+ }
473+ return r0, r1
474+}
475+
476+// RunService_ListRunners_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRunners'
477+type RunService_ListRunners_Call struct {
478+ *mock.Call
479+}
480+
481+// ListRunners is a helper method to define mock.On call
482+// - ctx context.Context
483+// - p domain.Principal
484+// - size int
485+// - token string
486+func (_e *RunService_Expecter) ListRunners(ctx any, p any, size any, token any) *RunService_ListRunners_Call {
487+ return &RunService_ListRunners_Call{Call: _e.mock.On("ListRunners", ctx, p, size, token)}
488+}
489+
490+func (_c *RunService_ListRunners_Call) Run(run func(ctx context.Context, p domain.Principal, size int, token string)) *RunService_ListRunners_Call {
491+ _c.Call.Run(func(args mock.Arguments) {
492+ var arg0 context.Context
493+ if args[0] != nil {
494+ arg0 = args[0].(context.Context)
495+ }
496+ var arg1 domain.Principal
497+ if args[1] != nil {
498+ arg1 = args[1].(domain.Principal)
499+ }
500+ var arg2 int
501+ if args[2] != nil {
502+ arg2 = args[2].(int)
503+ }
504+ var arg3 string
505+ if args[3] != nil {
506+ arg3 = args[3].(string)
507+ }
508+ run(
509+ arg0,
510+ arg1,
511+ arg2,
512+ arg3,
513+ )
514+ })
515+ return _c
516+}
517+
518+func (_c *RunService_ListRunners_Call) Return(runnerPage ports.RunnerPage, err error) *RunService_ListRunners_Call {
519+ _c.Call.Return(runnerPage, err)
520+ return _c
521+}
522+
523+func (_c *RunService_ListRunners_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, size int, token string) (ports.RunnerPage, error)) *RunService_ListRunners_Call {
524+ _c.Call.Return(run)
525+ return _c
526+}
527+
528+// ListRuns provides a mock function for the type RunService
529+func (_mock *RunService) ListRuns(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRunsParams) (ports.RunPage, error) {
530+ ret := _mock.Called(ctx, p, repo, params)
531+
532+ if len(ret) == 0 {
533+ panic("no return value specified for ListRuns")
534+ }
535+
536+ var r0 ports.RunPage
537+ var r1 error
538+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRunsParams) (ports.RunPage, error)); ok {
539+ return returnFunc(ctx, p, repo, params)
540+ }
541+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRunsParams) ports.RunPage); ok {
542+ r0 = returnFunc(ctx, p, repo, params)
543+ } else {
544+ r0 = ret.Get(0).(ports.RunPage)
545+ }
546+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, ports.ListRunsParams) error); ok {
547+ r1 = returnFunc(ctx, p, repo, params)
548+ } else {
549+ r1 = ret.Error(1)
550+ }
551+ return r0, r1
552+}
553+
554+// RunService_ListRuns_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListRuns'
555+type RunService_ListRuns_Call struct {
556+ *mock.Call
557+}
558+
559+// ListRuns is a helper method to define mock.On call
560+// - ctx context.Context
561+// - p domain.Principal
562+// - repo domain.RepoName
563+// - params ports.ListRunsParams
564+func (_e *RunService_Expecter) ListRuns(ctx any, p any, repo any, params any) *RunService_ListRuns_Call {
565+ return &RunService_ListRuns_Call{Call: _e.mock.On("ListRuns", ctx, p, repo, params)}
566+}
567+
568+func (_c *RunService_ListRuns_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRunsParams)) *RunService_ListRuns_Call {
569+ _c.Call.Run(func(args mock.Arguments) {
570+ var arg0 context.Context
571+ if args[0] != nil {
572+ arg0 = args[0].(context.Context)
573+ }
574+ var arg1 domain.Principal
575+ if args[1] != nil {
576+ arg1 = args[1].(domain.Principal)
577+ }
578+ var arg2 domain.RepoName
579+ if args[2] != nil {
580+ arg2 = args[2].(domain.RepoName)
581+ }
582+ var arg3 ports.ListRunsParams
583+ if args[3] != nil {
584+ arg3 = args[3].(ports.ListRunsParams)
585+ }
586+ run(
587+ arg0,
588+ arg1,
589+ arg2,
590+ arg3,
591+ )
592+ })
593+ return _c
594+}
595+
596+func (_c *RunService_ListRuns_Call) Return(runPage ports.RunPage, err error) *RunService_ListRuns_Call {
597+ _c.Call.Return(runPage, err)
598+ return _c
599+}
600+
601+func (_c *RunService_ListRuns_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRunsParams) (ports.RunPage, error)) *RunService_ListRuns_Call {
602+ _c.Call.Return(run)
603+ return _c
604+}
605+
606+// RemoveRunner provides a mock function for the type RunService
607+func (_mock *RunService) RemoveRunner(ctx context.Context, p domain.Principal, id string) error {
608+ ret := _mock.Called(ctx, p, id)
609+
610+ if len(ret) == 0 {
611+ panic("no return value specified for RemoveRunner")
612+ }
613+
614+ var r0 error
615+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, string) error); ok {
616+ r0 = returnFunc(ctx, p, id)
617+ } else {
618+ r0 = ret.Error(0)
619+ }
620+ return r0
621+}
622+
623+// RunService_RemoveRunner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRunner'
624+type RunService_RemoveRunner_Call struct {
625+ *mock.Call
626+}
627+
628+// RemoveRunner is a helper method to define mock.On call
629+// - ctx context.Context
630+// - p domain.Principal
631+// - id string
632+func (_e *RunService_Expecter) RemoveRunner(ctx any, p any, id any) *RunService_RemoveRunner_Call {
633+ return &RunService_RemoveRunner_Call{Call: _e.mock.On("RemoveRunner", ctx, p, id)}
634+}
635+
636+func (_c *RunService_RemoveRunner_Call) Run(run func(ctx context.Context, p domain.Principal, id string)) *RunService_RemoveRunner_Call {
637+ _c.Call.Run(func(args mock.Arguments) {
638+ var arg0 context.Context
639+ if args[0] != nil {
640+ arg0 = args[0].(context.Context)
641+ }
642+ var arg1 domain.Principal
643+ if args[1] != nil {
644+ arg1 = args[1].(domain.Principal)
645+ }
646+ var arg2 string
647+ if args[2] != nil {
648+ arg2 = args[2].(string)
649+ }
650+ run(
651+ arg0,
652+ arg1,
653+ arg2,
654+ )
655+ })
656+ return _c
657+}
658+
659+func (_c *RunService_RemoveRunner_Call) Return(err error) *RunService_RemoveRunner_Call {
660+ _c.Call.Return(err)
661+ return _c
662+}
663+
664+func (_c *RunService_RemoveRunner_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, id string) error) *RunService_RemoveRunner_Call {
665+ _c.Call.Return(run)
666+ return _c
667+}
668+
669+// RerunRun provides a mock function for the type RunService
670+func (_mock *RunService) RerunRun(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error) {
671+ ret := _mock.Called(ctx, p, repo, runID, failedOnly)
672+
673+ if len(ret) == 0 {
674+ panic("no return value specified for RerunRun")
675+ }
676+
677+ var r0 domain.CIRun
678+ var r1 error
679+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) (domain.CIRun, error)); ok {
680+ return returnFunc(ctx, p, repo, runID, failedOnly)
681+ }
682+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) domain.CIRun); ok {
683+ r0 = returnFunc(ctx, p, repo, runID, failedOnly)
684+ } else {
685+ r0 = ret.Get(0).(domain.CIRun)
686+ }
687+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, domain.RepoName, int64, bool) error); ok {
688+ r1 = returnFunc(ctx, p, repo, runID, failedOnly)
689+ } else {
690+ r1 = ret.Error(1)
691+ }
692+ return r0, r1
693+}
694+
695+// RunService_RerunRun_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RerunRun'
696+type RunService_RerunRun_Call struct {
697+ *mock.Call
698+}
699+
700+// RerunRun is a helper method to define mock.On call
701+// - ctx context.Context
702+// - p domain.Principal
703+// - repo domain.RepoName
704+// - runID int64
705+// - failedOnly bool
706+func (_e *RunService_Expecter) RerunRun(ctx any, p any, repo any, runID any, failedOnly any) *RunService_RerunRun_Call {
707+ return &RunService_RerunRun_Call{Call: _e.mock.On("RerunRun", ctx, p, repo, runID, failedOnly)}
708+}
709+
710+func (_c *RunService_RerunRun_Call) Run(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool)) *RunService_RerunRun_Call {
711+ _c.Call.Run(func(args mock.Arguments) {
712+ var arg0 context.Context
713+ if args[0] != nil {
714+ arg0 = args[0].(context.Context)
715+ }
716+ var arg1 domain.Principal
717+ if args[1] != nil {
718+ arg1 = args[1].(domain.Principal)
719+ }
720+ var arg2 domain.RepoName
721+ if args[2] != nil {
722+ arg2 = args[2].(domain.RepoName)
723+ }
724+ var arg3 int64
725+ if args[3] != nil {
726+ arg3 = args[3].(int64)
727+ }
728+ var arg4 bool
729+ if args[4] != nil {
730+ arg4 = args[4].(bool)
731+ }
732+ run(
733+ arg0,
734+ arg1,
735+ arg2,
736+ arg3,
737+ arg4,
738+ )
739+ })
740+ return _c
741+}
742+
743+func (_c *RunService_RerunRun_Call) Return(cIRun domain.CIRun, err error) *RunService_RerunRun_Call {
744+ _c.Call.Return(cIRun, err)
745+ return _c
746+}
747+
748+func (_c *RunService_RerunRun_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, repo domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error)) *RunService_RerunRun_Call {
749+ _c.Call.Return(run)
750+ return _c
751+}
A internal/ports/mocks/RunnerService.go +562 -0
@@ -0,0 +1,562 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ "bnema.dev/ermite/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewRunnerService creates a new instance of RunnerService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewRunnerService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *RunnerService {
21+ if helper, ok := t.(interface{ Helper() }); ok {
22+ helper.Helper()
23+ }
24+
25+ mock := &RunnerService{}
26+ mock.Mock.Test(t)
27+
28+ t.Cleanup(func() {
29+ if helper, ok := t.(interface{ Helper() }); ok {
30+ helper.Helper()
31+ }
32+ mock.AssertExpectations(t)
33+ })
34+
35+ return mock
36+}
37+
38+// RunnerService is an autogenerated mock type for the RunnerService type
39+type RunnerService struct {
40+ mock.Mock
41+}
42+
43+type RunnerService_Expecter struct {
44+ mock *mock.Mock
45+}
46+
47+func (_m *RunnerService) EXPECT() *RunnerService_Expecter {
48+ return &RunnerService_Expecter{mock: &_m.Mock}
49+}
50+
51+// AppendLog provides a mock function for the type RunnerService
52+func (_mock *RunnerService) AppendLog(ctx context.Context, secret string, jobID string, step int32, offset int64, lines []domain.CILogLine) (int64, error) {
53+ ret := _mock.Called(ctx, secret, jobID, step, offset, lines)
54+
55+ if len(ret) == 0 {
56+ panic("no return value specified for AppendLog")
57+ }
58+
59+ var r0 int64
60+ var r1 error
61+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int32, int64, []domain.CILogLine) (int64, error)); ok {
62+ return returnFunc(ctx, secret, jobID, step, offset, lines)
63+ }
64+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int32, int64, []domain.CILogLine) int64); ok {
65+ r0 = returnFunc(ctx, secret, jobID, step, offset, lines)
66+ } else {
67+ r0 = ret.Get(0).(int64)
68+ }
69+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, int32, int64, []domain.CILogLine) error); ok {
70+ r1 = returnFunc(ctx, secret, jobID, step, offset, lines)
71+ } else {
72+ r1 = ret.Error(1)
73+ }
74+ return r0, r1
75+}
76+
77+// RunnerService_AppendLog_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AppendLog'
78+type RunnerService_AppendLog_Call struct {
79+ *mock.Call
80+}
81+
82+// AppendLog is a helper method to define mock.On call
83+// - ctx context.Context
84+// - secret string
85+// - jobID string
86+// - step int32
87+// - offset int64
88+// - lines []domain.CILogLine
89+func (_e *RunnerService_Expecter) AppendLog(ctx any, secret any, jobID any, step any, offset any, lines any) *RunnerService_AppendLog_Call {
90+ return &RunnerService_AppendLog_Call{Call: _e.mock.On("AppendLog", ctx, secret, jobID, step, offset, lines)}
91+}
92+
93+func (_c *RunnerService_AppendLog_Call) Run(run func(ctx context.Context, secret string, jobID string, step int32, offset int64, lines []domain.CILogLine)) *RunnerService_AppendLog_Call {
94+ _c.Call.Run(func(args mock.Arguments) {
95+ var arg0 context.Context
96+ if args[0] != nil {
97+ arg0 = args[0].(context.Context)
98+ }
99+ var arg1 string
100+ if args[1] != nil {
101+ arg1 = args[1].(string)
102+ }
103+ var arg2 string
104+ if args[2] != nil {
105+ arg2 = args[2].(string)
106+ }
107+ var arg3 int32
108+ if args[3] != nil {
109+ arg3 = args[3].(int32)
110+ }
111+ var arg4 int64
112+ if args[4] != nil {
113+ arg4 = args[4].(int64)
114+ }
115+ var arg5 []domain.CILogLine
116+ if args[5] != nil {
117+ arg5 = args[5].([]domain.CILogLine)
118+ }
119+ run(
120+ arg0,
121+ arg1,
122+ arg2,
123+ arg3,
124+ arg4,
125+ arg5,
126+ )
127+ })
128+ return _c
129+}
130+
131+func (_c *RunnerService_AppendLog_Call) Return(next int64, err error) *RunnerService_AppendLog_Call {
132+ _c.Call.Return(next, err)
133+ return _c
134+}
135+
136+func (_c *RunnerService_AppendLog_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, step int32, offset int64, lines []domain.CILogLine) (int64, error)) *RunnerService_AppendLog_Call {
137+ _c.Call.Return(run)
138+ return _c
139+}
140+
141+// CompleteJob provides a mock function for the type RunnerService
142+func (_mock *RunnerService) CompleteJob(ctx context.Context, secret string, params ports.CompleteJobParams) (ports.CompleteJobResult, error) {
143+ ret := _mock.Called(ctx, secret, params)
144+
145+ if len(ret) == 0 {
146+ panic("no return value specified for CompleteJob")
147+ }
148+
149+ var r0 ports.CompleteJobResult
150+ var r1 error
151+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.CompleteJobParams) (ports.CompleteJobResult, error)); ok {
152+ return returnFunc(ctx, secret, params)
153+ }
154+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.CompleteJobParams) ports.CompleteJobResult); ok {
155+ r0 = returnFunc(ctx, secret, params)
156+ } else {
157+ r0 = ret.Get(0).(ports.CompleteJobResult)
158+ }
159+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, ports.CompleteJobParams) error); ok {
160+ r1 = returnFunc(ctx, secret, params)
161+ } else {
162+ r1 = ret.Error(1)
163+ }
164+ return r0, r1
165+}
166+
167+// RunnerService_CompleteJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CompleteJob'
168+type RunnerService_CompleteJob_Call struct {
169+ *mock.Call
170+}
171+
172+// CompleteJob is a helper method to define mock.On call
173+// - ctx context.Context
174+// - secret string
175+// - params ports.CompleteJobParams
176+func (_e *RunnerService_Expecter) CompleteJob(ctx any, secret any, params any) *RunnerService_CompleteJob_Call {
177+ return &RunnerService_CompleteJob_Call{Call: _e.mock.On("CompleteJob", ctx, secret, params)}
178+}
179+
180+func (_c *RunnerService_CompleteJob_Call) Run(run func(ctx context.Context, secret string, params ports.CompleteJobParams)) *RunnerService_CompleteJob_Call {
181+ _c.Call.Run(func(args mock.Arguments) {
182+ var arg0 context.Context
183+ if args[0] != nil {
184+ arg0 = args[0].(context.Context)
185+ }
186+ var arg1 string
187+ if args[1] != nil {
188+ arg1 = args[1].(string)
189+ }
190+ var arg2 ports.CompleteJobParams
191+ if args[2] != nil {
192+ arg2 = args[2].(ports.CompleteJobParams)
193+ }
194+ run(
195+ arg0,
196+ arg1,
197+ arg2,
198+ )
199+ })
200+ return _c
201+}
202+
203+func (_c *RunnerService_CompleteJob_Call) Return(completeJobResult ports.CompleteJobResult, err error) *RunnerService_CompleteJob_Call {
204+ _c.Call.Return(completeJobResult, err)
205+ return _c
206+}
207+
208+func (_c *RunnerService_CompleteJob_Call) RunAndReturn(run func(ctx context.Context, secret string, params ports.CompleteJobParams) (ports.CompleteJobResult, error)) *RunnerService_CompleteJob_Call {
209+ _c.Call.Return(run)
210+ return _c
211+}
212+
213+// FetchJob provides a mock function for the type RunnerService
214+func (_mock *RunnerService) FetchJob(ctx context.Context, secret string, params ports.FetchJobParams) (ports.FetchJobResult, error) {
215+ ret := _mock.Called(ctx, secret, params)
216+
217+ if len(ret) == 0 {
218+ panic("no return value specified for FetchJob")
219+ }
220+
221+ var r0 ports.FetchJobResult
222+ var r1 error
223+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.FetchJobParams) (ports.FetchJobResult, error)); ok {
224+ return returnFunc(ctx, secret, params)
225+ }
226+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.FetchJobParams) ports.FetchJobResult); ok {
227+ r0 = returnFunc(ctx, secret, params)
228+ } else {
229+ r0 = ret.Get(0).(ports.FetchJobResult)
230+ }
231+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, ports.FetchJobParams) error); ok {
232+ r1 = returnFunc(ctx, secret, params)
233+ } else {
234+ r1 = ret.Error(1)
235+ }
236+ return r0, r1
237+}
238+
239+// RunnerService_FetchJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FetchJob'
240+type RunnerService_FetchJob_Call struct {
241+ *mock.Call
242+}
243+
244+// FetchJob is a helper method to define mock.On call
245+// - ctx context.Context
246+// - secret string
247+// - params ports.FetchJobParams
248+func (_e *RunnerService_Expecter) FetchJob(ctx any, secret any, params any) *RunnerService_FetchJob_Call {
249+ return &RunnerService_FetchJob_Call{Call: _e.mock.On("FetchJob", ctx, secret, params)}
250+}
251+
252+func (_c *RunnerService_FetchJob_Call) Run(run func(ctx context.Context, secret string, params ports.FetchJobParams)) *RunnerService_FetchJob_Call {
253+ _c.Call.Run(func(args mock.Arguments) {
254+ var arg0 context.Context
255+ if args[0] != nil {
256+ arg0 = args[0].(context.Context)
257+ }
258+ var arg1 string
259+ if args[1] != nil {
260+ arg1 = args[1].(string)
261+ }
262+ var arg2 ports.FetchJobParams
263+ if args[2] != nil {
264+ arg2 = args[2].(ports.FetchJobParams)
265+ }
266+ run(
267+ arg0,
268+ arg1,
269+ arg2,
270+ )
271+ })
272+ return _c
273+}
274+
275+func (_c *RunnerService_FetchJob_Call) Return(fetchJobResult ports.FetchJobResult, err error) *RunnerService_FetchJob_Call {
276+ _c.Call.Return(fetchJobResult, err)
277+ return _c
278+}
279+
280+func (_c *RunnerService_FetchJob_Call) RunAndReturn(run func(ctx context.Context, secret string, params ports.FetchJobParams) (ports.FetchJobResult, error)) *RunnerService_FetchJob_Call {
281+ _c.Call.Return(run)
282+ return _c
283+}
284+
285+// Heartbeat provides a mock function for the type RunnerService
286+func (_mock *RunnerService) Heartbeat(ctx context.Context, secret string, jobID string) error {
287+ ret := _mock.Called(ctx, secret, jobID)
288+
289+ if len(ret) == 0 {
290+ panic("no return value specified for Heartbeat")
291+ }
292+
293+ var r0 error
294+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
295+ r0 = returnFunc(ctx, secret, jobID)
296+ } else {
297+ r0 = ret.Error(0)
298+ }
299+ return r0
300+}
301+
302+// RunnerService_Heartbeat_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Heartbeat'
303+type RunnerService_Heartbeat_Call struct {
304+ *mock.Call
305+}
306+
307+// Heartbeat is a helper method to define mock.On call
308+// - ctx context.Context
309+// - secret string
310+// - jobID string
311+func (_e *RunnerService_Expecter) Heartbeat(ctx any, secret any, jobID any) *RunnerService_Heartbeat_Call {
312+ return &RunnerService_Heartbeat_Call{Call: _e.mock.On("Heartbeat", ctx, secret, jobID)}
313+}
314+
315+func (_c *RunnerService_Heartbeat_Call) Run(run func(ctx context.Context, secret string, jobID string)) *RunnerService_Heartbeat_Call {
316+ _c.Call.Run(func(args mock.Arguments) {
317+ var arg0 context.Context
318+ if args[0] != nil {
319+ arg0 = args[0].(context.Context)
320+ }
321+ var arg1 string
322+ if args[1] != nil {
323+ arg1 = args[1].(string)
324+ }
325+ var arg2 string
326+ if args[2] != nil {
327+ arg2 = args[2].(string)
328+ }
329+ run(
330+ arg0,
331+ arg1,
332+ arg2,
333+ )
334+ })
335+ return _c
336+}
337+
338+func (_c *RunnerService_Heartbeat_Call) Return(err error) *RunnerService_Heartbeat_Call {
339+ _c.Call.Return(err)
340+ return _c
341+}
342+
343+func (_c *RunnerService_Heartbeat_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string) error) *RunnerService_Heartbeat_Call {
344+ _c.Call.Return(run)
345+ return _c
346+}
347+
348+// Register provides a mock function for the type RunnerService
349+func (_mock *RunnerService) Register(ctx context.Context, p domain.Principal, params ports.RegisterRunnerParams) (ports.RegisteredRunner, error) {
350+ ret := _mock.Called(ctx, p, params)
351+
352+ if len(ret) == 0 {
353+ panic("no return value specified for Register")
354+ }
355+
356+ var r0 ports.RegisteredRunner
357+ var r1 error
358+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.RegisterRunnerParams) (ports.RegisteredRunner, error)); ok {
359+ return returnFunc(ctx, p, params)
360+ }
361+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.Principal, ports.RegisterRunnerParams) ports.RegisteredRunner); ok {
362+ r0 = returnFunc(ctx, p, params)
363+ } else {
364+ r0 = ret.Get(0).(ports.RegisteredRunner)
365+ }
366+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.Principal, ports.RegisterRunnerParams) error); ok {
367+ r1 = returnFunc(ctx, p, params)
368+ } else {
369+ r1 = ret.Error(1)
370+ }
371+ return r0, r1
372+}
373+
374+// RunnerService_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register'
375+type RunnerService_Register_Call struct {
376+ *mock.Call
377+}
378+
379+// Register is a helper method to define mock.On call
380+// - ctx context.Context
381+// - p domain.Principal
382+// - params ports.RegisterRunnerParams
383+func (_e *RunnerService_Expecter) Register(ctx any, p any, params any) *RunnerService_Register_Call {
384+ return &RunnerService_Register_Call{Call: _e.mock.On("Register", ctx, p, params)}
385+}
386+
387+func (_c *RunnerService_Register_Call) Run(run func(ctx context.Context, p domain.Principal, params ports.RegisterRunnerParams)) *RunnerService_Register_Call {
388+ _c.Call.Run(func(args mock.Arguments) {
389+ var arg0 context.Context
390+ if args[0] != nil {
391+ arg0 = args[0].(context.Context)
392+ }
393+ var arg1 domain.Principal
394+ if args[1] != nil {
395+ arg1 = args[1].(domain.Principal)
396+ }
397+ var arg2 ports.RegisterRunnerParams
398+ if args[2] != nil {
399+ arg2 = args[2].(ports.RegisterRunnerParams)
400+ }
401+ run(
402+ arg0,
403+ arg1,
404+ arg2,
405+ )
406+ })
407+ return _c
408+}
409+
410+func (_c *RunnerService_Register_Call) Return(registeredRunner ports.RegisteredRunner, err error) *RunnerService_Register_Call {
411+ _c.Call.Return(registeredRunner, err)
412+ return _c
413+}
414+
415+func (_c *RunnerService_Register_Call) RunAndReturn(run func(ctx context.Context, p domain.Principal, params ports.RegisterRunnerParams) (ports.RegisteredRunner, error)) *RunnerService_Register_Call {
416+ _c.Call.Return(run)
417+ return _c
418+}
419+
420+// UpdateJob provides a mock function for the type RunnerService
421+func (_mock *RunnerService) UpdateJob(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error {
422+ ret := _mock.Called(ctx, secret, jobID, steps)
423+
424+ if len(ret) == 0 {
425+ panic("no return value specified for UpdateJob")
426+ }
427+
428+ var r0 error
429+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []ports.StepUpdate) error); ok {
430+ r0 = returnFunc(ctx, secret, jobID, steps)
431+ } else {
432+ r0 = ret.Error(0)
433+ }
434+ return r0
435+}
436+
437+// RunnerService_UpdateJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateJob'
438+type RunnerService_UpdateJob_Call struct {
439+ *mock.Call
440+}
441+
442+// UpdateJob is a helper method to define mock.On call
443+// - ctx context.Context
444+// - secret string
445+// - jobID string
446+// - steps []ports.StepUpdate
447+func (_e *RunnerService_Expecter) UpdateJob(ctx any, secret any, jobID any, steps any) *RunnerService_UpdateJob_Call {
448+ return &RunnerService_UpdateJob_Call{Call: _e.mock.On("UpdateJob", ctx, secret, jobID, steps)}
449+}
450+
451+func (_c *RunnerService_UpdateJob_Call) Run(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate)) *RunnerService_UpdateJob_Call {
452+ _c.Call.Run(func(args mock.Arguments) {
453+ var arg0 context.Context
454+ if args[0] != nil {
455+ arg0 = args[0].(context.Context)
456+ }
457+ var arg1 string
458+ if args[1] != nil {
459+ arg1 = args[1].(string)
460+ }
461+ var arg2 string
462+ if args[2] != nil {
463+ arg2 = args[2].(string)
464+ }
465+ var arg3 []ports.StepUpdate
466+ if args[3] != nil {
467+ arg3 = args[3].([]ports.StepUpdate)
468+ }
469+ run(
470+ arg0,
471+ arg1,
472+ arg2,
473+ arg3,
474+ )
475+ })
476+ return _c
477+}
478+
479+func (_c *RunnerService_UpdateJob_Call) Return(err error) *RunnerService_UpdateJob_Call {
480+ _c.Call.Return(err)
481+ return _c
482+}
483+
484+func (_c *RunnerService_UpdateJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, steps []ports.StepUpdate) error) *RunnerService_UpdateJob_Call {
485+ _c.Call.Return(run)
486+ return _c
487+}
488+
489+// WatchJob provides a mock function for the type RunnerService
490+func (_mock *RunnerService) WatchJob(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error) error {
491+ ret := _mock.Called(ctx, secret, jobID, ready, send)
492+
493+ if len(ret) == 0 {
494+ panic("no return value specified for WatchJob")
495+ }
496+
497+ var r0 error
498+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, func(), func(ports.JobSignal) error) error); ok {
499+ r0 = returnFunc(ctx, secret, jobID, ready, send)
500+ } else {
501+ r0 = ret.Error(0)
502+ }
503+ return r0
504+}
505+
506+// RunnerService_WatchJob_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WatchJob'
507+type RunnerService_WatchJob_Call struct {
508+ *mock.Call
509+}
510+
511+// WatchJob is a helper method to define mock.On call
512+// - ctx context.Context
513+// - secret string
514+// - jobID string
515+// - ready func()
516+// - send func(ports.JobSignal) error
517+func (_e *RunnerService_Expecter) WatchJob(ctx any, secret any, jobID any, ready any, send any) *RunnerService_WatchJob_Call {
518+ return &RunnerService_WatchJob_Call{Call: _e.mock.On("WatchJob", ctx, secret, jobID, ready, send)}
519+}
520+
521+func (_c *RunnerService_WatchJob_Call) Run(run func(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error)) *RunnerService_WatchJob_Call {
522+ _c.Call.Run(func(args mock.Arguments) {
523+ var arg0 context.Context
524+ if args[0] != nil {
525+ arg0 = args[0].(context.Context)
526+ }
527+ var arg1 string
528+ if args[1] != nil {
529+ arg1 = args[1].(string)
530+ }
531+ var arg2 string
532+ if args[2] != nil {
533+ arg2 = args[2].(string)
534+ }
535+ var arg3 func()
536+ if args[3] != nil {
537+ arg3 = args[3].(func())
538+ }
539+ var arg4 func(ports.JobSignal) error
540+ if args[4] != nil {
541+ arg4 = args[4].(func(ports.JobSignal) error)
542+ }
543+ run(
544+ arg0,
545+ arg1,
546+ arg2,
547+ arg3,
548+ arg4,
549+ )
550+ })
551+ return _c
552+}
553+
554+func (_c *RunnerService_WatchJob_Call) Return(err error) *RunnerService_WatchJob_Call {
555+ _c.Call.Return(err)
556+ return _c
557+}
558+
559+func (_c *RunnerService_WatchJob_Call) RunAndReturn(run func(ctx context.Context, secret string, jobID string, ready func(), send func(ports.JobSignal) error) error) *RunnerService_WatchJob_Call {
560+ _c.Call.Return(run)
561+ return _c
562+}
A internal/ports/mocks/WorkflowTrigger.go +106 -0
@@ -0,0 +1,106 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "bnema.dev/ermite/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewWorkflowTrigger creates a new instance of WorkflowTrigger. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewWorkflowTrigger(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *WorkflowTrigger {
20+ if helper, ok := t.(interface{ Helper() }); ok {
21+ helper.Helper()
22+ }
23+
24+ mock := &WorkflowTrigger{}
25+ mock.Mock.Test(t)
26+
27+ t.Cleanup(func() {
28+ if helper, ok := t.(interface{ Helper() }); ok {
29+ helper.Helper()
30+ }
31+ mock.AssertExpectations(t)
32+ })
33+
34+ return mock
35+}
36+
37+// WorkflowTrigger is an autogenerated mock type for the WorkflowTrigger type
38+type WorkflowTrigger struct {
39+ mock.Mock
40+}
41+
42+type WorkflowTrigger_Expecter struct {
43+ mock *mock.Mock
44+}
45+
46+func (_m *WorkflowTrigger) EXPECT() *WorkflowTrigger_Expecter {
47+ return &WorkflowTrigger_Expecter{mock: &_m.Mock}
48+}
49+
50+// OnPush provides a mock function for the type WorkflowTrigger
51+func (_mock *WorkflowTrigger) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
52+ _mock.Called(ctx, repo, updates, pusher)
53+ return
54+}
55+
56+// WorkflowTrigger_OnPush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnPush'
57+type WorkflowTrigger_OnPush_Call struct {
58+ *mock.Call
59+}
60+
61+// OnPush is a helper method to define mock.On call
62+// - ctx context.Context
63+// - repo domain.RepoName
64+// - updates []domain.RefUpdate
65+// - pusher domain.Principal
66+func (_e *WorkflowTrigger_Expecter) OnPush(ctx any, repo any, updates any, pusher any) *WorkflowTrigger_OnPush_Call {
67+ return &WorkflowTrigger_OnPush_Call{Call: _e.mock.On("OnPush", ctx, repo, updates, pusher)}
68+}
69+
70+func (_c *WorkflowTrigger_OnPush_Call) Run(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal)) *WorkflowTrigger_OnPush_Call {
71+ _c.Call.Run(func(args mock.Arguments) {
72+ var arg0 context.Context
73+ if args[0] != nil {
74+ arg0 = args[0].(context.Context)
75+ }
76+ var arg1 domain.RepoName
77+ if args[1] != nil {
78+ arg1 = args[1].(domain.RepoName)
79+ }
80+ var arg2 []domain.RefUpdate
81+ if args[2] != nil {
82+ arg2 = args[2].([]domain.RefUpdate)
83+ }
84+ var arg3 domain.Principal
85+ if args[3] != nil {
86+ arg3 = args[3].(domain.Principal)
87+ }
88+ run(
89+ arg0,
90+ arg1,
91+ arg2,
92+ arg3,
93+ )
94+ })
95+ return _c
96+}
97+
98+func (_c *WorkflowTrigger_OnPush_Call) Return() *WorkflowTrigger_OnPush_Call {
99+ _c.Call.Return()
100+ return _c
101+}
102+
103+func (_c *WorkflowTrigger_OnPush_Call) RunAndReturn(run func(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal)) *WorkflowTrigger_OnPush_Call {
104+ _c.Run(run)
105+ return _c
106+}
A internal/usecase/ci.go +530 -0
@@ -0,0 +1,530 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "crypto/rand"
8+ "encoding/base64"
9+ "encoding/hex"
10+ "fmt"
11+ "slices"
12+ "strings"
13+ "sync"
14+ "time"
15+
16+ "github.com/bnema/zerowrap"
17+
18+ "bnema.dev/ermite/internal/domain"
19+ "bnema.dev/ermite/internal/ports"
20+)
21+
22+// Defaults of the CI engine.
23+const (
24+ // DefaultCIPollTimeout is how long FetchJob holds a call: the contract's 50 s.
25+ DefaultCIPollTimeout = 50 * time.Second
26+ // DefaultCITick is how often the watchdog looks at the jobs in flight.
27+ DefaultCITick = time.Second
28+ // DefaultCIKeepalive is how often WatchJob sends a keepalive.
29+ DefaultCIKeepalive = 30 * time.Second
30+ // DefaultCITriggerQueue is the size of the queue of pushes to plan.
31+ DefaultCITriggerQueue = 256
32+ // MaxCIChecks is the most checks ListChecks returns.
33+ MaxCIChecks = 1000
34+ // MaxCILogStreams and MaxCILogStreamsPerClient bound the concurrent log
35+ // streams (GetJobLog).
36+ MaxCILogStreams = 64
37+ MaxCILogStreamsPerClient = 8
38+ // maxCIWorkflowBytes is the largest workflow file (the parser's limit).
39+ maxCIWorkflowBytes = 1 << 20
40+ // maxCIWorkflowFiles bounds the workflow files read per push.
41+ maxCIWorkflowFiles = 256
42+)
43+
44+// CIConfig holds what the CI use cases need. Store, Logs, Meta, Repos, Reader,
45+// Git, Diff, Bus, Clock, Owner and PublicURL are required (the config supplies
46+// the defaults of Owner and Retention).
47+type CIConfig struct {
48+ Store ports.CIStore
49+ Logs ports.CILogStore
50+ Meta ports.RepoMetaStore
51+ Repos ports.GitRepos
52+ Reader ports.RepoReader
53+ Git ports.MergeGit
54+ Diff ports.CIGit
55+ Bus ports.EventBus
56+ Clock ports.Clock
57+ // Auth re-validates the token of a log stream; nil skips it.
58+ Auth ports.Revalidator
59+ // Owner is the owner login of the github context.
60+ Owner string
61+ // PublicURL is the base URL of the instance (github.server_url).
62+ PublicURL string
63+ // Retention is how long runs, jobs, checks and logs are kept.
64+ Retention time.Duration
65+
66+ // The durations below default to the contract's; tests shorten them.
67+ PollTimeout time.Duration
68+ Tick time.Duration
69+ Keepalive time.Duration
70+ AckTimeout time.Duration
71+ HeartbeatDeadline time.Duration
72+ CancelGrace time.Duration
73+ TimeoutSlack time.Duration
74+ QueueTimeout time.Duration
75+ TriggerQueue int
76+}
77+
78+func (c CIConfig) withDefaults() CIConfig {
79+ def := func(d *time.Duration, v time.Duration) {
80+ if *d <= 0 {
81+ *d = v
82+ }
83+ }
84+ def(&c.PollTimeout, DefaultCIPollTimeout)
85+ def(&c.Tick, DefaultCITick)
86+ def(&c.Keepalive, DefaultCIKeepalive)
87+ def(&c.AckTimeout, domain.CIAckTimeout)
88+ def(&c.HeartbeatDeadline, domain.CIHeartbeatDeadline)
89+ def(&c.CancelGrace, domain.CICancelGrace)
90+ def(&c.TimeoutSlack, domain.CITimeoutSlack)
91+ def(&c.QueueTimeout, domain.CIQueueTimeout)
92+ def(&c.Retention, domain.CIRetentionDays*24*time.Hour)
93+ if c.TriggerQueue <= 0 {
94+ c.TriggerQueue = DefaultCITriggerQueue
95+ }
96+ return c
97+}
98+
99+// CI is the CI use cases: the workflow trigger, the planner, the assignment of
100+// jobs to runners, the runner protocol and the owner side (ADR 0025).
101+//
102+// Every change of a run goes through its lock (withRun): the records of a run
103+// are read, changed and written again by one goroutine at a time, so that two
104+// calls (a runner's update and an owner's cancel) never lose one another's
105+// write. Assignment takes the assignment lock before the lock of the run.
106+type CI struct {
107+ cfg CIConfig
108+ boot time.Time
109+ queue chan ciTrigger
110+
111+ stop context.CancelFunc
112+ ctx context.Context
113+ wg sync.WaitGroup
114+
115+ locks sync.Map // run id -> *sync.Mutex
116+ assign sync.Mutex
117+ jobs notifier // woken when a job changes: key is the job reference
118+ pending notifier // woken when a job may be ready or a runner was removed
119+ flows *wfCache
120+
121+ mu sync.Mutex
122+ heartbeats map[string]time.Time // job reference -> last heartbeat
123+ seen map[string]time.Time // runner id -> last seen
124+ polls map[string]pollRecord
125+ streams int
126+ perClient map[string]int
127+ closed bool
128+}
129+
130+var (
131+ _ ports.RunnerService = (*CI)(nil)
132+ _ ports.RunService = (*CI)(nil)
133+ _ ports.WorkflowTrigger = (*CI)(nil)
134+ _ ports.CIChecksReader = (*CI)(nil)
135+)
136+
137+// pollRecord remembers the assignment a poll made, for the retry of a lost
138+// response (FetchJobRequest.poll_id). The token is kept in memory only.
139+type pollRecord struct {
140+ pollID string
141+ ref string
142+ token string
143+ hash domain.TokenHash
144+}
145+
146+// NewCI builds the CI use cases. ctx carries the logger; its cancellation is
147+// not followed: call Start to run the workers and Close to stop them.
148+func NewCI(ctx context.Context, cfg CIConfig) *CI {
149+ if cfg.Owner == "" {
150+ panic("usecase: CIConfig.Owner is required")
151+ }
152+ cfg = cfg.withDefaults()
153+ run, stop := context.WithCancel(context.WithoutCancel(ctx))
154+ return &CI{
155+ cfg: cfg, boot: cfg.Clock.Now(), queue: make(chan ciTrigger, cfg.TriggerQueue),
156+ ctx: run, stop: stop, flows: newWfCache(64),
157+ heartbeats: map[string]time.Time{}, seen: map[string]time.Time{}, polls: map[string]pollRecord{},
158+ perClient: map[string]int{},
159+ }
160+}
161+
162+// Start runs the trigger worker and the watchdog, and plans again the runs
163+// that were open when the server stopped.
164+func (c *CI) Start() {
165+ c.wg.Add(2)
166+ go c.triggerLoop()
167+ go c.watchdog()
168+ c.wg.Add(1)
169+ go func() {
170+ defer c.wg.Done()
171+ c.resume()
172+ }()
173+}
174+
175+// Close stops the workers and waits for them.
176+func (c *CI) Close(_ context.Context) {
177+ c.mu.Lock()
178+ c.closed = true
179+ c.mu.Unlock()
180+ c.stop()
181+ c.wg.Wait()
182+}
183+
184+func (c *CI) now() time.Time { return c.cfg.Clock.Now().UTC() }
185+
186+// --- identifiers and secrets ---
187+
188+func newToken(prefix string) string {
189+ var raw [32]byte
190+ _, _ = rand.Read(raw[:])
191+ return prefix + base64.RawURLEncoding.EncodeToString(raw[:])
192+}
193+
194+func newRunnerID() string {
195+ var raw [8]byte
196+ _, _ = rand.Read(raw[:])
197+ return hex.EncodeToString(raw[:])
198+}
199+
200+func jobRef(j domain.CIJob) string { return fmt.Sprintf("%d/%d/%s", j.RunID, j.RunAttempt, j.ID) }
201+
202+// --- locks and notifications ---
203+
204+// notifier wakes the goroutines waiting on a key. A channel exists only while
205+// somebody waits on it.
206+type notifier struct {
207+ mu sync.Mutex
208+ ch map[string]chan struct{}
209+}
210+
211+// wait returns a channel closed by the next notify of key.
212+func (n *notifier) wait(key string) <-chan struct{} {
213+ n.mu.Lock()
214+ defer n.mu.Unlock()
215+ if n.ch == nil {
216+ n.ch = map[string]chan struct{}{}
217+ }
218+ c, ok := n.ch[key]
219+ if !ok {
220+ c = make(chan struct{})
221+ n.ch[key] = c
222+ }
223+ return c
224+}
225+
226+func (n *notifier) notify(key string) {
227+ n.mu.Lock()
228+ defer n.mu.Unlock()
229+ if c, ok := n.ch[key]; ok {
230+ close(c)
231+ delete(n.ch, key)
232+ }
233+}
234+
235+const queueKey = "q"
236+
237+func (c *CI) wakeQueue() { c.pending.notify(queueKey) }
238+
239+func (c *CI) runLock(runID int64) *sync.Mutex {
240+ m, _ := c.locks.LoadOrStore(runID, &sync.Mutex{})
241+ return m.(*sync.Mutex)
242+}
243+
244+// --- run state ---
245+
246+// runState is a run being changed under its lock: the run, the jobs of one of
247+// its attempts, and what must be written. flush writes everything in one batch
248+// and publishes the events.
249+type runState struct {
250+ c *CI
251+ run domain.CIRun
252+ attempt int32
253+ jobs []domain.CIJob
254+ src *domain.CIRunSource
255+
256+ runDirty bool
257+ dirty map[string]bool
258+ // chk marks jobs whose check must be written even when the job itself did
259+ // not change (a rerun moves every check to the new attempt).
260+ checks map[string]bool
261+}
262+
263+// withRun locks the run, loads it with the jobs of attempt (0 for the latest)
264+// and calls fn. The run is found by id alone through its source.
265+func (c *CI) withRun(ctx context.Context, runID int64, attempt int32, fn func(rs *runState) error) error {
266+ lock := c.runLock(runID)
267+ lock.Lock()
268+ defer lock.Unlock()
269+ rs, err := c.loadRun(ctx, runID, attempt)
270+ if err != nil {
271+ return err
272+ }
273+ return fn(rs)
274+}
275+
276+func (c *CI) loadRun(ctx context.Context, runID int64, attempt int32) (*runState, error) {
277+ src, err := c.cfg.Store.GetRunSource(ctx, runID)
278+ if err != nil {
279+ return nil, err
280+ }
281+ run, err := c.cfg.Store.GetRun(ctx, src.Repo, runID)
282+ if err != nil {
283+ return nil, err
284+ }
285+ if attempt <= 0 {
286+ attempt = run.Attempt
287+ }
288+ jobs, err := c.cfg.Store.ListJobs(ctx, runID, attempt)
289+ if err != nil {
290+ return nil, err
291+ }
292+ return &runState{c: c, run: run, attempt: attempt, jobs: jobs, src: &src, dirty: map[string]bool{}, checks: map[string]bool{}}, nil
293+}
294+
295+// latest reports whether the state holds the latest attempt of the run.
296+func (rs *runState) latest() bool { return rs.attempt == rs.run.Attempt }
297+
298+func (rs *runState) find(id string) *domain.CIJob {
299+ for i := range rs.jobs {
300+ if rs.jobs[i].ID == id {
301+ return &rs.jobs[i]
302+ }
303+ }
304+ return nil
305+}
306+
307+func (rs *runState) touch(j *domain.CIJob) { rs.dirty[j.ID] = true }
308+
309+// checkOf builds the check of a job.
310+func (rs *runState) checkOf(j domain.CIJob) domain.CICheck {
311+ return domain.CICheck{
312+ ID: j.ID, Repo: rs.run.Repo, Sha: rs.run.Sha, Name: j.Name, Status: j.Status(), Conclusion: j.Conclusion,
313+ RunID: rs.run.ID, JobID: j.ID, Attempt: rs.run.Attempt, WorkflowName: rs.run.WorkflowName,
314+ CreatedAt: j.CreatedAt, StartedAt: j.StartedAt, CompletedAt: j.CompletedAt,
315+ }
316+}
317+
318+// flush recomputes the status of the run, writes the changed jobs, their
319+// checks and the run in one batch, and publishes RunUpdated and JobUpdated.
320+func (rs *runState) flush(ctx context.Context) error {
321+ c := rs.c
322+ now := c.now()
323+ var w ports.CIWrite
324+ var changedJobs []int
325+ for i := range rs.jobs {
326+ if rs.dirty[rs.jobs[i].ID] {
327+ w.Jobs = append(w.Jobs, rs.jobs[i])
328+ changedJobs = append(changedJobs, i)
329+ }
330+ }
331+ if rs.latest() {
332+ for i := range rs.jobs {
333+ if rs.dirty[rs.jobs[i].ID] || rs.checks[rs.jobs[i].ID] {
334+ w.Checks = append(w.Checks, rs.checkOf(rs.jobs[i]))
335+ }
336+ }
337+ if next := rs.recompute(now); next != rs.run {
338+ rs.run, rs.runDirty = next, true
339+ }
340+ }
341+ if rs.runDirty {
342+ w.Runs = []domain.CIRun{rs.run}
343+ }
344+ if len(w.Jobs) == 0 && len(w.Runs) == 0 && len(w.Checks) == 0 {
345+ return nil
346+ }
347+ v, err := c.cfg.Store.Apply(ctx, w)
348+ if err != nil {
349+ return fmt.Errorf("write run %d: %w", rs.run.ID, err)
350+ }
351+ for _, i := range changedJobs {
352+ rs.jobs[i].Version = v
353+ }
354+ if rs.runDirty {
355+ rs.run.Version = v
356+ }
357+ changedRun := rs.runDirty
358+ rs.dirty, rs.checks, rs.runDirty = map[string]bool{}, map[string]bool{}, false
359+
360+ repo, err := c.cfg.Meta.Get(ctx, rs.run.Repo)
361+ if err != nil {
362+ for _, i := range changedJobs {
363+ c.jobs.notify(jobRef(rs.jobs[i]))
364+ }
365+ return nil // the repository is gone: nobody to tell
366+ }
367+ for _, i := range changedJobs {
368+ j := rs.jobs[i]
369+ lines, _ := c.cfg.Logs.Count(ctx, ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt})
370+ c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, Lines: lines, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
371+ c.jobs.notify(jobRef(j))
372+ if j.Phase == domain.CIPhaseReady {
373+ c.wakeQueue()
374+ }
375+ }
376+ if changedRun {
377+ c.cfg.Bus.Publish(ctx, domain.RunUpdated{Run: rs.run, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
378+ }
379+ return nil
380+}
381+
382+// recompute returns the run with the status, the conclusion and the
383+// timestamps its jobs imply.
384+func (rs *runState) recompute(now time.Time) domain.CIRun {
385+ run := rs.run
386+ if run.Status == domain.CICompleted && len(rs.jobs) == 0 {
387+ return run // an invalid workflow file: failed from the start
388+ }
389+ allEnded := len(rs.jobs) > 0
390+ started := time.Time{}
391+ var failed, cancelled bool
392+ for _, j := range rs.jobs {
393+ if !j.Ended() {
394+ allEnded = false
395+ }
396+ // A job kept from an earlier attempt did not start in this one.
397+ if j.Attempt == rs.attempt && !j.StartedAt.IsZero() && (started.IsZero() || j.StartedAt.Before(started)) {
398+ started = j.StartedAt
399+ }
400+ switch {
401+ case j.Conclusion.IsFailure():
402+ failed = true
403+ case j.Conclusion == domain.CICancelled:
404+ cancelled = true
405+ }
406+ }
407+ if run.StartedAt.IsZero() && !started.IsZero() {
408+ run.StartedAt = started
409+ }
410+ switch {
411+ case allEnded:
412+ run.Status, run.CompletedAt = domain.CICompleted, now
413+ switch {
414+ case run.CancelRequested:
415+ run.Conclusion = domain.CICancelled
416+ case failed:
417+ run.Conclusion = domain.CIFailure
418+ case cancelled:
419+ run.Conclusion = domain.CICancelled
420+ default:
421+ run.Conclusion = domain.CISuccess
422+ }
423+ if rs.run.Status == domain.CICompleted {
424+ run.CompletedAt = rs.run.CompletedAt
425+ }
426+ case !run.StartedAt.IsZero():
427+ run.Status, run.Conclusion, run.CompletedAt = domain.CIInProgress, domain.CINoConclusion, time.Time{}
428+ default:
429+ run.Status, run.Conclusion, run.CompletedAt = domain.CIQueued, domain.CINoConclusion, time.Time{}
430+ }
431+ return run
432+}
433+
434+// --- access ---
435+
436+// readableRepo looks the repository up and applies the read rule.
437+func (c *CI) readableRepo(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
438+ repo, err := lookupRepo(ctx, c.cfg.Meta, name)
439+ if err != nil {
440+ return domain.Repo{}, err
441+ }
442+ if !canRead(p, repo) {
443+ return domain.Repo{}, domain.ErrNotFound
444+ }
445+ return repo, nil
446+}
447+
448+// writableRepo is the access rule of cancelling and re-running: a principal
449+// whose token holds ci:write on the repository sees the repository for this
450+// action even without repo:read, as for merge requests.
451+func (c *CI) writableRepo(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
452+ repo, err := lookupRepo(ctx, c.cfg.Meta, name)
453+ if err != nil {
454+ return domain.Repo{}, err
455+ }
456+ if !canRead(p, repo) && !p.Allows(domain.ScopeCIWrite, name) {
457+ return domain.Repo{}, domain.ErrNotFound
458+ }
459+ if err := scopeAccess(p, repo, domain.ScopeCIWrite); err != nil {
460+ return domain.Repo{}, err
461+ }
462+ return repo, nil
463+}
464+
465+// canManageRunners is the access rule of runners: runner:register on a token
466+// that is not restricted to repositories (a runner serves every repository),
467+// or root.
468+func canManageRunners(p domain.Principal) error {
469+ switch {
470+ case p.IsAnonymous():
471+ return domain.ErrUnauthenticated
472+ case !p.HasScope(domain.ScopeRunnerRegister):
473+ return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister)
474+ case len(p.Repos()) > 0:
475+ return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden)
476+ }
477+ return nil
478+}
479+
480+// --- small helpers ---
481+
482+func truncateRunes(s string, n int) string {
483+ if len(s) <= n {
484+ return s
485+ }
486+ r := []rune(s)
487+ if len(r) <= n {
488+ return s
489+ }
490+ return string(r[:n])
491+}
492+
493+// cutText cuts s to n bytes at a character boundary.
494+func cutText(s string, n int) string {
495+ if len(s) <= n {
496+ return s
497+ }
498+ for n > 0 && n < len(s) && s[n]&0xC0 == 0x80 {
499+ n--
500+ }
501+ return s[:n]
502+}
503+
504+func sortedKeys[V any](m map[string]V) []string {
505+ keys := make([]string, 0, len(m))
506+ for k := range m {
507+ keys = append(keys, k)
508+ }
509+ slices.Sort(keys)
510+ return keys
511+}
512+
513+// ctxLog returns the logger of the context, addressable so that the level
514+// methods can be chained on it.
515+func ctxLog(ctx context.Context) *zerowrap.Logger {
516+ l := zerowrap.FromCtx(ctx)
517+ return &l
518+}
519+
520+// logRun returns the logger of the context with the fields of the run.
521+func logRun(ctx context.Context, run domain.CIRun) zerowrap.Logger {
522+ return zerowrap.FromCtx(ctx).WithFields(map[string]any{"repo": run.Repo.String(), "run_id": run.ID})
523+}
524+
525+func isHex40(s string) bool {
526+ if len(s) != 40 {
527+ return false
528+ }
529+ return strings.Trim(s, "0123456789abcdef") == ""
530+}
A internal/usecase/ci_plan.go +489 -0
@@ -0,0 +1,489 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "fmt"
10+ "math"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "sync"
15+
16+ "bnema.dev/ermite-actions/expr"
17+ "bnema.dev/ermite-actions/template"
18+ "bnema.dev/ermite-actions/workflow"
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// maxJobError bounds JobError text (4 KiB, run.proto).
23+const maxJobError = 4 << 10
24+
25+// wfCache keeps the parsed workflows of the runs in flight, so that planning a
26+// job does not parse the file again.
27+type wfCache struct {
28+ mu sync.Mutex
29+ max int
30+ m map[int64]*workflow.Workflow
31+}
32+
33+func newWfCache(max int) *wfCache { return &wfCache{max: max, m: map[int64]*workflow.Workflow{}} }
34+
35+func (w *wfCache) get(runID int64, path, yaml string) (*workflow.Workflow, error) {
36+ w.mu.Lock()
37+ if wf, ok := w.m[runID]; ok {
38+ w.mu.Unlock()
39+ return wf, nil
40+ }
41+ w.mu.Unlock()
42+ wf, errs := workflow.Parse(path, []byte(yaml))
43+ if len(errs) > 0 {
44+ return nil, fmt.Errorf("the workflow file no longer parses: %s", errs[0].Error())
45+ }
46+ w.mu.Lock()
47+ defer w.mu.Unlock()
48+ if len(w.m) >= w.max {
49+ clear(w.m)
50+ }
51+ w.m[runID] = wf
52+ return wf, nil
53+}
54+
55+// unsupported names the feature of a job that CI-1 does not run, or "". Such
56+// a job fails with "not supported yet: <feature>" instead of running wrong.
57+// Relax it as the runner learns the features (ADR 0025, CI-2 and CI-3).
58+func unsupported(j *workflow.Job) string {
59+ switch {
60+ case j.IsReusable():
61+ return "reusable workflows (uses at the job level)"
62+ case j.Container != nil:
63+ return "container"
64+ case j.Services != nil:
65+ return "services"
66+ case j.Environment != nil:
67+ return "environment"
68+ case j.Concurrency != nil:
69+ return "concurrency"
70+ case present(j.ContinueOnError):
71+ return "continue-on-error"
72+ }
73+ for _, s := range j.Steps {
74+ switch {
75+ case s.Uses != nil:
76+ return "uses: " + s.Uses.Value
77+ case present(s.ContinueOnError):
78+ return "continue-on-error on a step"
79+ case present(s.TimeoutMinutes):
80+ return "timeout-minutes on a step"
81+ }
82+ }
83+ return ""
84+}
85+
86+// present reports whether an optional key is given and is not a literal
87+// false (continue-on-error: false is the default).
88+func present(t template.Token) bool {
89+ if t == nil {
90+ return false
91+ }
92+ if b, ok := t.(*template.Boolean); ok && !b.Value {
93+ return false
94+ }
95+ return true
96+}
97+
98+// instances returns the indexes of the records of the job key, in order.
99+func (rs *runState) instances(key string) []int {
100+ var out []int
101+ for i, j := range rs.jobs {
102+ if j.Key == key {
103+ out = append(out, i)
104+ }
105+ }
106+ return out
107+}
108+
109+func (rs *runState) sortJobs() {
110+ slices.SortStableFunc(rs.jobs, func(a, b domain.CIJob) int {
111+ if a.Index != b.Index {
112+ return a.Index - b.Index
113+ }
114+ return a.Combo - b.Combo
115+ })
116+}
117+
118+// result is the aggregate conclusion of the instances of a job as the needs
119+// context shows it: success, failure, cancelled or skipped.
120+func result(insts []domain.CIJob) string {
121+ var failed, cancelled, ok bool
122+ for _, j := range insts {
123+ switch j.Conclusion {
124+ case domain.CIFailure, domain.CITimedOut, domain.CIActionRequired:
125+ failed = true
126+ case domain.CICancelled:
127+ cancelled = true
128+ case domain.CISkipped:
129+ default:
130+ ok = true
131+ }
132+ }
133+ switch {
134+ case failed:
135+ return "failure"
136+ case cancelled:
137+ return "cancelled"
138+ case ok:
139+ return "success"
140+ }
141+ return "skipped"
142+}
143+
144+// needsOf computes the needs context and the status functions of a job from
145+// the jobs it needs, and whether they all have ended.
146+func (rs *runState) needsOf(j *workflow.Job) (needs *expr.Dictionary, st workflow.StatusFuncs, ready bool) {
147+ needs = expr.NewDictionary()
148+ st = workflow.StatusFuncs{Success: true, Cancelled: rs.run.CancelRequested}
149+ for _, n := range j.Needs {
150+ var insts []domain.CIJob
151+ for _, i := range rs.instances(n) {
152+ if !rs.jobs[i].Ended() {
153+ return nil, st, false
154+ }
155+ insts = append(insts, rs.jobs[i])
156+ }
157+ if len(insts) == 0 {
158+ return nil, st, false
159+ }
160+ res := result(insts)
161+ switch res {
162+ case "failure":
163+ st.Failure, st.Success = true, false
164+ case "cancelled":
165+ st.Cancelled, st.Success = true, false
166+ case "skipped":
167+ st.Success = false
168+ }
169+ outs := expr.NewDictionary()
170+ for _, in := range insts {
171+ for _, k := range sortedKeys(in.Outputs) {
172+ // Instances of a matrix share names: the last non-empty value wins.
173+ if v := in.Outputs[k]; v != "" || !hasKey(outs, k) {
174+ setKey(outs, k, expr.String(v))
175+ }
176+ }
177+ }
178+ d := expr.NewDictionary()
179+ d.Add("result", expr.String(res))
180+ d.Add("outputs", outs)
181+ needs.Add(n, d)
182+ }
183+ return needs, st, true
184+}
185+
186+func hasKey(d *expr.Dictionary, k string) bool { _, ok := d.Get(k); return ok }
187+
188+// setKey sets a key of a dictionary, replacing an existing value.
189+func setKey(d *expr.Dictionary, k string, v expr.Value) {
190+ if !d.Add(k, v) {
191+ pairs := d.Pairs()
192+ *d = *expr.NewDictionary()
193+ for _, p := range pairs {
194+ if strings.EqualFold(p.Key, k) {
195+ d.Add(p.Key, v)
196+ } else {
197+ d.Add(p.Key, p.Value)
198+ }
199+ }
200+ }
201+}
202+
203+// githubValue is the github context of the run with the keys of the job.
204+func (rs *runState) githubValue(key string, attempt int32) (*expr.Dictionary, error) {
205+ v, err := expr.FromJSON([]byte(rs.src.GithubJSON))
206+ if err != nil {
207+ return nil, fmt.Errorf("the stored github context is not valid: %w", err)
208+ }
209+ d, ok := v.(*expr.Dictionary)
210+ if !ok {
211+ return nil, fmt.Errorf("the stored github context is not an object")
212+ }
213+ d.Add("job", expr.String(key))
214+ d.Add("run_attempt", expr.String(strconv.Itoa(int(attempt))))
215+ return d, nil
216+}
217+
218+// trim cuts a message for a job error.
219+func trim(s string) string { return cutText(s, maxJobError) }
220+
221+// end ends a job with a conclusion and an error text, completes its steps like
222+// the server does, and applies fail-fast to its siblings.
223+func (rs *runState) end(j *domain.CIJob, conclusion domain.CIConclusion, msg string) {
224+ now := rs.c.now()
225+ cancelish := conclusion == domain.CICancelled || conclusion == domain.CITimedOut
226+ for i := range j.Steps {
227+ s := &j.Steps[i]
228+ if s.State == domain.CIStepCompleted {
229+ continue
230+ }
231+ s.Conclusion = domain.CICancelled
232+ if s.State == domain.CIStepPending && !cancelish {
233+ s.Conclusion = domain.CISkipped
234+ }
235+ s.State, s.CompletedAt = domain.CIStepCompleted, now
236+ }
237+ j.Phase, j.Conclusion, j.CompletedAt = domain.CIPhaseEnded, conclusion, now
238+ if msg != "" {
239+ j.Error = trim(msg)
240+ }
241+ if j.HasToken() {
242+ j.TokenValidUntil = now.Add(domain.CITokenGrace)
243+ }
244+ rs.touch(j)
245+ rs.c.mu.Lock()
246+ delete(rs.c.heartbeats, jobRef(*j))
247+ rs.c.mu.Unlock()
248+
249+ if j.FailFast && conclusion.IsFailure() {
250+ for i := range rs.jobs {
251+ s := &rs.jobs[i]
252+ if s.Key != j.Key || s.ID == j.ID || s.Ended() {
253+ continue
254+ }
255+ if s.Active() {
256+ rs.requestCancel(s, domain.CICancelFailFast, "Canceled because another job of the matrix failed (fail-fast)")
257+ } else {
258+ rs.end(s, domain.CICancelled, "")
259+ }
260+ }
261+ }
262+}
263+
264+// requestCancel asks the runner of a job to stop, once: the first reason wins.
265+func (rs *runState) requestCancel(j *domain.CIJob, reason domain.CICancelReason, msg string) {
266+ if j.CancelReason != domain.CICancelNone || j.Ended() {
267+ return
268+ }
269+ j.CancelReason, j.CancelMessage, j.CancelRequestedAt = reason, msg, rs.c.now()
270+ rs.touch(j)
271+}
272+
273+// cancelAll cancels what is left of the run: jobs that no runner has are over
274+// at once, the ones a runner has get a cancel request.
275+func (rs *runState) cancelAll(msg string) {
276+ for i := range rs.jobs {
277+ j := &rs.jobs[i]
278+ switch {
279+ case j.Ended():
280+ case j.Active():
281+ rs.requestCancel(j, domain.CICancelUser, msg)
282+ default:
283+ rs.end(j, domain.CICancelled, "")
284+ }
285+ }
286+}
287+
288+// advance plans the jobs that became ready and promotes queued matrix
289+// instances, until nothing changes.
290+func (rs *runState) advance(ctx context.Context) error {
291+ if !rs.latest() {
292+ return nil
293+ }
294+ if rs.run.CancelRequested {
295+ rs.cancelAll("Canceled by the owner")
296+ return nil
297+ }
298+ wf, err := rs.c.flows.get(rs.run.ID, rs.run.WorkflowPath, rs.src.WorkflowYAML)
299+ if err != nil {
300+ for i := range rs.jobs {
301+ if !rs.jobs[i].Ended() && !rs.jobs[i].Active() {
302+ rs.end(&rs.jobs[i], domain.CIFailure, err.Error())
303+ }
304+ }
305+ return nil
306+ }
307+ for changed := true; changed; {
308+ changed = false
309+ for _, wj := range wf.Jobs {
310+ insts := rs.instances(wj.ID)
311+ if len(insts) != 1 {
312+ continue
313+ }
314+ rec := rs.jobs[insts[0]]
315+ if rec.Planned || rec.Ended() || rec.Phase != domain.CIPhaseWaiting {
316+ continue
317+ }
318+ needs, st, ready := rs.needsOf(wj)
319+ if !ready {
320+ continue
321+ }
322+ rs.planJob(ctx, wj, insts[0], needs, st)
323+ changed = true
324+ }
325+ }
326+ rs.promote()
327+ return nil
328+}
329+
330+// promote makes planned instances ready while max-parallel allows it.
331+func (rs *runState) promote() {
332+ now := rs.c.now()
333+ counts := map[string]int{}
334+ for _, j := range rs.jobs {
335+ if j.Phase == domain.CIPhaseReady || j.Active() {
336+ counts[j.Key]++
337+ }
338+ }
339+ for i := range rs.jobs {
340+ j := &rs.jobs[i]
341+ if !j.Planned || j.Phase != domain.CIPhaseWaiting {
342+ continue
343+ }
344+ if j.MaxParallel > 0 && counts[j.Key] >= j.MaxParallel {
345+ continue
346+ }
347+ j.Phase, j.ReadyAt = domain.CIPhaseReady, now
348+ counts[j.Key]++
349+ rs.touch(j)
350+ }
351+}
352+
353+// plan evaluates a job whose needs have ended: its condition, its strategy and
354+// the parts of every instance the server decides (name, labels, timeout,
355+// contexts). The record at idx becomes the first instance.
356+func (rs *runState) planJob(ctx context.Context, wj *workflow.Job, idx int, needs *expr.Dictionary, st workflow.StatusFuncs) {
357+ rec := rs.jobs[idx]
358+ fail := func(msg string) { rs.end(&rs.jobs[idx], domain.CIFailure, msg) }
359+
360+ gh, err := rs.githubValue(wj.ID, rs.run.Attempt)
361+ if err != nil {
362+ fail(err.Error())
363+ return
364+ }
365+ env := template.Env{Contexts: map[string]expr.Value{
366+ "github": gh, "needs": needs, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
367+ }}
368+ run, err := wj.EvaluateIf(ctx, env, st)
369+ switch {
370+ case err != nil:
371+ fail(err.Error())
372+ return
373+ case !run:
374+ rs.jobs[idx].Name = rs.skippedName(ctx, wj, env)
375+ rs.end(&rs.jobs[idx], domain.CISkipped, "")
376+ return
377+ }
378+ if feature := unsupported(wj); feature != "" {
379+ fail("not supported yet: " + feature)
380+ return
381+ }
382+ plan, err := wj.EvaluateStrategy(ctx, env)
383+ if err != nil {
384+ fail(err.Error())
385+ return
386+ }
387+
388+ insts := make([]domain.CIJob, 0, len(plan.Combos))
389+ for i := range plan.Combos {
390+ menv, err := plan.MatrixEnv(env, i)
391+ if err != nil {
392+ fail(err.Error())
393+ return
394+ }
395+ inst, err := rs.instance(ctx, wj, plan, rec, i, menv, needs)
396+ if err != nil {
397+ fail(err.Error())
398+ return
399+ }
400+ insts = append(insts, inst)
401+ }
402+ rs.jobs[idx] = insts[0]
403+ rs.touch(&rs.jobs[idx])
404+ for _, inst := range insts[1:] {
405+ rs.jobs = append(rs.jobs, inst)
406+ rs.dirty[inst.ID] = true
407+ }
408+ rs.sortJobs()
409+}
410+
411+// skippedName is the best name for a job that did not run: its evaluated name
412+// when that does not read the matrix, else its key.
413+func (rs *runState) skippedName(ctx context.Context, wj *workflow.Job, env template.Env) string {
414+ if wj.HasName {
415+ if name, err := template.EvaluateString(ctx, wj.Name, env); err == nil && name != "" {
416+ return name
417+ }
418+ }
419+ return wj.ID
420+}
421+
422+// instance builds the planned record of combination i of a job.
423+func (rs *runState) instance(ctx context.Context, wj *workflow.Job, plan *workflow.Strategy, base domain.CIJob, i int, env template.Env, needs *expr.Dictionary) (domain.CIJob, error) {
424+ j := base
425+ j.Combo = i
426+ j.ID = domain.NewJobID(rs.run.ID, base.Index, i)
427+ if i > 0 {
428+ j.Version = 0
429+ }
430+ name, err := wj.DisplayName(ctx, env, plan, i)
431+ if err != nil {
432+ return j, err
433+ }
434+ labels, _, err := wj.EvaluateRunsOn(ctx, env)
435+ if err != nil {
436+ return j, err
437+ }
438+ if len(labels) == 0 {
439+ return j, fmt.Errorf("job '%s' has no runs-on labels", wj.ID)
440+ }
441+ timeout := int32(domain.CIDefaultTimeoutMinutes)
442+ if wj.TimeoutMinutes != nil {
443+ n, err := template.EvaluateNumber(ctx, wj.TimeoutMinutes, env)
444+ if err != nil {
445+ return j, err
446+ }
447+ timeout = int32(min(max(math.Round(n), 1), 525600)) //nolint:gosec // clamped
448+ }
449+ gh, err := rs.githubValue(wj.ID, rs.run.Attempt)
450+ if err != nil {
451+ return j, err
452+ }
453+ matrix := plan.Combos[i].Values
454+ contexts, err := contextsJSON(gh, needs, plan.Context(i), matrix)
455+ if err != nil {
456+ return j, err
457+ }
458+ j.Name, j.Labels, j.TimeoutMinutes = name, labels, timeout
459+ j.MatrixJSON = compactJSON(expr.ToJSON(matrix))
460+ j.ContextsJSON, j.ComboTotal = contexts, len(plan.Combos)
461+ j.FailFast, j.MaxParallel = plan.FailFast, plan.MaxParallel
462+ j.Planned, j.Phase = true, domain.CIPhaseWaiting
463+ return j, nil
464+}
465+
466+func compactJSON(s string) string {
467+ var b bytes.Buffer
468+ if err := json.Compact(&b, []byte(s)); err != nil {
469+ return "{}"
470+ }
471+ return b.String()
472+}
473+
474+// contextsJSON is the JSON object of the contexts the server computes for a
475+// job (Job.contexts in runner.proto).
476+func contextsJSON(github, needs *expr.Dictionary, strategy, matrix *expr.Dictionary) (string, error) {
477+ out := map[string]json.RawMessage{}
478+ for k, v := range map[string]expr.Value{
479+ "github": github, "needs": needs, "strategy": strategy, "matrix": matrix,
480+ "inputs": expr.NewDictionary(), "vars": expr.NewDictionary(),
481+ } {
482+ out[k] = json.RawMessage(compactJSON(expr.ToJSON(v)))
483+ }
484+ b, err := json.Marshal(out)
485+ if err != nil {
486+ return "", err
487+ }
488+ return string(b), nil
489+}
A internal/usecase/ci_runner.go +771 -0
@@ -0,0 +1,771 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "fmt"
9+ "slices"
10+ "strings"
11+ "time"
12+ "unicode/utf8"
13+
14+ "github.com/bnema/zerowrap"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// maxVersionLen bounds RegisterRunner.version.
21+const maxVersionLen = 64
22+
23+// --- registration ---
24+
25+// Register adds a runner and returns its token, shown once. The principal
26+// needs runner:register on a token that is not restricted to repositories, or
27+// to be root. A name in use is domain.ErrAlreadyExists unless Replace is set,
28+// which removes the old runner (its token stops working at once).
29+func (c *CI) Register(ctx context.Context, p domain.Principal, params ports.RegisterRunnerParams) (ports.RegisteredRunner, error) {
30+ if err := canManageRunners(p); err != nil {
31+ return ports.RegisteredRunner{}, err
32+ }
33+ if err := domain.ParseRunnerName(params.Name); err != nil {
34+ return ports.RegisteredRunner{}, err
35+ }
36+ labels, err := domain.ParseRunnerLabels(params.Labels)
37+ if err != nil {
38+ return ports.RegisteredRunner{}, err
39+ }
40+ var v domain.Violations
41+ if len(params.Version) > maxVersionLen || !utf8.ValidString(params.Version) {
42+ v.Add("version", "is at most 64 characters")
43+ }
44+ switch params.Isolation {
45+ case domain.CIIsolationVM, domain.CIIsolationContainerSysbox, domain.CIIsolationContainerPrivileged:
46+ default:
47+ v.Add("isolation", "is required")
48+ }
49+ if params.Arch != "x64" && params.Arch != "arm64" {
50+ v.Add("arch", `must be "x64" or "arm64"`)
51+ }
52+ if params.OS != "linux" {
53+ v.Add("os", `must be "linux"`)
54+ }
55+ if err := v.Err(); err != nil {
56+ return ports.RegisteredRunner{}, err
57+ }
58+
59+ token := newToken(domain.RunnerTokenPrefix)
60+ r := domain.CIRunner{
61+ ID: newRunnerID(), Name: params.Name, Labels: domain.RunnerLabels(labels, params.OS, params.Arch),
62+ Isolation: params.Isolation, Version: params.Version, Arch: params.Arch, OS: params.OS,
63+ RegisteredAt: c.now(), TokenHash: domain.HashTokenSecret(token),
64+ }
65+ removed, err := c.cfg.Store.CreateRunner(ctx, r, params.Replace)
66+ if err != nil {
67+ return ports.RegisteredRunner{}, err
68+ }
69+ log := zerowrap.FromCtx(ctx)
70+ log.Info().Str("runner_id", r.ID).Str("runner", r.Name).Str("isolation", string(r.Isolation)).
71+ Int("replaced", len(removed)).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("runner registered")
72+ c.forgetRunners(removed)
73+ return ports.RegisteredRunner{Runner: r, Token: token}, nil
74+}
75+
76+// forgetRunners drops what is kept in memory for removed runners and ends
77+// their polls in progress.
78+func (c *CI) forgetRunners(rs []domain.CIRunner) {
79+ c.mu.Lock()
80+ for _, r := range rs {
81+ delete(c.polls, r.ID)
82+ delete(c.seen, r.ID)
83+ }
84+ c.mu.Unlock()
85+ c.wakeQueue()
86+}
87+
88+// runnerFor authenticates a runner token.
89+func (c *CI) runnerFor(ctx context.Context, secret string) (domain.CIRunner, error) {
90+ if !domain.IsRunnerToken(secret) {
91+ return domain.CIRunner{}, domain.ErrUnauthenticated
92+ }
93+ r, err := c.cfg.Store.FindRunnerByToken(ctx, domain.HashTokenSecret(secret))
94+ if errors.Is(err, domain.ErrNotFound) {
95+ return domain.CIRunner{}, domain.ErrUnauthenticated
96+ }
97+ return r, err
98+}
99+
100+func (c *CI) markSeen(runnerID string) {
101+ c.mu.Lock()
102+ c.seen[runnerID] = c.now()
103+ c.mu.Unlock()
104+}
105+
106+// --- FetchJob ---
107+
108+var errNoJob = errors.New("no job")
109+
110+// FetchJob holds the call until a queued job matches the runner, or the poll
111+// time passes. A job is given to exactly one runner: assignments are made one
112+// at a time under a lock and recorded before the answer is sent.
113+func (c *CI) FetchJob(ctx context.Context, secret string, params ports.FetchJobParams) (ports.FetchJobResult, error) {
114+ runner, err := c.runnerFor(ctx, secret)
115+ if err != nil {
116+ return ports.FetchJobResult{}, err
117+ }
118+ offered, err := offeredLabels(runner, params.Labels)
119+ if err != nil {
120+ return ports.FetchJobResult{}, err
121+ }
122+ if params.PollID != "" {
123+ if err := checkPollID(params.PollID); err != nil {
124+ return ports.FetchJobResult{}, err
125+ }
126+ if j, ok := c.repeatPoll(ctx, runner, params.PollID); ok {
127+ return ports.FetchJobResult{Job: j}, nil
128+ }
129+ }
130+ c.markSeen(runner.ID)
131+ deadline := time.NewTimer(c.cfg.PollTimeout)
132+ defer deadline.Stop()
133+ for {
134+ wake := c.pending.wait(queueKey) // before looking: a job made meanwhile wakes us
135+ job, err := c.assignTo(ctx, runner, offered, params.PollID)
136+ switch {
137+ case err == nil:
138+ c.markSeen(runner.ID)
139+ return ports.FetchJobResult{Job: job}, nil
140+ case !errors.Is(err, errNoJob):
141+ return ports.FetchJobResult{}, err
142+ }
143+ select {
144+ case <-wake:
145+ case <-deadline.C:
146+ c.markSeen(runner.ID)
147+ return ports.FetchJobResult{}, nil
148+ case <-ctx.Done():
149+ return ports.FetchJobResult{}, ctx.Err()
150+ case <-c.ctx.Done():
151+ return ports.FetchJobResult{RetryAfter: time.Second}, nil
152+ }
153+ // A removed runner's poll ends with unauthenticated.
154+ if _, err := c.cfg.Store.GetRunner(ctx, runner.ID); errors.Is(err, domain.ErrNotFound) {
155+ return ports.FetchJobResult{}, domain.ErrUnauthenticated
156+ }
157+ }
158+}
159+
160+func checkPollID(id string) error {
161+ if len(id) > 64 {
162+ return fmt.Errorf("%w: poll_id is at most 64 characters", domain.ErrInvalid)
163+ }
164+ for _, r := range id {
165+ if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && (r < '0' || r > '9') && r != '_' && r != '-' {
166+ return fmt.Errorf("%w: poll_id has letters, digits, '_' and '-' only", domain.ErrInvalid)
167+ }
168+ }
169+ return nil
170+}
171+
172+// offeredLabels are the labels of one poll: all those of the runner, or the
173+// subset the call names.
174+func offeredLabels(r domain.CIRunner, asked []string) ([]string, error) {
175+ if len(asked) == 0 {
176+ return r.Labels, nil
177+ }
178+ var out []string
179+ for _, l := range asked {
180+ l = strings.ToLower(l)
181+ if !slices.Contains(r.Labels, l) {
182+ return nil, fmt.Errorf("%w: label %q is not a label of the runner", domain.ErrInvalid, l)
183+ }
184+ out = append(out, l)
185+ }
186+ return out, nil
187+}
188+
189+// repeatPoll answers a retried poll with the assignment the first call made,
190+// as long as it has not been acknowledged or cancelled.
191+func (c *CI) repeatPoll(ctx context.Context, runner domain.CIRunner, pollID string) (*ports.RunnerJob, bool) {
192+ c.mu.Lock()
193+ rec, ok := c.polls[runner.ID]
194+ c.mu.Unlock()
195+ if !ok || rec.pollID != pollID {
196+ return nil, false
197+ }
198+ var rid int64
199+ var ra int32
200+ var id string
201+ if _, err := fmt.Sscanf(rec.ref, "%d/%d/%s", &rid, &ra, &id); err != nil {
202+ return nil, false
203+ }
204+ var out *ports.RunnerJob
205+ err := c.withRun(ctx, rid, ra, func(rs *runState) error {
206+ j := rs.find(id)
207+ if j == nil || j.Phase != domain.CIPhaseAssigned || j.TokenHash != rec.hash || j.RunnerID != runner.ID {
208+ return errNoJob
209+ }
210+ out = c.runnerJob(rs, *j, rec.token)
211+ return nil
212+ })
213+ return out, err == nil && out != nil
214+}
215+
216+// runnerJob is the job as the runner gets it.
217+func (c *CI) runnerJob(rs *runState, j domain.CIJob, token string) *ports.RunnerJob {
218+ return &ports.RunnerJob{
219+ Job: j, Token: token, Repo: rs.run.Repo, Sha: rs.run.Sha, Ref: rs.run.Ref,
220+ WorkflowPath: rs.run.WorkflowPath, WorkflowYAML: rs.src.WorkflowYAML,
221+ }
222+}
223+
224+// assignTo gives the oldest queued job that matches the labels to the runner,
225+// or errNoJob. The assignment lock makes the choice and the write one step.
226+func (c *CI) assignTo(ctx context.Context, runner domain.CIRunner, offered []string, pollID string) (*ports.RunnerJob, error) {
227+ c.assign.Lock()
228+ defer c.assign.Unlock()
229+ ready, err := c.cfg.Store.ListReadyJobs(ctx, 0)
230+ if err != nil {
231+ return nil, err
232+ }
233+ for _, cand := range ready {
234+ if !domain.LabelsMatch(cand.Labels, offered) {
235+ continue
236+ }
237+ var out *ports.RunnerJob
238+ err := c.withRun(ctx, cand.RunID, cand.RunAttempt, func(rs *runState) error {
239+ j := rs.find(cand.ID)
240+ if j == nil || j.Phase != domain.CIPhaseReady || rs.run.CancelRequested {
241+ return errNoJob
242+ }
243+ token := newToken(domain.JobTokenPrefix)
244+ j.Phase, j.AssignedAt = domain.CIPhaseAssigned, c.now()
245+ j.TokenHash, j.TokenValidUntil = domain.HashTokenSecret(token), time.Time{}
246+ j.RunnerID, j.RunnerName, j.Isolation = runner.ID, runner.Name, runner.Isolation
247+ rs.touch(j)
248+ if err := rs.flush(ctx); err != nil {
249+ return err
250+ }
251+ out = c.runnerJob(rs, *j, token)
252+ if pollID != "" {
253+ c.mu.Lock()
254+ c.polls[runner.ID] = pollRecord{pollID: pollID, ref: jobRef(*j), token: token, hash: j.TokenHash}
255+ c.mu.Unlock()
256+ }
257+ return nil
258+ })
259+ switch {
260+ case err == nil:
261+ log := zerowrap.FromCtx(ctx)
262+ log.Info().Str("runner", runner.Name).Str("job_id", out.Job.ID).Int64("run_id", out.Job.RunID).Msg("job assigned")
263+ return out, nil
264+ case errors.Is(err, errNoJob), errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrConflict):
265+ continue // taken or gone meanwhile
266+ default:
267+ return nil, err
268+ }
269+ }
270+ return nil, errNoJob
271+}
272+
273+// --- the calls of a job token ---
274+
275+// jobFor authenticates a job token and checks that it is the one of jobID.
276+func (c *CI) jobFor(ctx context.Context, secret, jobID string) (domain.CIJob, error) {
277+ if !domain.IsJobToken(secret) {
278+ return domain.CIJob{}, domain.ErrUnauthenticated
279+ }
280+ j, err := c.cfg.Store.FindJobByToken(ctx, domain.HashTokenSecret(secret), c.now())
281+ if errors.Is(err, domain.ErrNotFound) {
282+ return domain.CIJob{}, domain.ErrUnauthenticated
283+ }
284+ if err != nil {
285+ return domain.CIJob{}, err
286+ }
287+ if j.ID != jobID {
288+ return domain.CIJob{}, fmt.Errorf("%w: this token is the token of another job", domain.ErrForbidden)
289+ }
290+ return j, nil
291+}
292+
293+func endedError(j domain.CIJob) error {
294+ return &domain.JobEndedError{Conclusion: j.Conclusion, Message: j.Error}
295+}
296+
297+// lockedJob runs fn with the lock of the run and the job read again.
298+func (c *CI) lockedJob(ctx context.Context, j domain.CIJob, fn func(cur *domain.CIJob) error) error {
299+ lock := c.runLock(j.RunID)
300+ lock.Lock()
301+ defer lock.Unlock()
302+ cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)
303+ if err != nil {
304+ return err
305+ }
306+ if err := sameAssignment(j, cur); err != nil {
307+ return err
308+ }
309+ return fn(&cur)
310+}
311+
312+// sameAssignment checks, under the lock of the run, that the job a token was
313+// found for is still given under that token: an assignment cancelled and given
314+// again since has a new one, and the old runner is unauthenticated. A job that
315+// has ended answers with its end instead (the callers do).
316+func sameAssignment(was, cur domain.CIJob) error {
317+ if !cur.Ended() && cur.TokenHash != was.TokenHash {
318+ return domain.ErrUnauthenticated
319+ }
320+ return nil
321+}
322+
323+// saveJob writes one job (its steps changed) and publishes JobUpdated. The
324+// status of the run does not depend on steps.
325+func (c *CI) saveJob(ctx context.Context, j *domain.CIJob) error {
326+ v, err := c.cfg.Store.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{*j}})
327+ if err != nil {
328+ return fmt.Errorf("write job %s: %w", j.ID, err)
329+ }
330+ j.Version = v
331+ if repo, err := c.cfg.Meta.Get(ctx, j.Repo); err == nil {
332+ lines, _ := c.cfg.Logs.Count(ctx, logRef(*j))
333+ c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: *j, Lines: lines, At: c.now(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
334+ }
335+ c.jobs.notify(jobRef(*j))
336+ return nil
337+}
338+
339+func logRef(j domain.CIJob) ports.CILogRef {
340+ return ports.CILogRef{RunID: j.RunID, JobID: j.ID, Attempt: j.Attempt}
341+}
342+
343+// UpdateJob applies the steps of the request whole or not at all.
344+func (c *CI) UpdateJob(ctx context.Context, secret, jobID string, steps []ports.StepUpdate) error {
345+ j, err := c.jobFor(ctx, secret, jobID)
346+ if err != nil {
347+ return err
348+ }
349+ if j.Ended() {
350+ return endedError(j)
351+ }
352+ if err := checkStepUpdates(steps); err != nil {
353+ return err
354+ }
355+ return c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
356+ if cur.Ended() {
357+ return endedError(*cur)
358+ }
359+ next, changed, err := applySteps(cur.Steps, steps)
360+ if err != nil || !changed {
361+ return err
362+ }
363+ cur.Steps = next
364+ return c.saveJob(ctx, cur)
365+ })
366+}
367+
368+// checkStepUpdates validates the shape of the steps of a request.
369+func checkStepUpdates(steps []ports.StepUpdate) error {
370+ if len(steps) > domain.CIMaxSteps {
371+ return fmt.Errorf("%w: at most %d steps", domain.ErrInvalid, domain.CIMaxSteps)
372+ }
373+ for _, s := range steps {
374+ if s.Number < 1 || s.Number > domain.CIMaxSteps {
375+ return fmt.Errorf("%w: step number %d is not between 1 and %d", domain.ErrInvalid, s.Number, domain.CIMaxSteps)
376+ }
377+ switch s.State {
378+ case domain.CIStepPending:
379+ if s.Conclusion != domain.CINoConclusion {
380+ return fmt.Errorf("%w: step %d is not completed: it has no conclusion", domain.ErrInvalid, s.Number)
381+ }
382+ case domain.CIStepInProgress:
383+ if s.Conclusion != domain.CINoConclusion || s.StartedAt.IsZero() {
384+ return fmt.Errorf("%w: step %d is in progress: started_at is required, conclusion is not allowed", domain.ErrInvalid, s.Number)
385+ }
386+ case domain.CIStepCompleted:
387+ switch s.Conclusion {
388+ case domain.CISuccess, domain.CIFailure, domain.CICancelled, domain.CISkipped:
389+ default:
390+ return fmt.Errorf("%w: step %d is completed: its conclusion must be success, failure, cancelled or skipped", domain.ErrInvalid, s.Number)
391+ }
392+ if s.StartedAt.IsZero() && s.Conclusion != domain.CISkipped {
393+ return fmt.Errorf("%w: step %d: started_at is required", domain.ErrInvalid, s.Number)
394+ }
395+ if s.CompletedAt.IsZero() {
396+ return fmt.Errorf("%w: step %d is completed: completed_at is required", domain.ErrInvalid, s.Number)
397+ }
398+ default:
399+ return fmt.Errorf("%w: step %d has no state", domain.ErrInvalid, s.Number)
400+ }
401+ }
402+ return nil
403+}
404+
405+// applySteps returns the steps after the updates, and whether anything
406+// changed. The input is not modified.
407+func applySteps(have []domain.CIStep, ups []ports.StepUpdate) ([]domain.CIStep, bool, error) {
408+ out := slices.Clone(have)
409+ changed := false
410+ for _, u := range ups {
411+ name := truncateRunes(u.Name, domain.CIMaxStepName)
412+ i := slices.IndexFunc(out, func(s domain.CIStep) bool { return s.Number == u.Number })
413+ if i < 0 {
414+ out = append(out, domain.CIStep{
415+ Number: u.Number, Name: name, State: u.State, Conclusion: u.Conclusion, StartedAt: u.StartedAt, CompletedAt: u.CompletedAt,
416+ })
417+ changed = true
418+ continue
419+ }
420+ cur := out[i]
421+ next := cur
422+ next.Name, next.State, next.Conclusion = name, u.State, u.Conclusion
423+ next.StartedAt, next.CompletedAt = u.StartedAt, u.CompletedAt
424+ if cur.Name != "" && name == "" {
425+ next.Name = cur.Name // an empty name never erases one
426+ }
427+ switch {
428+ case cur.State == domain.CIStepCompleted:
429+ same := next.State == cur.State && next.Conclusion == cur.Conclusion && next.Name == cur.Name &&
430+ next.StartedAt.Equal(cur.StartedAt) && next.CompletedAt.Equal(cur.CompletedAt)
431+ if !same {
432+ return nil, false, fmt.Errorf("%w: step %d is completed: it cannot change", domain.ErrPreconditionFailed, u.Number)
433+ }
434+ continue
435+ case cur.State == domain.CIStepInProgress && next.State == domain.CIStepPending:
436+ return nil, false, fmt.Errorf("%w: step %d cannot go from in progress back to pending", domain.ErrPreconditionFailed, u.Number)
437+ }
438+ if !stepsEqual(cur, next) {
439+ out[i], changed = next, true
440+ }
441+ }
442+ slices.SortFunc(out, func(a, b domain.CIStep) int { return int(a.Number) - int(b.Number) })
443+ return out, changed, nil
444+}
445+
446+func stepsEqual(a, b domain.CIStep) bool {
447+ return a.Number == b.Number && a.Name == b.Name && a.State == b.State && a.Conclusion == b.Conclusion &&
448+ a.StartedAt.Equal(b.StartedAt) && a.CompletedAt.Equal(b.CompletedAt) && a.LogStart == b.LogStart && a.HasLines == b.HasLines
449+}
450+
451+// lastLineStep returns the highest step number that has lines, 0 for none.
452+func lastLineStep(steps []domain.CIStep) int32 {
453+ var last int32
454+ for _, s := range steps {
455+ if s.HasLines {
456+ last = max(last, s.Number)
457+ }
458+ }
459+ return last
460+}
461+
462+// checkLines validates the shape of the lines of an AppendLog request.
463+func checkLines(step int32, offset int64, lines []domain.CILogLine) error {
464+ if step < 1 || step > domain.CIMaxSteps {
465+ return fmt.Errorf("%w: step %d is not between 1 and %d", domain.ErrInvalid, step, domain.CIMaxSteps)
466+ }
467+ if offset < 0 {
468+ return fmt.Errorf("%w: negative offset", domain.ErrInvalid)
469+ }
470+ if len(lines) > domain.CIMaxLogLines {
471+ return fmt.Errorf("%w: at most %d lines per request", domain.ErrInvalid, domain.CIMaxLogLines)
472+ }
473+ total := 0
474+ for i, l := range lines {
475+ if strings.ContainsRune(l.Text, '\n') || !utf8.ValidString(l.Text) {
476+ return fmt.Errorf("%w: line %d has a line feed or is not valid UTF-8", domain.ErrInvalid, i)
477+ }
478+ if l.Time.IsZero() {
479+ return fmt.Errorf("%w: line %d has no time", domain.ErrInvalid, i)
480+ }
481+ if l.Continued && offset == 0 && i == 0 {
482+ return fmt.Errorf("%w: the first line of a job cannot be continued", domain.ErrInvalid)
483+ }
484+ total += len(l.Text)
485+ }
486+ if total > domain.CIMaxLogChunkBytes {
487+ return fmt.Errorf("%w: at most %d bytes of text per request", domain.ErrInvalid, domain.CIMaxLogChunkBytes)
488+ }
489+ return nil
490+}
491+
492+// AppendLog appends lines of a step to the log of the job; see runner.proto
493+// for the rules of offsets. The metadata store is written only when a step
494+// gets its first line.
495+func (c *CI) AppendLog(ctx context.Context, secret, jobID string, step int32, offset int64, lines []domain.CILogLine) (int64, error) {
496+ j, err := c.jobFor(ctx, secret, jobID)
497+ if err != nil {
498+ return 0, err
499+ }
500+ if j.Ended() {
501+ return 0, endedError(j)
502+ }
503+ if err := checkLines(step, offset, lines); err != nil {
504+ return 0, err
505+ }
506+ var next int64
507+ limitHit := false
508+ err = c.lockedJob(ctx, j, func(cur *domain.CIJob) error {
509+ if cur.Ended() {
510+ return endedError(*cur)
511+ }
512+ ref := logRef(*cur)
513+ count, err := c.cfg.Logs.Count(ctx, ref)
514+ if err != nil {
515+ return err
516+ }
517+ if offset > count {
518+ return &ports.ErrCILogGap{Count: count}
519+ }
520+ skip := count - offset
521+ if skip >= int64(len(lines)) {
522+ next = count // everything is stored already
523+ return nil
524+ }
525+ last := lastLineStep(cur.Steps)
526+ if step < last {
527+ return fmt.Errorf("%w: step %d is below step %d, which already has lines", domain.ErrPreconditionFailed, step, last)
528+ }
529+ if first := lines[skip]; first.Continued && count > 0 && step != last {
530+ return fmt.Errorf("%w: a continued line must be in the step of the line before it", domain.ErrPreconditionFailed)
531+ }
532+ n, added, err := c.cfg.Logs.Append(ctx, ref, offset, lines, domain.CIMaxLogBytes)
533+ if errors.Is(err, ports.ErrCILogLimit) {
534+ limitHit = true
535+ return nil
536+ }
537+ if err != nil {
538+ return err
539+ }
540+ next = n
541+ if added == 0 {
542+ return nil
543+ }
544+ return c.startStepLines(ctx, cur, step, count, lines[skip].Time)
545+ })
546+ if err != nil {
547+ return 0, err
548+ }
549+ if limitHit {
550+ return 0, c.stopForLogLimit(ctx, j)
551+ }
552+ return next, nil
553+}
554+
555+// startStepLines records where the lines of a step begin, creating the step
556+// when UpdateJob did not announce it.
557+func (c *CI) startStepLines(ctx context.Context, cur *domain.CIJob, step int32, firstLine int64, at time.Time) error {
558+ i := slices.IndexFunc(cur.Steps, func(s domain.CIStep) bool { return s.Number == step })
559+ switch {
560+ case i < 0:
561+ cur.Steps = append(cur.Steps, domain.CIStep{
562+ Number: step, State: domain.CIStepInProgress, StartedAt: at, HasLines: true, LogStart: firstLine,
563+ })
564+ slices.SortFunc(cur.Steps, func(a, b domain.CIStep) int { return int(a.Number) - int(b.Number) })
565+ case cur.Steps[i].HasLines:
566+ return nil
567+ default:
568+ cur.Steps[i].HasLines, cur.Steps[i].LogStart = true, firstLine
569+ }
570+ return c.saveJob(ctx, cur)
571+}
572+
573+// stopForLogLimit ends a job whose log reached its limit: a last line says
574+// so, the job fails, and the call answers like any call after the end.
575+func (c *CI) stopForLogLimit(ctx context.Context, j domain.CIJob) error {
576+ var ended *domain.JobEndedError
577+ err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
578+ cur := rs.find(j.ID)
579+ if cur == nil {
580+ return domain.ErrNotFound
581+ }
582+ if cur.Ended() {
583+ ended = &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error}
584+ return nil
585+ }
586+ if err := sameAssignment(j, *cur); err != nil {
587+ return err
588+ }
589+ _, _ = c.cfg.Logs.AppendForced(ctx, logRef(*cur), []domain.CILogLine{{Time: c.now(), Text: "##[error]" + domain.CILogLimitMessage}})
590+ rs.end(cur, domain.CIFailure, domain.CILogLimitMessage)
591+ ended = &domain.JobEndedError{Conclusion: domain.CIFailure, Message: domain.CILogLimitMessage}
592+ if err := rs.advance(ctx); err != nil {
593+ return err
594+ }
595+ return rs.flush(ctx)
596+ })
597+ if err != nil {
598+ return err
599+ }
600+ if ended == nil {
601+ return domain.ErrNotFound // a typed nil would be taken for an error
602+ }
603+ return ended
604+}
605+
606+// Heartbeat tells the server that the runner is alive. The first one
607+// acknowledges the assignment; the others only move the deadline.
608+func (c *CI) Heartbeat(ctx context.Context, secret, jobID string) error {
609+ j, err := c.jobFor(ctx, secret, jobID)
610+ if err != nil {
611+ return err
612+ }
613+ if j.Ended() {
614+ return endedError(j)
615+ }
616+ // Everything is done under the lock of the run: the acknowledgement, and
617+ // the note of the heartbeat, which must not be written once the job ended
618+ // (end() forgets it under the same lock).
619+ return c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
620+ cur := rs.find(j.ID)
621+ switch {
622+ case cur == nil:
623+ return domain.ErrUnauthenticated
624+ case cur.Ended():
625+ return endedError(*cur)
626+ case cur.TokenHash != j.TokenHash:
627+ return domain.ErrUnauthenticated // the assignment was cancelled and given again
628+ }
629+ now := c.now()
630+ if cur.Phase == domain.CIPhaseAssigned {
631+ cur.Phase, cur.AckedAt, cur.StartedAt, cur.AssignCancels = domain.CIPhaseRunning, now, now, 0
632+ rs.touch(cur)
633+ if err := rs.flush(ctx); err != nil {
634+ return err
635+ }
636+ c.forgetPoll(jobRef(*cur)) // the token it holds is the runner's now
637+ }
638+ c.mu.Lock()
639+ c.heartbeats[jobRef(*cur)] = now
640+ c.seen[cur.RunnerID] = now
641+ c.mu.Unlock()
642+ return nil
643+ })
644+}
645+
646+// forgetPoll drops the remembered assignment (and its plaintext token) of a
647+// job: after the acknowledgement or the cancel of the assignment, a retried
648+// poll has nothing to repeat.
649+func (c *CI) forgetPoll(ref string) {
650+ c.mu.Lock()
651+ defer c.mu.Unlock()
652+ for id, p := range c.polls {
653+ if p.ref == ref {
654+ delete(c.polls, id)
655+ }
656+ }
657+}
658+
659+// WatchJob streams what the runner must react to: the cancel request (the
660+// first message when one is already pending), keepalives, and the end.
661+func (c *CI) WatchJob(ctx context.Context, secret, jobID string, ready func(), send func(ports.JobSignal) error) error {
662+ j, err := c.jobFor(ctx, secret, jobID)
663+ if err != nil {
664+ return err
665+ }
666+ if j.Ended() {
667+ return endedError(j)
668+ }
669+ ref := jobRef(j)
670+ keepalive := time.NewTicker(c.cfg.Keepalive)
671+ defer keepalive.Stop()
672+ cancelSent := false
673+ if ready != nil {
674+ ready()
675+ }
676+ for {
677+ wake := c.jobs.wait(ref)
678+ cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)
679+ if err != nil {
680+ return err
681+ }
682+ if cur.TokenHash != j.TokenHash && !cur.Ended() {
683+ return domain.ErrUnauthenticated // given again
684+ }
685+ if cur.Ended() {
686+ return send(ports.JobSignal{Ended: &domain.JobEndedError{Conclusion: cur.Conclusion, Message: cur.Error}})
687+ }
688+ if cur.CancelReason != domain.CICancelNone && !cancelSent {
689+ cancelSent = true
690+ if err := send(ports.JobSignal{Cancel: &ports.JobCancel{
691+ Reason: cur.CancelReason, Message: cur.CancelMessage, RequestedAt: cur.CancelRequestedAt,
692+ }}); err != nil {
693+ return err
694+ }
695+ }
696+ select {
697+ case <-wake:
698+ case <-keepalive.C:
699+ if err := send(ports.JobSignal{Keepalive: true}); err != nil {
700+ return err
701+ }
702+ case <-ctx.Done():
703+ return nil
704+ case <-c.ctx.Done():
705+ return nil
706+ }
707+ }
708+}
709+
710+// CompleteJob ends a job, once. A later call returns the stored result.
711+func (c *CI) CompleteJob(ctx context.Context, secret string, params ports.CompleteJobParams) (ports.CompleteJobResult, error) {
712+ j, err := c.jobFor(ctx, secret, params.JobID)
713+ if err != nil {
714+ return ports.CompleteJobResult{}, err
715+ }
716+ if j.Ended() {
717+ return ports.CompleteJobResult{Conclusion: j.Conclusion, Error: j.Error}, nil
718+ }
719+ switch params.Conclusion {
720+ case domain.CISuccess, domain.CIFailure, domain.CICancelled, domain.CITimedOut:
721+ default:
722+ return ports.CompleteJobResult{}, fmt.Errorf("%w: a runner reports success, failure, cancelled or timed_out", domain.ErrInvalid)
723+ }
724+ if params.RunnerShutdown && params.Conclusion != domain.CICancelled {
725+ return ports.CompleteJobResult{}, fmt.Errorf("%w: runner_shutdown goes with cancelled", domain.ErrInvalid)
726+ }
727+ if err := domain.CheckOutputs(params.Outputs); err != nil {
728+ return ports.CompleteJobResult{}, err
729+ }
730+ if err := checkStepUpdates(params.Steps); err != nil {
731+ return ports.CompleteJobResult{}, err
732+ }
733+ var res ports.CompleteJobResult
734+ err = c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
735+ cur := rs.find(j.ID)
736+ if cur == nil {
737+ return domain.ErrNotFound
738+ }
739+ if cur.Ended() { // ended by the server since
740+ res = ports.CompleteJobResult{Conclusion: cur.Conclusion, Error: cur.Error}
741+ return nil
742+ }
743+ if err := sameAssignment(j, *cur); err != nil {
744+ return err
745+ }
746+ steps, _, err := applySteps(cur.Steps, params.Steps)
747+ if err != nil {
748+ return err
749+ }
750+ cur.Steps = steps
751+ cur.Outputs = params.Outputs
752+ msg := ""
753+ if params.RunnerShutdown {
754+ msg = domain.CIShutdownMessage
755+ }
756+ rs.end(cur, params.Conclusion, msg)
757+ if err := rs.advance(ctx); err != nil {
758+ return err
759+ }
760+ res = ports.CompleteJobResult{Conclusion: cur.Conclusion, Error: cur.Error, Applied: true}
761+ return rs.flush(ctx)
762+ })
763+ if err != nil {
764+ return ports.CompleteJobResult{}, err
765+ }
766+ if res.Applied {
767+ log := zerowrap.FromCtx(ctx)
768+ log.Info().Str("job_id", j.ID).Int64("run_id", j.RunID).Str("conclusion", string(res.Conclusion)).Msg("job completed")
769+ }
770+ return res, nil
771+}
A internal/usecase/ci_runner_unit_test.go +414 -0
@@ -0,0 +1,414 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/zerowrap"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/mock"
15+ "github.com/stretchr/testify/require"
16+
17+ "bnema.dev/ermite/internal/domain"
18+ "bnema.dev/ermite/internal/ports"
19+ "bnema.dev/ermite/internal/ports/mocks"
20+ "bnema.dev/ermite/internal/usecase"
21+)
22+
23+// ciUnit is the CI use cases over mocks, with a clock the test moves.
24+type ciUnit struct {
25+ store *mocks.CIStore
26+ logs *mocks.CILogStore
27+ meta *mocks.RepoMetaStore
28+ bus *mocks.EventBus
29+ clock *fakeClock
30+ ci *usecase.CI
31+}
32+
33+type fakeClock struct{ t time.Time }
34+
35+func (c *fakeClock) Now() time.Time { return c.t }
36+
37+func newCIUnit(t *testing.T, tweak func(*usecase.CIConfig)) *ciUnit {
38+ t.Helper()
39+ u := &ciUnit{
40+ store: mocks.NewCIStore(t), logs: mocks.NewCILogStore(t), meta: mocks.NewRepoMetaStore(t),
41+ bus: mocks.NewEventBus(t), clock: &fakeClock{t: now},
42+ }
43+ cfg := usecase.CIConfig{
44+ Store: u.store, Logs: u.logs, Meta: u.meta, Repos: mocks.NewGitRepos(t), Reader: mocks.NewRepoReader(t),
45+ Git: mocks.NewMergeGit(t), Diff: mocks.NewCIGit(t), Bus: u.bus, Clock: u.clock, Owner: "owner", PublicURL: "https://forge.test",
46+ }
47+ if tweak != nil {
48+ tweak(&cfg)
49+ }
50+ u.ci = usecase.NewCI(context.Background(), cfg)
51+ t.Cleanup(func() { u.ci.Close(context.Background()) })
52+ return u
53+}
54+
55+const (
56+ jobSecret = "ermitejob_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
57+ jobIDOne = "7-0-0"
58+)
59+
60+func ciJob(t *testing.T, phase domain.CIJobPhase, secret string) domain.CIJob {
61+ t.Helper()
62+ return domain.CIJob{
63+ ID: jobIDOne, RunID: 7, Attempt: 1, RunAttempt: 1, Repo: name(t, "proj"), Key: "build", Name: "build",
64+ Phase: phase, TokenHash: domain.HashTokenSecret(secret), RunnerID: "r1", RunnerName: "alpha", Planned: true,
65+ }
66+}
67+
68+// expectToken makes the token lookup find j.
69+func (u *ciUnit) expectToken(secret string, j domain.CIJob) {
70+ u.store.EXPECT().FindJobByToken(mock.Anything, domain.HashTokenSecret(secret), mock.Anything).Return(j, nil)
71+}
72+
73+// S4: a call whose token was replaced between the lookup and the lock (the
74+// assignment was cancelled and given again) is unauthenticated, for every
75+// call that writes the job.
76+func TestStaleAssignmentIsUnauthenticatedUnderTheLock(t *testing.T) {
77+ t.Parallel()
78+ replaced := func(j domain.CIJob) domain.CIJob {
79+ j.TokenHash = domain.HashTokenSecret("ermitejob_replaced")
80+ return j
81+ }
82+ calls := map[string]func(u *ciUnit) error{
83+ "UpdateJob": func(u *ciUnit) error {
84+ return u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{
85+ Number: 1, State: domain.CIStepInProgress, StartedAt: now,
86+ }})
87+ },
88+ "AppendLog": func(u *ciUnit) error {
89+ _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}})
90+ return err
91+ },
92+ }
93+ for label, call := range calls {
94+ t.Run(label, func(t *testing.T) {
95+ t.Parallel()
96+ u := newCIUnit(t, nil)
97+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
98+ u.expectToken(jobSecret, j)
99+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(replaced(j), nil)
100+ // No write: the stores and the log expect nothing else.
101+ assert.ErrorIs(t, call(u), domain.ErrUnauthenticated)
102+ })
103+ }
104+
105+ t.Run("CompleteJob", func(t *testing.T) {
106+ t.Parallel()
107+ u := newCIUnit(t, nil)
108+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
109+ u.expectToken(jobSecret, j)
110+ u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo}, nil)
111+ u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil)
112+ u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return([]domain.CIJob{replaced(j)}, nil)
113+ _, err := u.ci.CompleteJob(context.Background(), jobSecret, ports.CompleteJobParams{JobID: j.ID, Conclusion: domain.CISuccess})
114+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
115+ })
116+
117+ t.Run("an ended job answers with its end, not unauthenticated", func(t *testing.T) {
118+ t.Parallel()
119+ u := newCIUnit(t, nil)
120+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
121+ ended := replaced(j)
122+ ended.Phase, ended.Conclusion = domain.CIPhaseEnded, domain.CICancelled
123+ u.expectToken(jobSecret, j)
124+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(ended, nil)
125+ err := u.ci.UpdateJob(context.Background(), jobSecret, jobIDOne, []ports.StepUpdate{{Number: 1, State: domain.CIStepInProgress, StartedAt: now}})
126+ got, ok := domain.IsJobEnded(err)
127+ require.True(t, ok, "%v", err)
128+ assert.Equal(t, domain.CICancelled, got.Conclusion)
129+ })
130+}
131+
132+// S5: the job vanished while the log limit was handled: a plain not found, never
133+// a typed nil error.
134+func TestLogLimitOnAVanishedJobIsNotFound(t *testing.T) {
135+ t.Parallel()
136+ u := newCIUnit(t, nil)
137+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
138+ u.expectToken(jobSecret, j)
139+ // UpdateJob-style lookup under lock, then a log at the limit.
140+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
141+ u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(0, nil)
142+ u.logs.EXPECT().Append(mock.Anything, mock.Anything, int64(0), mock.Anything, int64(domain.CIMaxLogBytes)).Return(0, 0, ports.ErrCILogLimit)
143+ // stopForLogLimit loads the run, and the job is no longer in it.
144+ u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo}, nil)
145+ u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil)
146+ u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return(nil, nil)
147+
148+ _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 0, []domain.CILogLine{{Time: now, Text: "x"}})
149+ require.Error(t, err)
150+ assert.ErrorIs(t, err, domain.ErrNotFound)
151+ _, isEnded := domain.IsJobEnded(err)
152+ assert.False(t, isEnded)
153+}
154+
155+// expectRunJob makes GetJobLog find the job of the repository (a public one).
156+func (u *ciUnit) expectLogJob(j domain.CIJob) {
157+ u.meta.EXPECT().Get(mock.Anything, j.Repo).Return(domain.Repo{Name: j.Repo, Visibility: domain.VisibilityPublic}, nil)
158+ u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1}, nil)
159+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
160+}
161+
162+// S7: without follow a missing log is not found and an offset past the end is
163+// out of range; with follow a running job's reader waits for the lines, and a
164+// job that ended answers as without follow (follow changes nothing then).
165+func TestGetJobLogFollowAndOutOfRange(t *testing.T) {
166+ t.Parallel()
167+ p := domain.Anonymous()
168+ params := func(follow bool, offset int64) ports.GetJobLogParams {
169+ return ports.GetJobLogParams{JobID: jobIDOne, Offset: offset, Follow: follow}
170+ }
171+ send := func(ports.LogBatch) error { return nil }
172+
173+ t.Run("no follow: out of range", func(t *testing.T) {
174+ t.Parallel()
175+ u := newCIUnit(t, nil)
176+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
177+ u.expectLogJob(j)
178+ u.logs.EXPECT().Changed(mock.Anything).Return(make(chan struct{}))
179+ u.logs.EXPECT().Read(mock.Anything, mock.Anything, int64(5), mock.Anything, mock.Anything).Return(nil, domain.ErrOutOfRange)
180+ err := u.ci.GetJobLog(context.Background(), p, j.Repo, params(false, 5), send)
181+ assert.ErrorIs(t, err, domain.ErrOutOfRange)
182+ })
183+ t.Run("no follow: no log", func(t *testing.T) {
184+ t.Parallel()
185+ u := newCIUnit(t, nil)
186+ j := ciJob(t, domain.CIPhaseReady, jobSecret)
187+ u.expectLogJob(j)
188+ u.logs.EXPECT().Changed(mock.Anything).Return(make(chan struct{}))
189+ u.logs.EXPECT().Read(mock.Anything, mock.Anything, int64(0), mock.Anything, mock.Anything).Return(nil, domain.ErrNotFound)
190+ assert.ErrorIs(t, u.ci.GetJobLog(context.Background(), p, j.Repo, params(false, 0), send), domain.ErrNotFound)
191+ })
192+ t.Run("follow, ended: out of range like without follow", func(t *testing.T) {
193+ t.Parallel()
194+ u := newCIUnit(t, nil)
195+ j := ciJob(t, domain.CIPhaseEnded, jobSecret)
196+ u.expectLogJob(j)
197+ u.logs.EXPECT().Changed(mock.Anything).Return(make(chan struct{}))
198+ u.logs.EXPECT().Read(mock.Anything, mock.Anything, int64(5), mock.Anything, mock.Anything).Return(nil, domain.ErrOutOfRange)
199+ err := u.ci.GetJobLog(context.Background(), p, j.Repo, params(true, 5), send)
200+ assert.ErrorIs(t, err, domain.ErrOutOfRange)
201+ })
202+ t.Run("follow, running: waits for the line, then sends it", func(t *testing.T) {
203+ t.Parallel()
204+ u := newCIUnit(t, nil)
205+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
206+ u.expectLogJob(j)
207+ wake := make(chan struct{})
208+ u.logs.EXPECT().Changed(mock.Anything).Return((<-chan struct{})(wake))
209+ reads := 0
210+ u.logs.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
211+ func(_ context.Context, _ ports.CILogRef, from int64, _, _ int) ([]domain.CILogLine, error) {
212+ reads++
213+ if from == 0 && reads == 1 {
214+ return nil, domain.ErrOutOfRange // the runner has not written yet
215+ }
216+ if from == 0 {
217+ return []domain.CILogLine{{Time: now, Text: "late line"}}, nil
218+ }
219+ return nil, nil
220+ })
221+ ctx, cancel := context.WithCancel(context.Background())
222+ got := make(chan ports.LogBatch, 1)
223+ errc := make(chan error, 1)
224+ go func() {
225+ errc <- u.ci.GetJobLog(ctx, p, j.Repo, params(true, 0), func(b ports.LogBatch) error {
226+ got <- b
227+ cancel() // enough
228+ return nil
229+ })
230+ }()
231+ select {
232+ case <-got:
233+ t.Fatal("a line was sent before it existed")
234+ case <-time.After(150 * time.Millisecond):
235+ }
236+ close(wake) // the append happens
237+ select {
238+ case b := <-got:
239+ require.Len(t, b.Lines, 1)
240+ assert.Equal(t, "late line", b.Lines[0].Text)
241+ case <-time.After(5 * time.Second):
242+ t.Fatal("the reader did not wake up")
243+ }
244+ require.NoError(t, <-errc)
245+ })
246+}
247+
248+// S8: a stream that never idles revalidates the token too.
249+func TestGetJobLogRevalidatesABusyStream(t *testing.T) {
250+ t.Parallel()
251+ auth := mocks.NewRevalidator(t)
252+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Auth = auth })
253+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
254+ u.expectLogJob(j)
255+ u.logs.EXPECT().Changed(mock.Anything).Return(make(chan struct{}))
256+ // A token principal; the log keeps having lines to send.
257+ tok := domain.TokenPrincipal(domain.Token{ID: "t1", Scopes: []domain.Scope{domain.ScopeRepoRead}})
258+ u.logs.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return([]domain.CILogLine{{Time: now, Text: "x"}}, nil)
259+ revoked := domain.ErrUnauthenticated
260+ auth.EXPECT().Revalidate(mock.Anything, tok).Return(revoked).Once()
261+
262+ sent := 0
263+ start := time.Now()
264+ err := u.ci.GetJobLog(context.Background(), tok, j.Repo, ports.GetJobLogParams{JobID: jobIDOne, Follow: true}, func(ports.LogBatch) error {
265+ sent++
266+ time.Sleep(usecase.DefaultRevalidateEvery / 2)
267+ if time.Since(start) > 30*time.Second {
268+ return context.DeadlineExceeded
269+ }
270+ return nil
271+ })
272+ assert.ErrorIs(t, err, domain.ErrUnauthenticated, "a revoked token stops a busy stream")
273+ assert.GreaterOrEqual(t, sent, 2)
274+}
275+
276+// S9: more than 1,000 checks on a commit is an error for the merge rule, not a
277+// list cut at 1,000.
278+func TestCommitChecksRefusesATruncatedList(t *testing.T) {
279+ t.Parallel()
280+ u := newCIUnit(t, nil)
281+ repo := name(t, "proj")
282+ u.store.EXPECT().ListChecks(mock.Anything, repo, "abc", usecase.MaxCIChecks).Return(make([]domain.CICheck, usecase.MaxCIChecks), true, nil).Once()
283+ _, err := u.ci.CommitChecks(context.Background(), repo, "abc")
284+ assert.ErrorIs(t, err, domain.ErrResourceExhausted)
285+
286+ u.store.EXPECT().ListChecks(mock.Anything, repo, "def", usecase.MaxCIChecks).Return([]domain.CICheck{{Name: "a"}}, false, nil).Once()
287+ checks, err := u.ci.CommitChecks(context.Background(), repo, "def")
288+ require.NoError(t, err)
289+ assert.Len(t, checks, 1)
290+}
291+
292+// logCapture is a context whose logger writes into a buffer the test reads.
293+func logCapture(t *testing.T) (context.Context, *bytes.Buffer) {
294+ t.Helper()
295+ var buf bytes.Buffer
296+ log := zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})
297+ return zerowrap.WithCtx(context.Background(), log), &buf
298+}
299+
300+// M5, M6: the sweep removes the runs older than the retention (with the clock
301+// moved, not waited for), the logs of runs no record has any more, and says so
302+// when it cannot.
303+func TestSweep(t *testing.T) {
304+ t.Parallel()
305+ retention := 30 * 24 * time.Hour
306+
307+ t.Run("old runs go with their logs, orphan logs too", func(t *testing.T) {
308+ t.Parallel()
309+ u := newCIUnit(t, func(cfg *usecase.CIConfig) { cfg.Retention = retention })
310+ u.clock.t = now.Add(100 * 24 * time.Hour)
311+ ctx, out := logCapture(t)
312+ u.store.EXPECT().Sweep(mock.Anything, u.clock.t.Add(-retention)).Return([]int64{3, 4}, nil)
313+ u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{3, 4}).Return(nil).Once()
314+ u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{5, 6, 7}, nil)
315+ u.store.EXPECT().GetRunSource(mock.Anything, int64(5)).Return(domain.CIRunSource{}, nil)
316+ u.store.EXPECT().GetRunSource(mock.Anything, int64(6)).Return(domain.CIRunSource{}, domain.ErrNotFound)
317+ u.store.EXPECT().GetRunSource(mock.Anything, int64(7)).Return(domain.CIRunSource{}, domain.ErrNotFound)
318+ u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{6, 7}).Return(nil).Once()
319+ u.ci.Sweep(ctx)
320+ assert.NotContains(t, out.String(), `"level":"error"`)
321+ assert.Contains(t, out.String(), "old runs removed")
322+ })
323+
324+ t.Run("a store that fails ends the sweep, logged", func(t *testing.T) {
325+ t.Parallel()
326+ u := newCIUnit(t, nil)
327+ ctx, out := logCapture(t)
328+ u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, errors.New("kv closed"))
329+ u.ci.Sweep(ctx) // the log store expects no call
330+ assert.Contains(t, out.String(), "sweep the old runs")
331+ assert.Contains(t, out.String(), "kv closed")
332+ })
333+
334+ t.Run("a log directory that cannot be removed is logged and the orphans are still looked for", func(t *testing.T) {
335+ t.Parallel()
336+ u := newCIUnit(t, nil)
337+ ctx, out := logCapture(t)
338+ u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return([]int64{3}, nil)
339+ u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{3}).Return(errors.New("permission denied")).Once()
340+ u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil)
341+ u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, domain.ErrNotFound)
342+ u.logs.EXPECT().RemoveRuns(mock.Anything, []int64{9}).Return(errors.New("disk on fire")).Once()
343+ u.ci.Sweep(ctx)
344+ assert.Contains(t, out.String(), "remove the logs of the old runs")
345+ assert.Contains(t, out.String(), "permission denied")
346+ assert.Contains(t, out.String(), "remove the logs of runs that no longer exist")
347+ assert.Contains(t, out.String(), "disk on fire")
348+ })
349+
350+ t.Run("a listing of the log directories that fails is logged", func(t *testing.T) {
351+ t.Parallel()
352+ u := newCIUnit(t, nil)
353+ ctx, out := logCapture(t)
354+ u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil)
355+ u.logs.EXPECT().RunIDs(mock.Anything).Return(nil, errors.New("io error"))
356+ u.ci.Sweep(ctx)
357+ assert.Contains(t, out.String(), "list the log directories")
358+ })
359+
360+ t.Run("a run with a lookup error keeps its logs", func(t *testing.T) {
361+ t.Parallel()
362+ u := newCIUnit(t, nil)
363+ ctx, out := logCapture(t)
364+ u.store.EXPECT().Sweep(mock.Anything, mock.Anything).Return(nil, nil)
365+ u.logs.EXPECT().RunIDs(mock.Anything).Return([]int64{9}, nil)
366+ u.store.EXPECT().GetRunSource(mock.Anything, int64(9)).Return(domain.CIRunSource{}, errors.New("kv closed"))
367+ u.ci.Sweep(ctx) // RemoveRuns is not expected: an unknown state removes nothing
368+ assert.Contains(t, out.String(), "look up a run")
369+ })
370+}
371+
372+// M5: a log that would pass its limit appends nothing; the server adds a last
373+// line saying so, fails the job and answers failed_precondition with the end.
374+func TestLogLimitEndsTheJob(t *testing.T) {
375+ t.Parallel()
376+ u := newCIUnit(t, nil)
377+ j := ciJob(t, domain.CIPhaseRunning, jobSecret)
378+ j.Steps = []domain.CIStep{{Number: 1, State: domain.CIStepInProgress, StartedAt: now, HasLines: true}}
379+ u.expectToken(jobSecret, j)
380+ u.store.EXPECT().GetJob(mock.Anything, j.RunID, j.RunAttempt, j.ID).Return(j, nil)
381+ u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(10, nil)
382+ u.logs.EXPECT().Append(mock.Anything, mock.Anything, int64(10), mock.Anything, int64(domain.CIMaxLogBytes)).Return(0, 0, ports.ErrCILogLimit)
383+
384+ // stopForLogLimit: the run is loaded, the last line is forced in, the job ends.
385+ u.store.EXPECT().GetRunSource(mock.Anything, j.RunID).Return(domain.CIRunSource{RunID: j.RunID, Repo: j.Repo, WorkflowYAML: "on: push\njobs:\n build:\n runs-on: x\n steps:\n - run: echo\n"}, nil)
386+ u.store.EXPECT().GetRun(mock.Anything, j.Repo, j.RunID).Return(domain.CIRun{Repo: j.Repo, ID: j.RunID, Attempt: 1, WorkflowPath: "w.yml"}, nil)
387+ u.store.EXPECT().ListJobs(mock.Anything, j.RunID, int32(1)).Return([]domain.CIJob{j}, nil)
388+ var forced []domain.CILogLine
389+ u.logs.EXPECT().AppendForced(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
390+ func(_ context.Context, _ ports.CILogRef, lines []domain.CILogLine) (int64, error) {
391+ forced = lines
392+ return 11, nil
393+ })
394+ var written ports.CIWrite
395+ u.store.EXPECT().Apply(mock.Anything, mock.Anything).RunAndReturn(
396+ func(_ context.Context, w ports.CIWrite) (uint64, error) { written = w; return 5, nil })
397+ u.meta.EXPECT().Get(mock.Anything, j.Repo).Return(domain.Repo{Name: j.Repo, Visibility: domain.VisibilityPrivate}, nil)
398+ u.logs.EXPECT().Count(mock.Anything, mock.Anything).Return(11, nil).Maybe()
399+ u.bus.EXPECT().Publish(mock.Anything, mock.Anything).Return().Maybe()
400+
401+ _, err := u.ci.AppendLog(context.Background(), jobSecret, jobIDOne, 1, 10, []domain.CILogLine{{Time: now, Text: "one more"}})
402+ ended, ok := domain.IsJobEnded(err)
403+ require.True(t, ok, "%v", err)
404+ assert.Equal(t, domain.CIFailure, ended.Conclusion)
405+ assert.Contains(t, ended.Message, "512 MiB")
406+
407+ require.Len(t, forced, 1)
408+ assert.Contains(t, forced[0].Text, "##[error]")
409+ assert.Contains(t, forced[0].Text, "512 MiB")
410+ require.Len(t, written.Jobs, 1)
411+ assert.Equal(t, domain.CIPhaseEnded, written.Jobs[0].Phase)
412+ assert.Equal(t, domain.CIFailure, written.Jobs[0].Conclusion)
413+ assert.Equal(t, domain.CIStepCompleted, written.Jobs[0].Steps[0].State, "an open step is completed with the job")
414+}
A internal/usecase/ci_runs.go +584 -0
@@ -0,0 +1,584 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "encoding/base64"
8+ "errors"
9+ "fmt"
10+ "path"
11+ "slices"
12+ "strconv"
13+ "strings"
14+ "time"
15+
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+const runTokenPrefix = "ru1:"
21+
22+// --- runs and jobs ---
23+
24+// ListRuns returns a page of the runs of a repository, newest first. The
25+// principal needs the access of reading the repository.
26+func (c *CI) ListRuns(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.ListRunsParams) (ports.RunPage, error) {
27+ if _, err := c.readableRepo(ctx, p, name); err != nil {
28+ return ports.RunPage{}, err
29+ }
30+ n, err := pageSize(params.PageSize)
31+ if err != nil {
32+ return ports.RunPage{}, err
33+ }
34+ var before int64
35+ if params.PageToken != "" {
36+ raw, ok := decodeToken(params.PageToken, runTokenPrefix)
37+ id, perr := strconv.ParseInt(raw, 10, 64)
38+ if !ok || perr != nil || id < 1 {
39+ return ports.RunPage{}, fmt.Errorf("%w: runs", domain.ErrInvalidPageToken)
40+ }
41+ before = id
42+ }
43+ match := runFilter(params)
44+ // The store returns runs by id; the filters are applied while paging, so a
45+ // page of a filtered listing is full unless the runs ran out.
46+ var kept []domain.CIRun
47+ cursor := before
48+ for len(kept) <= n {
49+ batch, err := c.cfg.Store.ListRuns(ctx, name, cursor, n+1)
50+ if err != nil {
51+ return ports.RunPage{}, err
52+ }
53+ for _, r := range batch {
54+ if match(r) {
55+ kept = append(kept, r)
56+ }
57+ }
58+ if len(batch) < n+1 {
59+ break
60+ }
61+ cursor = batch[len(batch)-1].ID
62+ }
63+ page := ports.RunPage{Runs: kept}
64+ if len(kept) > n {
65+ page.Runs = kept[:n]
66+ page.NextPageToken = base64.RawURLEncoding.EncodeToString([]byte(runTokenPrefix + strconv.FormatInt(kept[n-1].ID, 10)))
67+ }
68+ return page, nil
69+}
70+
71+// runFilter returns the test of the filters of a listing.
72+func runFilter(p ports.ListRunsParams) func(domain.CIRun) bool {
73+ return func(r domain.CIRun) bool {
74+ switch {
75+ case p.Status != "" && r.Status != p.Status:
76+ return false
77+ case p.Branch != "" && r.Ref != "refs/heads/"+p.Branch:
78+ return false
79+ case p.Workflow != "" && r.WorkflowPath != p.Workflow && path.Base(r.WorkflowPath) != p.Workflow:
80+ return false
81+ }
82+ return true
83+ }
84+}
85+
86+// runOf finds a run of the repository; a run of another repository is not
87+// found.
88+func (c *CI) runOf(ctx context.Context, name domain.RepoName, runID int64) (domain.CIRun, error) {
89+ run, err := c.cfg.Store.GetRun(ctx, name, runID)
90+ if err != nil {
91+ return domain.CIRun{}, err
92+ }
93+ return run, nil
94+}
95+
96+// view adds the live line count to a job.
97+func (c *CI) view(ctx context.Context, j domain.CIJob) ports.JobView {
98+ v := ports.JobView{CIJob: j}
99+ if j.Phase != domain.CIPhaseWaiting && j.Phase != domain.CIPhaseReady {
100+ v.Lines, _ = c.cfg.Logs.Count(ctx, logRef(j))
101+ }
102+ if j.RunnerID != "" {
103+ if _, err := c.cfg.Store.GetRunner(ctx, j.RunnerID); errors.Is(err, domain.ErrNotFound) {
104+ v.RunnerGone = true
105+ }
106+ }
107+ return v
108+}
109+
110+// GetRun returns a run with the jobs of an attempt (0 for the latest).
111+func (c *CI) GetRun(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64, attempt int32) (ports.RunDetail, error) {
112+ if _, err := c.readableRepo(ctx, p, name); err != nil {
113+ return ports.RunDetail{}, err
114+ }
115+ run, err := c.runOf(ctx, name, runID)
116+ if err != nil {
117+ return ports.RunDetail{}, err
118+ }
119+ latest := run.Attempt
120+ if attempt < 0 || attempt > latest {
121+ return ports.RunDetail{}, domain.ErrNotFound
122+ }
123+ if attempt == 0 {
124+ attempt = latest
125+ }
126+ jobs, err := c.cfg.Store.ListJobs(ctx, runID, attempt)
127+ if err != nil {
128+ return ports.RunDetail{}, err
129+ }
130+ if attempt != latest {
131+ run = c.runAtAttempt(run, jobs, attempt)
132+ }
133+ d := ports.RunDetail{Run: run, LatestAttempt: latest}
134+ for _, j := range jobs {
135+ d.Jobs = append(d.Jobs, c.view(ctx, j))
136+ }
137+ return d, nil
138+}
139+
140+// runAtAttempt describes an earlier attempt of a run from its jobs: the run
141+// record holds the latest attempt only.
142+func (c *CI) runAtAttempt(run domain.CIRun, jobs []domain.CIJob, attempt int32) domain.CIRun {
143+ rs := &runState{c: c, run: run, jobs: jobs, attempt: attempt}
144+ r := rs.recompute(c.now())
145+ r.Attempt, r.Version = attempt, run.Version
146+ r.CancelRequested = false
147+ return r
148+}
149+
150+// jobOf finds a job of the repository by its id and attempt (0 for the
151+// latest). The attempt is the one of the run it was kept by, or executed in.
152+func (c *CI) jobOf(ctx context.Context, name domain.RepoName, jobID string, attempt int32) (domain.CIRun, domain.CIJob, error) {
153+ runID, _, _, ok := domain.ParseJobID(jobID)
154+ if !ok {
155+ return domain.CIRun{}, domain.CIJob{}, domain.ErrNotFound
156+ }
157+ run, err := c.runOf(ctx, name, runID)
158+ if err != nil {
159+ return domain.CIRun{}, domain.CIJob{}, err
160+ }
161+ if attempt < 0 || attempt > run.Attempt {
162+ return domain.CIRun{}, domain.CIJob{}, domain.ErrNotFound
163+ }
164+ if attempt == 0 {
165+ attempt = run.Attempt
166+ }
167+ j, err := c.cfg.Store.GetJob(ctx, runID, attempt, jobID)
168+ if err != nil {
169+ return domain.CIRun{}, domain.CIJob{}, err
170+ }
171+ return run, j, nil
172+}
173+
174+// GetJob returns one job with its steps.
175+func (c *CI) GetJob(ctx context.Context, p domain.Principal, name domain.RepoName, jobID string, attempt int32) (ports.JobView, error) {
176+ if _, err := c.readableRepo(ctx, p, name); err != nil {
177+ return ports.JobView{}, err
178+ }
179+ _, j, err := c.jobOf(ctx, name, jobID, attempt)
180+ if err != nil {
181+ return ports.JobView{}, err
182+ }
183+ return c.view(ctx, j), nil
184+}
185+
186+// --- log streaming ---
187+
188+// acquireStream counts a log stream against the limits.
189+func (c *CI) acquireStream(client string) (release func(), err error) {
190+ c.mu.Lock()
191+ defer c.mu.Unlock()
192+ if c.streams >= MaxCILogStreams || (client != "" && c.perClient[client] >= MaxCILogStreamsPerClient) {
193+ return nil, fmt.Errorf("%w: too many log streams", domain.ErrResourceExhausted)
194+ }
195+ c.streams++
196+ if client != "" {
197+ c.perClient[client]++
198+ }
199+ return func() {
200+ c.mu.Lock()
201+ defer c.mu.Unlock()
202+ c.streams--
203+ if client != "" {
204+ if c.perClient[client]--; c.perClient[client] <= 0 {
205+ delete(c.perClient, client)
206+ }
207+ }
208+ }, nil
209+}
210+
211+// GetJobLog streams the lines of a job log from an offset, in batches of at
212+// most 1,000 lines and 64 KiB. With Follow it goes on while the job runs, and
213+// ends once the job completed and every line was sent.
214+func (c *CI) GetJobLog(ctx context.Context, p domain.Principal, name domain.RepoName, params ports.GetJobLogParams, send func(ports.LogBatch) error) error {
215+ if _, err := c.readableRepo(ctx, p, name); err != nil {
216+ return err
217+ }
218+ if params.Offset < 0 {
219+ return fmt.Errorf("%w: negative offset", domain.ErrInvalid)
220+ }
221+ _, j, err := c.jobOf(ctx, name, params.JobID, params.Attempt)
222+ if err != nil {
223+ return err
224+ }
225+ release, err := c.acquireStream(params.Client)
226+ if err != nil {
227+ return err
228+ }
229+ defer release()
230+ if params.Ready != nil {
231+ params.Ready()
232+ }
233+ ref := logRef(j)
234+ jref := jobRef(j)
235+ offset := params.Offset
236+ checked := time.Now()
237+ // revalidate asks again whether the token of a stored-token caller still
238+ // exists, at most every few seconds, whether the stream is busy or idle: a
239+ // token revoked while a long log is sent stops the stream.
240+ revalidate := func() error {
241+ if p.TokenID() == "" || c.cfg.Auth == nil || time.Since(checked) <= DefaultRevalidateEvery {
242+ return nil
243+ }
244+ if err := c.cfg.Auth.Revalidate(ctx, p); err != nil {
245+ return err
246+ }
247+ checked = time.Now()
248+ return nil
249+ }
250+ for {
251+ wakeJob, wakeLog := c.jobs.wait(jref), c.cfg.Logs.Changed(ref)
252+ cur, err := c.cfg.Store.GetJob(ctx, j.RunID, j.RunAttempt, j.ID)
253+ if err != nil {
254+ return err
255+ }
256+ done := cur.Ended()
257+ lines, err := c.cfg.Logs.Read(ctx, ref, offset, domain.CIMaxLogLines, domain.CIMaxLogChunkBytes)
258+ switch {
259+ case errors.Is(err, domain.ErrNotFound), errors.Is(err, domain.ErrOutOfRange):
260+ // No line yet (a queued job has no log), or fewer lines than the
261+ // offset. Without follow, and for a job that ended (follow changes
262+ // nothing then), that is the answer (contract: not_found,
263+ // out_of_range). A job that runs may still write the line: wait.
264+ if !params.Follow || done {
265+ if errors.Is(err, domain.ErrNotFound) {
266+ return domain.ErrNotFound
267+ }
268+ return fmt.Errorf("%w: the log has fewer than %d lines", domain.ErrOutOfRange, offset)
269+ }
270+ lines = nil
271+ case err != nil:
272+ return err
273+ }
274+ if len(lines) > 0 {
275+ if err := revalidate(); err != nil {
276+ return err
277+ }
278+ batch := ports.LogBatch{Offset: offset, Lines: c.withSteps(ctx, cur, offset, lines)}
279+ if err := send(batch); err != nil {
280+ return err
281+ }
282+ offset += int64(len(lines))
283+ continue // more may be waiting
284+ }
285+ if !params.Follow || done {
286+ return nil
287+ }
288+ if err := revalidate(); err != nil {
289+ return err
290+ }
291+ select {
292+ case <-wakeJob:
293+ case <-wakeLog:
294+ case <-time.After(DefaultRevalidateEvery):
295+ case <-ctx.Done():
296+ return nil
297+ case <-c.ctx.Done():
298+ return nil
299+ }
300+ }
301+}
302+
303+// withSteps tags the lines of a batch with their step from the step ranges.
304+func (c *CI) withSteps(_ context.Context, j domain.CIJob, offset int64, lines []domain.CILogLine) []ports.JobLogLine {
305+ out := make([]ports.JobLogLine, len(lines))
306+ for i, l := range lines {
307+ out[i] = ports.JobLogLine{Step: domain.StepOfLine(j.Steps, offset+int64(i)), CILogLine: l}
308+ }
309+ return out
310+}
311+
312+// --- cancel and rerun ---
313+
314+// CancelRun cancels the latest attempt of a run. The principal needs ci:write
315+// on the repository. Jobs not given to a runner end at once; the others get a
316+// cancel request, and the run completes when they have ended.
317+func (c *CI) CancelRun(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64) (domain.CIRun, error) {
318+ if _, err := c.writableRepo(ctx, p, name); err != nil {
319+ return domain.CIRun{}, err
320+ }
321+ if _, err := c.runOf(ctx, name, runID); err != nil {
322+ return domain.CIRun{}, err
323+ }
324+ var out domain.CIRun
325+ err := c.withRun(ctx, runID, 0, func(rs *runState) error {
326+ if rs.run.Status == domain.CICompleted {
327+ return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID)
328+ }
329+ if !rs.run.CancelRequested {
330+ rs.run.CancelRequested, rs.run.CancelRequestedAt, rs.runDirty = true, c.now(), true
331+ rs.cancelAll("Canceled by " + authorOf(p))
332+ }
333+ if err := rs.flush(ctx); err != nil {
334+ return err
335+ }
336+ out = rs.run
337+ return nil
338+ })
339+ if err != nil {
340+ return domain.CIRun{}, err
341+ }
342+ ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", runID).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("ci run cancelled")
343+ return out, nil
344+}
345+
346+// RerunRun starts a new attempt of a completed run, from the stored workflow
347+// file: all the jobs, or with failedOnly the jobs that did not succeed and
348+// those that need them; the others are kept with their result, steps and log.
349+func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error) {
350+ repo, err := c.writableRepo(ctx, p, name)
351+ if err != nil {
352+ return domain.CIRun{}, err
353+ }
354+ if _, err := c.runOf(ctx, name, runID); err != nil {
355+ return domain.CIRun{}, err
356+ }
357+ err = c.withRun(ctx, runID, 0, func(rs *runState) error {
358+ if rs.run.Status != domain.CICompleted {
359+ return fmt.Errorf("%w: run %d has not completed", domain.ErrPreconditionFailed, runID)
360+ }
361+ if len(rs.jobs) == 0 {
362+ return fmt.Errorf("%w: run %d has no job to run again", domain.ErrPreconditionFailed, runID)
363+ }
364+ redo, err := rerunSet(rs, failedOnly)
365+ if err != nil {
366+ return err
367+ }
368+ next := rs.run.Attempt + 1
369+ now := c.now()
370+ w := ports.CIWrite{}
371+ var fresh []domain.CIJob
372+ for _, j := range rs.jobs {
373+ if !redo[j.Key] {
374+ kept := j // kept with its own attempt, steps and log
375+ kept.RunAttempt, kept.Version = next, 0
376+ w.Jobs = append(w.Jobs, kept)
377+ fresh = append(fresh, kept)
378+ continue
379+ }
380+ if j.Combo > 0 {
381+ continue // the matrix is expanded again when the job is planned
382+ }
383+ n := domain.CIJob{
384+ ID: domain.NewJobID(runID, j.Index, 0), RunID: runID, Attempt: next, RunAttempt: next, Repo: rs.run.Repo,
385+ Key: j.Key, Name: j.Key, Phase: domain.CIPhaseWaiting, Needs: slices.Clone(j.Needs), Index: j.Index, CreatedAt: now,
386+ }
387+ w.Jobs = append(w.Jobs, n)
388+ fresh = append(fresh, n)
389+ }
390+ run := rs.run
391+ run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion
392+ run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now
393+ run.CancelRequested, run.CancelRequestedAt = false, time.Time{}
394+ w.Runs = []domain.CIRun{run}
395+ rs2 := &runState{c: c, run: run, attempt: next, jobs: fresh, src: rs.src, dirty: map[string]bool{}, checks: map[string]bool{}}
396+ for _, j := range fresh {
397+ w.Checks = append(w.Checks, rs2.checkOf(j))
398+ }
399+ v, err := c.cfg.Store.Apply(ctx, w)
400+ if err != nil {
401+ return err
402+ }
403+ run.Version = v
404+ c.flows.drop(runID)
405+ c.publishRun(ctx, repo, run)
406+ for _, j := range w.Jobs {
407+ j.Version = v
408+ c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
409+ }
410+ return nil
411+ })
412+ if err != nil {
413+ return domain.CIRun{}, err
414+ }
415+ // Plan the new attempt: the kept jobs count as the results of their needs.
416+ if err := c.advanceRun(ctx, runID); err != nil {
417+ return domain.CIRun{}, err
418+ }
419+ run, err := c.runOf(ctx, name, runID)
420+ if err != nil {
421+ return domain.CIRun{}, err
422+ }
423+ ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", runID).Int32("attempt", run.Attempt).Bool("failed_only", failedOnly).
424+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("ci run started again")
425+ return run, nil
426+}
427+
428+// rerunSet returns the keys of the jobs to run again: all of them, or the ones
429+// that did not succeed and those that need them, directly or not.
430+func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) {
431+ redo := map[string]bool{}
432+ if !failedOnly {
433+ for _, j := range rs.jobs {
434+ redo[j.Key] = true
435+ }
436+ return redo, nil
437+ }
438+ for _, j := range rs.jobs {
439+ switch j.Conclusion {
440+ case domain.CIFailure, domain.CICancelled, domain.CITimedOut, domain.CIActionRequired:
441+ redo[j.Key] = true
442+ }
443+ }
444+ if len(redo) == 0 {
445+ return nil, fmt.Errorf("%w: no job of run %d failed", domain.ErrPreconditionFailed, rs.run.ID)
446+ }
447+ for changed := true; changed; {
448+ changed = false
449+ for _, j := range rs.jobs {
450+ if redo[j.Key] {
451+ continue
452+ }
453+ if slices.ContainsFunc(j.Needs, func(n string) bool { return redo[n] }) {
454+ redo[j.Key], changed = true, true
455+ }
456+ }
457+ }
458+ return redo, nil
459+}
460+
461+func (w *wfCache) drop(runID int64) {
462+ w.mu.Lock()
463+ delete(w.m, runID)
464+ w.mu.Unlock()
465+}
466+
467+// --- checks ---
468+
469+// CommitChecks returns the checks of a commit, without access control. A
470+// commit with more than MaxCIChecks checks is an error (domain.ErrResourceExhausted):
471+// the merge rule cannot answer from a part of them.
472+func (c *CI) CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) {
473+ checks, more, err := c.cfg.Store.ListChecks(ctx, repo, sha, MaxCIChecks)
474+ if err != nil {
475+ return nil, err
476+ }
477+ if more {
478+ return nil, fmt.Errorf("%w: the commit has more than %d checks", domain.ErrResourceExhausted, MaxCIChecks)
479+ }
480+ return checks, nil
481+}
482+
483+// ListChecks returns the checks of a commit: the one of each job of the latest
484+// attempt of each run on it. A commit without a run has none.
485+func (c *CI) ListChecks(ctx context.Context, p domain.Principal, name domain.RepoName, sha string) (ports.ChecksResult, error) {
486+ if _, err := c.readableRepo(ctx, p, name); err != nil {
487+ return ports.ChecksResult{}, err
488+ }
489+ if !isHex40(sha) {
490+ return ports.ChecksResult{}, fmt.Errorf("%w: a commit is 40 lowercase hexadecimal characters", domain.ErrInvalidObjectID)
491+ }
492+ checks, more, err := c.cfg.Store.ListChecks(ctx, name, sha, MaxCIChecks)
493+ if err != nil {
494+ return ports.ChecksResult{}, err
495+ }
496+ res := ports.ChecksResult{Truncated: more}
497+ var runners []domain.CIRunner
498+ for _, ck := range checks {
499+ v := ports.CheckView{CICheck: ck}
500+ if ck.Status == domain.CIQueued {
501+ if runners == nil {
502+ runners, _ = c.cfg.Store.ListRunners(ctx)
503+ }
504+ v.WaitingForRunner = c.noRunnerFor(ctx, ck, runners)
505+ }
506+ res.Checks = append(res.Checks, v)
507+ }
508+ return res, nil
509+}
510+
511+// noRunnerFor reports whether no registered runner can take a queued check's
512+// job. A job that waits for its needs is not waiting for a runner.
513+func (c *CI) noRunnerFor(ctx context.Context, ck domain.CICheck, runners []domain.CIRunner) bool {
514+ if ck.JobID == "" {
515+ return false
516+ }
517+ j, err := c.cfg.Store.GetJob(ctx, ck.RunID, ck.Attempt, ck.JobID)
518+ if err != nil || j.Phase != domain.CIPhaseReady {
519+ return false
520+ }
521+ return !slices.ContainsFunc(runners, func(r domain.CIRunner) bool { return domain.LabelsMatch(j.Labels, r.Labels) })
522+}
523+
524+// --- runners ---
525+
526+// ListRunners returns a page of the runners ordered by name. The principal
527+// needs runner:register on a token that is not restricted to repositories.
528+func (c *CI) ListRunners(ctx context.Context, p domain.Principal, size int, token string) (ports.RunnerPage, error) {
529+ if err := canManageRunners(p); err != nil {
530+ return ports.RunnerPage{}, err
531+ }
532+ n, err := pageSize(size)
533+ if err != nil {
534+ return ports.RunnerPage{}, err
535+ }
536+ after := ""
537+ if token != "" {
538+ raw, ok := decodeToken(token, "rn1:")
539+ if !ok || raw == "" {
540+ return ports.RunnerPage{}, fmt.Errorf("%w: runners", domain.ErrInvalidPageToken)
541+ }
542+ after = raw
543+ }
544+ all, err := c.cfg.Store.ListRunners(ctx)
545+ if err != nil {
546+ return ports.RunnerPage{}, err
547+ }
548+ busy := map[string]bool{}
549+ if active, err := c.cfg.Store.ListActiveJobs(ctx); err == nil {
550+ for _, j := range active {
551+ if j.Phase == domain.CIPhaseRunning {
552+ busy[j.RunnerID] = true
553+ }
554+ }
555+ }
556+ var page ports.RunnerPage
557+ var rows []domain.CIRunner
558+ rows, page.NextPageToken = pageAfter(all, n,
559+ func(r domain.CIRunner) bool { return after == "" || strings.ToLower(r.Name) > after },
560+ func(r domain.CIRunner) string {
561+ return base64.RawURLEncoding.EncodeToString([]byte("rn1:" + strings.ToLower(r.Name)))
562+ })
563+ c.mu.Lock()
564+ defer c.mu.Unlock()
565+ for _, r := range rows {
566+ page.Runners = append(page.Runners, ports.RunnerView{CIRunner: r, LastSeen: c.seen[r.ID], Busy: busy[r.ID]})
567+ }
568+ return page, nil
569+}
570+
571+// RemoveRunner removes a runner: its token stops working at once and a poll of
572+// it in progress ends with unauthenticated. A job it runs is not affected.
573+func (c *CI) RemoveRunner(ctx context.Context, p domain.Principal, id string) error {
574+ if err := canManageRunners(p); err != nil {
575+ return err
576+ }
577+ r, err := c.cfg.Store.RemoveRunner(ctx, id)
578+ if err != nil {
579+ return err
580+ }
581+ c.forgetRunners([]domain.CIRunner{r})
582+ ctxLog(ctx).Info().Str("runner_id", r.ID).Str("runner", r.Name).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("runner removed")
583+ return nil
584+}
A internal/usecase/ci_trigger.go +512 -0
@@ -0,0 +1,512 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "encoding/json"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "path"
12+ "runtime/debug"
13+ "slices"
14+ "strconv"
15+ "strings"
16+ "time"
17+
18+ "github.com/bnema/zerowrap"
19+
20+ "bnema.dev/ermite-actions/workflow"
21+ "bnema.dev/ermite/internal/domain"
22+ "bnema.dev/ermite/internal/ports"
23+)
24+
25+const (
26+ workflowDir = ".github/workflows"
27+ // maxRunError bounds Run.error (8 KiB, run.proto).
28+ maxRunError = 8 << 10
29+ // maxPushCommits is the number of commits of the push event payload.
30+ maxPushCommits = 20
31+)
32+
33+// ciTrigger is a push waiting to be planned.
34+type ciTrigger struct {
35+ repo domain.RepoName
36+ updates []domain.RefUpdate
37+ actor string
38+ at time.Time
39+ log zerowrap.Logger
40+}
41+
42+// OnPush queues the refs of a push or a merge for planning and returns at once;
43+// a worker plans them. A full queue drops the trigger, logged at error: a push
44+// is never slowed down or failed by CI.
45+func (c *CI) OnPush(ctx context.Context, repo domain.RepoName, updates []domain.RefUpdate, pusher domain.Principal) {
46+ var kept []domain.RefUpdate
47+ for _, u := range updates {
48+ if !u.IsDelete() && (strings.HasPrefix(u.Ref, "refs/heads/") || strings.HasPrefix(u.Ref, "refs/tags/")) {
49+ kept = append(kept, u)
50+ }
51+ }
52+ if len(kept) == 0 {
53+ return
54+ }
55+ t := ciTrigger{repo: repo, updates: kept, actor: authorOf(pusher), at: c.now(), log: zerowrap.FromCtx(ctx)}
56+ c.mu.Lock()
57+ closed := c.closed
58+ c.mu.Unlock()
59+ if closed {
60+ return
61+ }
62+ select {
63+ case c.queue <- t:
64+ default:
65+ t.log.Error().Str("repo", repo.String()).Int("refs", len(kept)).Msg("ci trigger queue is full: the workflows of this push are not run")
66+ }
67+}
68+
69+func (c *CI) triggerLoop() {
70+ defer c.wg.Done()
71+ for {
72+ select {
73+ case <-c.ctx.Done():
74+ return
75+ case t := <-c.queue:
76+ c.planPush(t)
77+ }
78+ }
79+}
80+
81+// planPush plans one push; a panic or an error is logged and never escapes.
82+func (c *CI) planPush(t ciTrigger) {
83+ ctx := zerowrap.WithCtx(c.ctx, t.log)
84+ defer func() {
85+ if r := recover(); r != nil {
86+ t.log.Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("repo", t.repo.String()).Msg("ci trigger panicked")
87+ }
88+ }()
89+ if err := c.handlePush(ctx, t); err != nil && ctx.Err() == nil {
90+ t.log.Error().Err(err).Str("repo", t.repo.String()).Msg("ci trigger failed")
91+ }
92+}
93+
94+// handlePush starts the workflows that match each ref of the push.
95+func (c *CI) handlePush(ctx context.Context, t ciTrigger) error {
96+ // CI is on once a runner is registered (plan, "Config"): without one a push
97+ // makes no run and no check, so a forge that does not use CI shows nothing.
98+ runners, err := c.cfg.Store.ListRunners(ctx)
99+ if err != nil {
100+ return fmt.Errorf("list the runners: %w", err)
101+ }
102+ if len(runners) == 0 {
103+ return nil
104+ }
105+ tags := 0
106+ for _, u := range t.updates {
107+ if strings.HasPrefix(u.Ref, "refs/tags/") {
108+ tags++
109+ }
110+ }
111+ var errs []error
112+ for _, u := range t.updates {
113+ if err := c.handleRef(ctx, t, u, tags); err != nil {
114+ errs = append(errs, fmt.Errorf("%s: %w", u.Ref, err))
115+ }
116+ }
117+ return errors.Join(errs...)
118+}
119+
120+// pushFacts is what is known of one pushed ref.
121+type pushFacts struct {
122+ update domain.RefUpdate
123+ commit domain.ObjectID // the commit the ref points to, tags followed
124+ head domain.Commit
125+ commits []domain.Commit // oldest first, at most maxPushCommits (branches only)
126+ forced bool
127+ created bool
128+ files []string
129+ // filesTruncated: the list of changed files is not available.
130+ filesTruncated bool
131+ tagCount int
132+ isTag bool
133+ // defaultBranch is the default branch of the repository, "" when unknown.
134+ defaultBranch string
135+}
136+
137+func (c *CI) handleRef(ctx context.Context, t ciTrigger, u domain.RefUpdate, tagCount int) error {
138+ isTag := strings.HasPrefix(u.Ref, "refs/tags/")
139+ if isTag && tagCount > workflow.MaxTagsPerPush {
140+ return nil // GitHub creates no event for the tags of such a push
141+ }
142+ repo, err := c.cfg.Meta.Get(ctx, t.repo)
143+ if err != nil {
144+ return nil // deleted meanwhile
145+ }
146+ facts, err := c.collect(ctx, t.repo, u, isTag, tagCount)
147+ if err != nil {
148+ return err
149+ }
150+ files, err := c.workflowFiles(ctx, t.repo, facts.commit)
151+ if err != nil || len(files) == 0 {
152+ return err
153+ }
154+ skip := workflow.SkipCI(facts.head.Message)
155+ var errs []error
156+ for _, f := range files {
157+ wf, perrs := workflow.Parse(f.path, f.content)
158+ switch {
159+ case f.problem != "":
160+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, f.problem))
161+ case len(perrs) > 0:
162+ var lines []string
163+ for _, e := range perrs {
164+ lines = append(lines, e.Error())
165+ }
166+ errs = append(errs, c.startInvalid(ctx, repo, t, facts, f.path, strings.Join(lines, "\n")))
167+ case skip || !wf.MatchPush(workflow.PushInfo{
168+ Ref: u.Ref, Created: facts.created, ChangedFiles: facts.files, FilesTruncated: facts.filesTruncated, TagCount: tagCount,
169+ }):
170+ default:
171+ errs = append(errs, c.startRun(ctx, repo, t, facts, f.path, string(f.content), wf))
172+ }
173+ }
174+ return errors.Join(errs...)
175+}
176+
177+// collect reads what the payload and the filters need from Git.
178+func (c *CI) collect(ctx context.Context, repo domain.RepoName, u domain.RefUpdate, isTag bool, tagCount int) (pushFacts, error) {
179+ newID, err := domain.ParseObjectID(u.New)
180+ if err != nil {
181+ return pushFacts{}, err
182+ }
183+ f := pushFacts{update: u, created: u.IsCreate(), isTag: isTag, tagCount: tagCount}
184+ if def, _, err := c.cfg.Repos.DefaultBranch(ctx, repo); err == nil {
185+ f.defaultBranch = def
186+ }
187+ res, err := c.cfg.Reader.ResolveRevision(ctx, repo, domain.RevisionOf(newID))
188+ if err != nil {
189+ return f, fmt.Errorf("resolve %s: %w", u.New, err)
190+ }
191+ f.commit = res.CommitID
192+ if isTag {
193+ cs, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
194+ if err != nil {
195+ return f, fmt.Errorf("read the commit of the tag: %w", err)
196+ }
197+ if len(cs) == 0 {
198+ return f, fmt.Errorf("read the commit of the tag: %w", domain.ErrNotFound)
199+ }
200+ f.head = cs[0]
201+ return f, nil
202+ }
203+
204+ var exclude domain.ObjectID
205+ if !f.created {
206+ if old, err := domain.ParseObjectID(u.Old); err == nil {
207+ exclude = old
208+ if ok, err := c.cfg.Git.IsAncestor(ctx, repo, old, f.commit); err == nil && !ok {
209+ f.forced = true
210+ }
211+ }
212+ }
213+ base := exclude
214+ if f.created {
215+ base = c.defaultBase(ctx, repo, strings.TrimPrefix(u.Ref, "refs/heads/"), f.commit)
216+ exclude = base
217+ }
218+ cs, more, err := c.cfg.Git.ListRange(ctx, repo, exclude, f.commit, ports.MaxCommitLimit)
219+ if err != nil {
220+ return f, fmt.Errorf("read the commits of the push: %w", err)
221+ }
222+ if len(cs) == 0 {
223+ // A new branch at a commit the default branch has, or a branch rewound
224+ // to an ancestor: no new commit, no changed file. GitHub still runs
225+ // the push workflows that have no paths filter, for the head commit.
226+ head, _, err := c.cfg.Git.ListRange(ctx, repo, domain.ObjectID{}, f.commit, 1)
227+ if err != nil {
228+ return f, fmt.Errorf("read the head commit of the push: %w", err)
229+ }
230+ if len(head) == 0 {
231+ return f, fmt.Errorf("read the head commit of the push: %w", domain.ErrNotFound)
232+ }
233+ f.head = head[0]
234+ return f, nil
235+ }
236+ f.head = cs[0]
237+ n := min(len(cs), maxPushCommits)
238+ f.commits = slices.Clone(cs[:n])
239+ slices.Reverse(f.commits)
240+ if more || base == f.commit {
241+ // More than 1,000 commits: the diff is not looked at (it runs).
242+ f.filesTruncated = more
243+ }
244+ if !more && base != f.commit {
245+ paths, _, err := c.cfg.Diff.ChangedPaths(ctx, repo, base, f.commit, workflow.MaxFilesEvaluated)
246+ if err != nil {
247+ f.filesTruncated = true // diff unavailable: runs
248+ } else {
249+ f.files = paths
250+ }
251+ }
252+ return f, nil
253+}
254+
255+// defaultBase is the merge base of a new branch with the default branch, the
256+// zero id when there is none (every file of the branch counts).
257+func (c *CI) defaultBase(ctx context.Context, repo domain.RepoName, branch string, head domain.ObjectID) domain.ObjectID {
258+ def, exists, err := c.cfg.Repos.DefaultBranch(ctx, repo)
259+ if err != nil || !exists || def == branch {
260+ return domain.ObjectID{}
261+ }
262+ tip, err := c.cfg.Git.BranchHead(ctx, repo, def)
263+ if err != nil {
264+ return domain.ObjectID{}
265+ }
266+ base, err := c.cfg.Git.MergeBase(ctx, repo, tip, head)
267+ if err != nil {
268+ return domain.ObjectID{}
269+ }
270+ return base
271+}
272+
273+type workflowFile struct {
274+ path string
275+ content []byte
276+ // problem is set when the file cannot even be parsed (too large).
277+ problem string
278+}
279+
280+// workflowFiles reads .github/workflows/*.yml|*.yaml at the commit.
281+func (c *CI) workflowFiles(ctx context.Context, repo domain.RepoName, commit domain.ObjectID) ([]workflowFile, error) {
282+ dir, err := domain.ParseTreePath(workflowDir)
283+ if err != nil {
284+ return nil, err
285+ }
286+ tree, err := c.cfg.Reader.GetTree(ctx, repo, domain.RevisionOf(commit), dir, false)
287+ if errors.Is(err, domain.ErrNotFound) || errors.Is(err, domain.ErrNotDirectory) {
288+ return nil, nil
289+ }
290+ if err != nil {
291+ return nil, fmt.Errorf("list %s: %w", workflowDir, err)
292+ }
293+ var out []workflowFile
294+ for _, e := range tree.Entries {
295+ ext := strings.ToLower(path.Ext(e.Name))
296+ if (e.Kind != domain.EntryFile && e.Kind != domain.EntryExecutable) || (ext != ".yml" && ext != ".yaml") {
297+ continue
298+ }
299+ if len(out) == maxCIWorkflowFiles {
300+ break
301+ }
302+ wf := workflowFile{path: e.Path}
303+ if wf.path == "" {
304+ wf.path = workflowDir + "/" + e.Name
305+ }
306+ if e.Size > maxCIWorkflowBytes {
307+ wf.problem = fmt.Sprintf("%s: the workflow file is larger than 1 MiB", wf.path)
308+ out = append(out, wf)
309+ continue
310+ }
311+ tp, err := domain.ParseTreePath(wf.path)
312+ if err != nil {
313+ continue
314+ }
315+ _, rc, err := c.cfg.Reader.OpenBlob(ctx, repo, domain.RevisionOf(commit), tp)
316+ if err != nil {
317+ return nil, fmt.Errorf("read %s: %w", wf.path, err)
318+ }
319+ wf.content, err = io.ReadAll(io.LimitReader(rc, maxCIWorkflowBytes+1))
320+ _ = rc.Close()
321+ if err != nil {
322+ return nil, fmt.Errorf("read %s: %w", wf.path, err)
323+ }
324+ if len(wf.content) > maxCIWorkflowBytes {
325+ wf.content, wf.problem = nil, fmt.Sprintf("%s: the workflow file is larger than 1 MiB", wf.path)
326+ }
327+ out = append(out, wf)
328+ }
329+ return out, nil
330+}
331+
332+// --- the run ---
333+
334+func firstLine(s string) string {
335+ line, _, _ := strings.Cut(s, "\n")
336+ return truncateRunes(strings.TrimSpace(line), 256)
337+}
338+
339+// startInvalid records the failed run of an invalid workflow file: no job, the
340+// errors as Run.error, like GitHub's "Invalid workflow file".
341+func (c *CI) startInvalid(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, problem string) error {
342+ now := c.now()
343+ draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
344+ run := domain.CIRun{
345+ Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: wfPath,
346+ Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
347+ Status: domain.CICompleted, Conclusion: domain.CIFailure, Error: cutText(problem, maxRunError),
348+ CreatedAt: now, StartedAt: now, CompletedAt: now,
349+ }
350+ return ports.CIRunDraft{
351+ Run: run, Source: domain.CIRunSource{Repo: repo.Name, WorkflowYAML: "", GithubJSON: "{}"},
352+ Checks: []domain.CICheck{{
353+ ID: "run-" + strconv.FormatInt(id, 10), Repo: repo.Name, Sha: run.Sha, Name: wfPath, Status: domain.CICompleted,
354+ Conclusion: domain.CIFailure, RunID: id, Attempt: 1, WorkflowName: wfPath, CreatedAt: now, StartedAt: now, CompletedAt: now,
355+ }},
356+ }
357+ })
358+ if errors.Is(err, domain.ErrNotFound) {
359+ return nil
360+ }
361+ if err != nil {
362+ return err
363+ }
364+ c.publishRun(ctx, repo, draft.Run)
365+ log := logRun(ctx, draft.Run)
366+ log.Info().Str("workflow", wfPath).Msg("ci run failed: invalid workflow file")
367+ return nil
368+}
369+
370+func (c *CI) publishRun(ctx context.Context, repo domain.Repo, run domain.CIRun) {
371+ c.cfg.Bus.Publish(ctx, domain.RunUpdated{Run: run, At: c.now(), Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
372+}
373+
374+// startRun creates the run of a matching workflow with one waiting record per
375+// job of the file, then plans the jobs that can start.
376+func (c *CI) startRun(ctx context.Context, repo domain.Repo, t ciTrigger, f pushFacts, wfPath, yaml string, wf *workflow.Workflow) error {
377+ now := c.now()
378+ name := wf.Name
379+ if name == "" {
380+ name = wfPath
381+ }
382+ draft, err := c.cfg.Store.CreateRun(ctx, repo.Name, wfPath, func(id, number int64) ports.CIRunDraft {
383+ run := domain.CIRun{
384+ Repo: repo.Name, ID: id, Number: number, Attempt: 1, WorkflowPath: wfPath, WorkflowName: name,
385+ Title: firstLine(f.head.Message), Event: "push", Ref: f.update.Ref, Sha: f.commit.String(), Actor: t.actor,
386+ Status: domain.CIQueued, CreatedAt: now,
387+ }
388+ d := ports.CIRunDraft{Run: run, Source: domain.CIRunSource{
389+ Repo: repo.Name, WorkflowYAML: yaml, GithubJSON: c.githubJSON(ctx, repo, run, f, wf),
390+ }}
391+ for i, j := range wf.Jobs {
392+ job := domain.CIJob{
393+ ID: domain.NewJobID(id, i, 0), RunID: id, Attempt: 1, RunAttempt: 1, Repo: repo.Name, Key: j.ID, Name: j.ID,
394+ Phase: domain.CIPhaseWaiting, Needs: slices.Clone(j.Needs), Index: i, CreatedAt: now,
395+ }
396+ d.Jobs = append(d.Jobs, job)
397+ d.Checks = append(d.Checks, domain.CICheck{
398+ ID: job.ID, Repo: repo.Name, Sha: run.Sha, Name: job.Name, Status: domain.CIQueued, RunID: id, JobID: job.ID,
399+ Attempt: 1, WorkflowName: name, CreatedAt: now,
400+ })
401+ }
402+ return d
403+ })
404+ if errors.Is(err, domain.ErrNotFound) {
405+ return nil
406+ }
407+ if err != nil {
408+ return err
409+ }
410+ c.publishRun(ctx, repo, draft.Run)
411+ for _, j := range draft.Jobs {
412+ c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
413+ }
414+ log := logRun(ctx, draft.Run)
415+ log.Info().Str("workflow", wfPath).Str("ref", f.update.Ref).Int("jobs", len(draft.Jobs)).Msg("ci run created")
416+ return c.advanceRun(ctx, draft.Run.ID)
417+}
418+
419+// --- the github context ---
420+
421+// shortID is the 12-character form of an object id that GitHub puts in the
422+// compare URL of a push.
423+func shortID(id string) string {
424+ if len(id) > 12 {
425+ return id[:12]
426+ }
427+ return id
428+}
429+
430+// retentionDays is the retention in days, as github.retention_days shows it
431+// (at least 1).
432+func (c *CI) retentionDays() int {
433+ return max(int(c.cfg.Retention/(24*time.Hour)), 1)
434+}
435+
436+type obj = map[string]any
437+
438+func person(name, email string) obj { return obj{"name": name, "email": email} }
439+
440+func (c *CI) commitJSON(ctx context.Context, repo domain.Repo, cm domain.Commit) obj {
441+ ts := cm.Committer.Time.UTC().Format(time.RFC3339)
442+ tree := ""
443+ if id, err := c.cfg.Git.CommitTree(ctx, repo.Name, cm.ID); err == nil {
444+ tree = id.String()
445+ }
446+ return obj{
447+ "id": cm.ID.String(), "tree_id": tree, "distinct": true, "message": cm.Message, "timestamp": ts,
448+ "url": c.cfg.PublicURL + "/" + repo.Name.String() + "/commit/" + cm.ID.String(),
449+ "author": obj{"name": cm.Author.Name, "email": cm.Author.Email, "username": ""},
450+ "committer": obj{"name": cm.Committer.Name, "email": cm.Committer.Email, "username": ""},
451+ "added": []string{}, "removed": []string{}, "modified": []string{},
452+ }
453+}
454+
455+// githubJSON builds the "github" context of the run as GitHub does for a push
456+// (the job-dependent keys are added when a job is planned). Values that
457+// GitHub's documentation does not settle are marked for the recordings.
458+//
459+// Checked against the recordings of ermite-conformance (push-branch-create,
460+// push-tag, push-force, ctx-github-names): ids and counters are strings, so is
461+// retention_days; "compare" has 12-character ids, the commit page for a created
462+// ref and "<before>...<after>" for an update; repository.default_branch and
463+// master_branch are the default branch.
464+//
465+// TODO(verify with recording): the empty added/removed/modified lists of the
466+// push commits.
467+func (c *CI) githubJSON(ctx context.Context, repo domain.Repo, run domain.CIRun, f pushFacts, wf *workflow.Workflow) string {
468+ full := c.cfg.Owner + "/" + repo.Name.String()
469+ refName, refType := strings.TrimPrefix(run.Ref, "refs/heads/"), "branch"
470+ if f.isTag {
471+ refName, refType = strings.TrimPrefix(run.Ref, "refs/tags/"), "tag"
472+ }
473+ repoURL := c.cfg.PublicURL + "/" + repo.Name.String()
474+ before := f.update.Old
475+ if f.created {
476+ before = domain.ZeroOID
477+ }
478+ compare := repoURL + "/compare/" + shortID(before) + "..." + shortID(f.update.New)
479+ if f.created {
480+ compare = repoURL + "/commit/" + shortID(f.update.New)
481+ }
482+ commits := make([]any, 0, len(f.commits))
483+ for _, cm := range f.commits {
484+ commits = append(commits, c.commitJSON(ctx, repo, cm))
485+ }
486+ event := obj{
487+ "ref": run.Ref, "before": before, "after": f.update.New, "created": f.created, "deleted": false, "forced": f.forced,
488+ "base_ref": nil, "compare": compare, "commits": commits, "head_commit": c.commitJSON(ctx, repo, f.head),
489+ "pusher": person(run.Actor, ""),
490+ "repository": obj{
491+ "name": repo.Name.String(), "full_name": full, "private": repo.Visibility == domain.VisibilityPrivate,
492+ "owner": obj{"login": c.cfg.Owner, "name": c.cfg.Owner}, "html_url": repoURL, "clone_url": repoURL + ".git",
493+ "default_branch": f.defaultBranch, "master_branch": f.defaultBranch, "url": repoURL,
494+ },
495+ "sender": obj{"login": run.Actor, "type": "User"},
496+ }
497+ wfRef := full + "/" + run.WorkflowPath + "@" + run.Ref
498+ gh := obj{
499+ "event_name": "push", "event": event, "ref": run.Ref, "ref_name": refName, "ref_type": refType, "ref_protected": false,
500+ "sha": run.Sha, "repository": full, "repository_owner": c.cfg.Owner, "repositoryUrl": repoURL + ".git",
501+ "actor": run.Actor, "triggering_actor": run.Actor, "workflow": run.WorkflowName, "workflow_ref": wfRef,
502+ "workflow_sha": run.Sha, "run_id": strconv.FormatInt(run.ID, 10), "run_number": strconv.FormatInt(run.Number, 10),
503+ "server_url": c.cfg.PublicURL, "api_url": c.cfg.PublicURL + "/-/github/api/v3",
504+ "graphql_url": c.cfg.PublicURL + "/-/github/api/graphql", "retention_days": strconv.Itoa(c.retentionDays()),
505+ "head_ref": "", "base_ref": "",
506+ }
507+ b, err := json.Marshal(gh)
508+ if err != nil {
509+ return "{}"
510+ }
511+ return string(b)
512+}
A internal/usecase/ci_unit_test.go +175 -0
@@ -0,0 +1,175 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/mock"
11+ "github.com/stretchr/testify/require"
12+
13+ "bnema.dev/ermite/internal/domain"
14+ "bnema.dev/ermite/internal/ports"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+ "bnema.dev/ermite/internal/usecase"
17+)
18+
19+// mergeUnit is a merge request that is ready to merge, with a checks reader.
20+type mergeUnit struct {
21+ *mrUnit
22+ checks *mocks.CIChecksReader
23+ trig *mocks.WorkflowTrigger
24+ head domain.ObjectID
25+}
26+
27+func newMergeUnit(t *testing.T, block bool) *mergeUnit {
28+ t.Helper()
29+ u := &mrUnit{
30+ meta: mocks.NewRepoMetaStore(t),
31+ store: mocks.NewMergeRequestStore(t),
32+ repos: mocks.NewGitRepos(t),
33+ git: mocks.NewMergeGit(t),
34+ repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},
35+ }
36+ u.repo.Settings = domain.DefaultRepoSettings()
37+ u.repo.Settings.FailingChecksBlock = block
38+ clock := mocks.NewClock(t)
39+ clock.EXPECT().Now().Return(now).Maybe()
40+ sched := mocks.NewMaintenanceScheduler(t)
41+ sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
42+ m := &mergeUnit{mrUnit: u, checks: mocks.NewCIChecksReader(t), trig: mocks.NewWorkflowTrigger(t), head: someID(t)}
43+ u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44+ Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
45+ Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
46+ CI: m.trig, Checks: m.checks,
47+ })
48+ u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
49+ u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{
50+ Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main",
51+ }, nil)
52+ tip, err := domain.ParseObjectID("1111111111111111111111111111111111111111")
53+ require.NoError(t, err)
54+ u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)
55+ u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)
56+ u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)
57+ return m
58+}
59+
60+func (m *mergeUnit) merge() error {
61+ _, err := m.mrs.Merge(context.Background(), domain.Root(), m.repo.Name, 1, ports.MergeParams{HeadID: m.head})
62+ return err
63+}
64+
65+// AC5: the merge rule under FailingChecksBlock.
66+func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
67+ t.Parallel()
68+ check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
69+ return domain.CICheck{Name: name, Status: status, Conclusion: c}
70+ }
71+ tests := []struct {
72+ name string
73+ checks []domain.CICheck
74+ blocked string // part of the message
75+ }{
76+ {"a failure blocks", []domain.CICheck{check(domain.CICompleted, domain.CISuccess, "a"), check(domain.CICompleted, domain.CIFailure, "build")}, "failing checks block the merge: build"},
77+ {"a timeout blocks", []domain.CICheck{check(domain.CICompleted, domain.CITimedOut, "slow")}, "slow"},
78+ {"a cancelled check blocks", []domain.CICheck{check(domain.CICompleted, domain.CICancelled, "c")}, "failing checks"},
79+ {"action required blocks", []domain.CICheck{check(domain.CICompleted, domain.CIActionRequired, "env")}, "failing checks"},
80+ {"a queued check blocks", []domain.CICheck{check(domain.CIQueued, "", "q")}, "have not finished: q"},
81+ {"a running check blocks", []domain.CICheck{check(domain.CIInProgress, "", "r")}, "have not finished: r"},
82+ }
83+ for _, tt := range tests {
84+ t.Run(tt.name, func(t *testing.T) {
85+ t.Parallel()
86+ m := newMergeUnit(t, true)
87+ m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
88+ // The merge stops at the rule: the branch mock expects no move.
89+ err := m.merge()
90+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
91+ assert.Contains(t, err.Error(), tt.blocked)
92+ })
93+ }
94+ t.Run("too many checks to tell", func(t *testing.T) {
95+ t.Parallel()
96+ m := newMergeUnit(t, true)
97+ m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
98+ err := m.merge()
99+ require.ErrorIs(t, err, domain.ErrPreconditionFailed)
100+ assert.Contains(t, err.Error(), "too many checks")
101+ })
102+ // The cases where the merge goes on (no check, all passing, rule off) need
103+ // the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.
104+}
105+
106+func TestCombineChecks(t *testing.T) {
107+ t.Parallel()
108+ ck := func(s domain.CIStatus, c domain.CIConclusion) domain.CICheck {
109+ return domain.CICheck{Status: s, Conclusion: c}
110+ }
111+ assert.Equal(t, domain.CheckStateNone, domain.CombineChecks(nil))
112+ assert.Equal(t, domain.CheckStateSuccess, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CICompleted, domain.CISkipped)}))
113+ assert.Equal(t, domain.CheckStatePending, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CIQueued, "")}))
114+ assert.Equal(t, domain.CheckStateFailure, domain.CombineChecks([]domain.CICheck{ck(domain.CIInProgress, ""), ck(domain.CICompleted, domain.CIFailure)}), "a failure wins over pending")
115+}
116+
117+func TestLabelsMatchAndRunnerLabels(t *testing.T) {
118+ t.Parallel()
119+ have := domain.RunnerLabels([]string{"ubuntu-latest", "gpu"}, "linux", "x64")
120+ assert.Equal(t, []string{"gpu", "linux", "self-hosted", "ubuntu-latest", "x64"}, have)
121+ assert.True(t, domain.LabelsMatch([]string{"Ubuntu-Latest"}, have), "case-insensitive")
122+ assert.True(t, domain.LabelsMatch([]string{"self-hosted", "linux", "x64"}, have))
123+ assert.False(t, domain.LabelsMatch([]string{"self-hosted", "arm64"}, have))
124+ assert.False(t, domain.LabelsMatch([]string{"windows-latest"}, have))
125+ assert.True(t, domain.LabelsMatch(nil, have), "no requirement matches")
126+ assert.False(t, domain.LabelsMatch([]string{"runner-name"}, have), "the runner name is not a label")
127+}
128+
129+func TestStepRanges(t *testing.T) {
130+ t.Parallel()
131+ steps := []domain.CIStep{
132+ {Number: 1, HasLines: true, LogStart: 0},
133+ {Number: 2},
134+ {Number: 3, HasLines: true, LogStart: 4},
135+ {Number: 4, HasLines: true, LogStart: 9},
136+ {Number: 5},
137+ }
138+ got := domain.StepRanges(steps, 12)
139+ assert.Equal(t, []domain.StepRange{{Start: 0, End: 4}, {Start: 4, End: 4}, {Start: 4, End: 9}, {Start: 9, End: 12}, {Start: 12, End: 12}}, got)
140+ assert.EqualValues(t, 3, domain.StepOfLine(steps, 5))
141+ assert.EqualValues(t, 1, domain.StepOfLine(steps, 0))
142+ assert.EqualValues(t, 4, domain.StepOfLine(steps, 11))
143+}
144+
145+func TestJobIDsRoundTripAndRejectOthers(t *testing.T) {
146+ t.Parallel()
147+ id := domain.NewJobID(42, 3, 1)
148+ run, index, combo, ok := domain.ParseJobID(id)
149+ require.True(t, ok)
150+ assert.Equal(t, []int{42, 3, 1}, []int{int(run), index, combo})
151+ for _, bad := range []string{"", "1-2", "1-2-3-4", "a-b-c", "0-1-1", "1--2", "01-2-3", "../../x", "1-2-3/4"} {
152+ _, _, _, ok := domain.ParseJobID(bad)
153+ assert.False(t, ok, bad)
154+ }
155+}
156+
157+func TestOutputLimitsAndNames(t *testing.T) {
158+ t.Parallel()
159+ assert.NoError(t, domain.CheckOutputs(map[string]string{"a": "1", "_b-c": "2"}))
160+ assert.Error(t, domain.CheckOutputs(map[string]string{"1a": "x"}), "starts with a digit")
161+ assert.Error(t, domain.CheckOutputs(map[string]string{"": "x"}))
162+ assert.Error(t, domain.CheckOutputs(map[string]string{"A": "x", "a": "y"}), "names differ only by case")
163+ assert.Error(t, domain.CheckOutputs(map[string]string{"big": string(make([]byte, domain.CIMaxOutputBytes+1))}))
164+ assert.Error(t, domain.CheckOutputs(map[string]string{"a": string(make([]byte, domain.CIMaxOutputBytes/2+1)), "b": string(make([]byte, domain.CIMaxOutputBytes/2+1))}), "1 MiB together")
165+}
166+
167+func TestTokenShapes(t *testing.T) {
168+ t.Parallel()
169+ good := "ermitejob_" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
170+ assert.True(t, domain.IsJobToken(good))
171+ assert.False(t, domain.IsRunnerToken(good))
172+ assert.False(t, domain.IsJobToken(good[:len(good)-1]))
173+ assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
174+ assert.False(t, domain.IsJobToken(good+"="))
175+}
A internal/usecase/ci_watch.go +292 -0
@@ -0,0 +1,292 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase
4+
5+import (
6+ "context"
7+ "errors"
8+ "runtime/debug"
9+ "strconv"
10+ "time"
11+
12+ "bnema.dev/ermite/internal/domain"
13+)
14+
15+const sweepEvery = 24 * time.Hour
16+
17+// advanceRun plans what can start in the latest attempt of a run.
18+func (c *CI) advanceRun(ctx context.Context, runID int64) error {
19+ return c.withRun(ctx, runID, 0, func(rs *runState) error {
20+ if err := rs.advance(ctx); err != nil {
21+ return err
22+ }
23+ return rs.flush(ctx)
24+ })
25+}
26+
27+// resume plans again the runs that were open when the server stopped: jobs
28+// whose needs ended while it was down are planned now. Running jobs go on:
29+// their tokens are in the store, and the heartbeat deadline counts from the
30+// start of this process.
31+func (c *CI) resume() {
32+ ctx := c.ctx
33+ runs, err := c.cfg.Store.ListOpenRuns(ctx)
34+ if err != nil {
35+ ctxLog(ctx).Error().Err(err).Msg("ci: list the open runs")
36+ return
37+ }
38+ for _, r := range runs {
39+ if err := c.advanceRun(ctx, r.ID); err != nil && !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil {
40+ ctxLog(ctx).Error().Err(err).Int64("run_id", r.ID).Msg("ci: plan an open run again")
41+ }
42+ }
43+ c.wakeQueue()
44+}
45+
46+// watchdog looks at the jobs in flight every tick, and sweeps the old runs
47+// once a day.
48+func (c *CI) watchdog() {
49+ defer c.wg.Done()
50+ tick := time.NewTicker(c.cfg.Tick)
51+ defer tick.Stop()
52+ lastSweep := time.Time{}
53+ for {
54+ select {
55+ case <-c.ctx.Done():
56+ return
57+ case <-tick.C:
58+ }
59+ c.guard("watchdog", func() {
60+ c.checkActive(c.ctx)
61+ c.checkQueue(c.ctx)
62+ if now := c.now(); now.Sub(lastSweep) >= sweepEvery {
63+ lastSweep = now
64+ c.sweep(c.ctx)
65+ }
66+ })
67+ }
68+}
69+
70+// guard runs fn and logs a panic instead of letting it end the worker.
71+func (c *CI) guard(what string, fn func()) {
72+ defer func() {
73+ if r := recover(); r != nil {
74+ ctxLog(c.ctx).Error().Interface("panic", r).Bytes("stack", debug.Stack()).Str("worker", what).Msg("ci worker panicked")
75+ }
76+ }()
77+ fn()
78+}
79+
80+// checkActive applies the server's deadlines to the jobs given to runners:
81+// acknowledgement, heartbeat, time budget, and the grace after a cancel.
82+func (c *CI) checkActive(ctx context.Context) {
83+ jobs, err := c.cfg.Store.ListActiveJobs(ctx)
84+ if err != nil {
85+ ctxLog(ctx).Error().Err(err).Msg("ci: list the active jobs")
86+ return
87+ }
88+ now := c.now()
89+ for _, j := range jobs {
90+ if !c.due(j, now) {
91+ continue
92+ }
93+ err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
94+ cur := rs.find(j.ID)
95+ if cur == nil || !cur.Active() {
96+ return nil
97+ }
98+ if !c.enforce(ctx, rs, cur, c.now()) {
99+ return nil
100+ }
101+ if err := rs.advance(ctx); err != nil {
102+ return err
103+ }
104+ return rs.flush(ctx)
105+ })
106+ if err != nil && !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil {
107+ ctxLog(ctx).Error().Err(err).Str("job_id", j.ID).Msg("ci: enforce a deadline")
108+ }
109+ }
110+}
111+
112+// lastBeat is the time of the last sign of life of an active job: the later
113+// of its last heartbeat, its acknowledgement and the start of the server.
114+func (c *CI) lastBeat(j domain.CIJob) time.Time {
115+ c.mu.Lock()
116+ beat := c.heartbeats[jobRef(j)]
117+ c.mu.Unlock()
118+ for _, t := range []time.Time{j.AckedAt, c.boot} {
119+ if t.After(beat) {
120+ beat = t
121+ }
122+ }
123+ return beat
124+}
125+
126+// due is a cheap test, on the listed copy, of whether a deadline may have
127+// passed.
128+func (c *CI) due(j domain.CIJob, now time.Time) bool {
129+ switch {
130+ case j.Phase == domain.CIPhaseAssigned:
131+ return now.Sub(c.assignedSince(j)) > c.cfg.AckTimeout
132+ case j.CancelReason != domain.CICancelNone:
133+ return now.Sub(j.CancelRequestedAt) > c.cfg.CancelGrace
134+ }
135+ return now.Sub(c.lastBeat(j)) > c.cfg.HeartbeatDeadline || now.After(c.deadline(j))
136+}
137+
138+// assignedSince is when the acknowledgement of an assignment starts to count:
139+// its assignment, or the start of this process when that is later, so that a
140+// runner that could not reach a server that was down is not punished for it
141+// (the same grace as the heartbeat deadline).
142+func (c *CI) assignedSince(j domain.CIJob) time.Time {
143+ if c.boot.After(j.AssignedAt) {
144+ return c.boot
145+ }
146+ return j.AssignedAt
147+}
148+
149+// deadline is when the job's time budget ends for the server: timeout-minutes
150+// from the assignment, plus the slack that lets the runner's own timeout win.
151+func (c *CI) deadline(j domain.CIJob) time.Time {
152+ return j.AssignedAt.Add(time.Duration(j.TimeoutMinutes) * time.Minute).Add(c.cfg.TimeoutSlack)
153+}
154+
155+// enforce applies the deadlines to a job read under the lock of its run and
156+// reports whether it changed.
157+func (c *CI) enforce(ctx context.Context, rs *runState, j *domain.CIJob, now time.Time) bool {
158+ switch {
159+ case j.Phase == domain.CIPhaseAssigned && now.Sub(c.assignedSince(*j)) > c.cfg.AckTimeout:
160+ c.cancelAssignment(ctx, rs, j)
161+ return true
162+ case j.CancelReason != domain.CICancelNone && now.Sub(j.CancelRequestedAt) > c.cfg.CancelGrace:
163+ conclusion := domain.CICancelled
164+ if j.CancelReason == domain.CICancelTimeout {
165+ conclusion = domain.CITimedOut
166+ }
167+ rs.end(j, conclusion, "The runner "+j.RunnerName+" did not answer the cancellation request in time; the job was ended by the server.")
168+ return true
169+ case j.Phase == domain.CIPhaseRunning && now.Sub(c.lastBeat(*j)) > c.cfg.HeartbeatDeadline:
170+ rs.end(j, domain.CIFailure, domain.CILostMessage(j.RunnerName))
171+ return true
172+ case j.CancelReason == domain.CICancelNone && now.After(c.deadline(*j)):
173+ rs.requestCancel(j, domain.CICancelTimeout, "The job exceeded its timeout of "+itoa(int64(j.TimeoutMinutes))+" minutes")
174+ return true
175+ }
176+ return false
177+}
178+
179+func itoa(n int64) string { return strconv.FormatInt(n, 10) }
180+
181+// cancelAssignment takes a job back from a runner that did not acknowledge
182+// it: the token is revoked, what the assignment stored is dropped, and the job
183+// is queued again with a fresh token at its next assignment. Three in a row
184+// fail the job.
185+func (c *CI) cancelAssignment(ctx context.Context, rs *runState, j *domain.CIJob) {
186+ ctxLog(ctx).Warn().Str("job_id", j.ID).Str("runner", j.RunnerName).Msg("job assignment not acknowledged: cancelled")
187+ _ = c.cfg.Logs.Reset(ctx, logRef(*j))
188+ j.AssignCancels++
189+ j.TokenHash, j.TokenValidUntil = domain.TokenHash{}, time.Time{}
190+ j.RunnerID, j.RunnerName, j.Isolation = "", "", domain.CIIsolationNone
191+ j.Steps, j.Outputs, j.AssignedAt = nil, nil, time.Time{}
192+ c.forgetPoll(jobRef(*j))
193+ if j.CancelReason != domain.CICancelNone {
194+ // Cancelled (by the owner, fail-fast or the time budget) before the
195+ // runner answered: it is over, not queued again for another runner.
196+ conclusion := domain.CICancelled
197+ if j.CancelReason == domain.CICancelTimeout {
198+ conclusion = domain.CITimedOut
199+ }
200+ rs.end(j, conclusion, "")
201+ return
202+ }
203+ if j.AssignCancels >= domain.CIMaxAssignCancels {
204+ rs.end(j, domain.CIFailure, "The job was given to a runner "+itoa(int64(domain.CIMaxAssignCancels))+" times and none of them acknowledged it.")
205+ return
206+ }
207+ j.Phase, j.ReadyAt = domain.CIPhaseReady, c.now()
208+ rs.touch(j)
209+}
210+
211+// checkQueue fails the jobs that no runner took within the queue timeout.
212+func (c *CI) checkQueue(ctx context.Context) {
213+ ready, err := c.cfg.Store.ListReadyJobs(ctx, 0)
214+ if err != nil {
215+ return
216+ }
217+ now := c.now()
218+ for _, j := range ready {
219+ if now.Sub(j.ReadyAt) <= c.cfg.QueueTimeout {
220+ continue
221+ }
222+ err := c.withRun(ctx, j.RunID, j.RunAttempt, func(rs *runState) error {
223+ cur := rs.find(j.ID)
224+ if cur == nil || cur.Phase != domain.CIPhaseReady || c.now().Sub(cur.ReadyAt) <= c.cfg.QueueTimeout {
225+ return nil
226+ }
227+ rs.end(cur, domain.CIFailure, domain.CIQueueTimeoutMessage)
228+ if err := rs.advance(ctx); err != nil {
229+ return err
230+ }
231+ return rs.flush(ctx)
232+ })
233+ if err != nil && !errors.Is(err, domain.ErrNotFound) && ctx.Err() == nil {
234+ ctxLog(ctx).Error().Err(err).Str("job_id", j.ID).Msg("ci: end a job nobody took")
235+ }
236+ }
237+}
238+
239+// sweep removes the runs older than the retention, and the logs of runs that
240+// no longer exist (a repository deleted while the server was down). Every
241+// failure is logged and the next step still runs: a log directory that cannot
242+// be removed is tried again at the next sweep.
243+func (c *CI) sweep(ctx context.Context) {
244+ log := ctxLog(ctx)
245+ ids, err := c.cfg.Store.Sweep(ctx, c.now().Add(-c.cfg.Retention))
246+ if err != nil {
247+ log.Error().Err(err).Msg("ci: sweep the old runs")
248+ return
249+ }
250+ if len(ids) > 0 {
251+ if err := c.cfg.Logs.RemoveRuns(ctx, ids); err != nil {
252+ log.Error().Err(err).Int("runs", len(ids)).Msg("ci: remove the logs of the old runs")
253+ }
254+ for _, id := range ids {
255+ c.locks.Delete(id)
256+ }
257+ log.Info().Int("runs", len(ids)).Msg("ci: old runs removed")
258+ }
259+ onDisk, err := c.cfg.Logs.RunIDs(ctx)
260+ if err != nil {
261+ log.Error().Err(err).Msg("ci: list the log directories")
262+ return
263+ }
264+ var orphans []int64
265+ for _, id := range onDisk {
266+ switch _, err := c.cfg.Store.GetRunSource(ctx, id); {
267+ case errors.Is(err, domain.ErrNotFound):
268+ orphans = append(orphans, id)
269+ case err != nil:
270+ log.Error().Err(err).Int64("run_id", id).Msg("ci: look up a run with a log directory")
271+ }
272+ }
273+ if len(orphans) > 0 {
274+ if err := c.cfg.Logs.RemoveRuns(ctx, orphans); err != nil {
275+ log.Error().Err(err).Int("runs", len(orphans)).Msg("ci: remove the logs of runs that no longer exist")
276+ }
277+ }
278+}
279+
280+// RunIDs lists the runs of a repository, for the cleanup that follows its
281+// deletion.
282+func (c *CI) RunIDs(ctx context.Context, repo domain.RepoName) ([]int64, error) {
283+ return c.cfg.Store.RunIDs(ctx, repo)
284+}
285+
286+// RemoveLogs deletes the logs of runs of a repository that was deleted.
287+func (c *CI) RemoveLogs(ctx context.Context, runIDs []int64) error {
288+ for _, id := range runIDs {
289+ c.locks.Delete(id)
290+ }
291+ return c.cfg.Logs.RemoveRuns(ctx, runIDs)
292+}
M internal/usecase/events.go +15 -0
@@ -176,6 +176,10 @@ func (e *Events) snapshot(ctx context.Context, ev domain.Event) bufferedEvent {
176176 return repoSnapshot(ev.Visibility, ev.CreatedAt)
177177 case domain.CommentAdded:
178178 return repoSnapshot(ev.Visibility, ev.CreatedAt)
179+ case domain.RunUpdated:
180+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
181+ case domain.JobUpdated:
182+ return repoSnapshot(ev.Visibility, ev.CreatedAt)
179183 }
180184 return bufferedEvent{known: true}
181185 }
@@ -479,6 +483,17 @@ func (e *Events) deliver(ctx context.Context, w *watch, be bufferedEvent, send f
479483 return e.deliverRepo(ctx, w, ev.MergeRequest.Repo, false, be, out, send)
480484 case domain.CommentAdded:
481485 return e.deliverRepo(ctx, w, ev.Repo, false, be, out, send)
486+ case domain.RunUpdated:
487+ // CI events go on the topic of their repository only, never on home.
488+ if !w.repos[ev.Run.Repo] {
489+ return nil
490+ }
491+ return e.deliverRepo(ctx, w, ev.Run.Repo, false, be, out, send)
492+ case domain.JobUpdated:
493+ if !w.repos[ev.Job.Repo] {
494+ return nil
495+ }
496+ return e.deliverRepo(ctx, w, ev.Job.Repo, false, be, out, send)
482497 }
483498 return nil // an event this version does not stream
484499 }
M internal/usecase/export_test.go +8 -1
@@ -2,7 +2,14 @@
22
33 package usecase
44
5-import "bnema.dev/ermite/internal/domain"
5+import (
6+ "context"
7+
8+ "bnema.dev/ermite/internal/domain"
9+)
610
711 // AuthorOf exposes authorOf to the tests.
812 func AuthorOf(p domain.Principal) string { return authorOf(p) }
13+
14+// Sweep exposes the daily sweep to the tests.
15+func (c *CI) Sweep(ctx context.Context) { c.sweep(ctx) }
M internal/usecase/mergerequests.go +51 -1
@@ -8,6 +8,7 @@ import (
88 "errors"
99 "fmt"
1010 "strconv"
11+ "strings"
1112 "sync"
1213
1314 "github.com/bnema/zerowrap"
@@ -48,6 +49,8 @@ type MergeRequests struct {
4849 bus ports.EventBus
4950 clock ports.Clock
5051 maint ports.MaintenanceScheduler
52+ ci ports.WorkflowTrigger // nil: no CI
53+ checks ports.CIChecksReader // nil: no CI, so no check blocks a merge
5154
5255 mu sync.Mutex
5356 locks map[domain.RepoName]chan struct{}
@@ -71,13 +74,18 @@ type MergeRequestsConfig struct {
7174 Clock ports.Clock
7275 // Maint queues the maintenance that follows a merge, as it does a push.
7376 Maint ports.MaintenanceScheduler
77+ // CI starts the workflows a merge triggers (GitHub runs "push" workflows
78+ // on a merge) and Checks gives the checks the merge rule reads. Both are
79+ // optional: without them there is no CI.
80+ CI ports.WorkflowTrigger
81+ Checks ports.CIChecksReader
7482 }
7583
7684 // NewMergeRequests builds the merge request use cases.
7785 func NewMergeRequests(cfg MergeRequestsConfig) *MergeRequests {
7886 return &MergeRequests{
7987 meta: cfg.Meta, store: cfg.Store, repos: cfg.Repos, git: cfg.Git, signer: cfg.Signer,
80- committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint,
88+ committer: cfg.Committer, bus: cfg.Bus, clock: cfg.Clock, maint: cfg.Maint, ci: cfg.CI, checks: cfg.Checks,
8189 locks: make(map[domain.RepoName]chan struct{}),
8290 }
8391 }
@@ -753,6 +761,11 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
753761 case !ok:
754762 return domain.MergeRequest{}, false, fmt.Errorf("%w: %q is not an ancestor of %q: rebase it on %q and push again", domain.ErrPreconditionFailed, mr.Target, mr.Source, mr.Target)
755763 }
764+ if settings.FailingChecksBlock {
765+ if err := m.checksAllow(ctx, name, head); err != nil {
766+ return domain.MergeRequest{}, false, err
767+ }
768+ }
756769 if params.DeleteSource {
757770 if err := m.checkDeletable(ctx, name, mr); err != nil {
758771 return domain.MergeRequest{}, false, err
@@ -792,9 +805,46 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
792805 m.bus.Publish(ctx, domain.PushEvent{
793806 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
794807 })
808+ if m.ci != nil {
809+ // GitHub runs the "push" workflows of a merge, with the owner as pusher.
810+ m.ci.OnPush(ctx, name, updates[:1], domain.Owner())
811+ }
795812 return mr, deleted, nil
796813 }
797814
815+// checksAllow is the merge rule of FailingChecksBlock: every check of the
816+// head must have ended as success, skipped or neutral. A check still queued or
817+// running blocks too (the merge could land code that then fails), and a head
818+// without any check merges.
819+func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error {
820+ if m.checks == nil {
821+ return nil
822+ }
823+ checks, err := m.checks.CommitChecks(ctx, name, head.String())
824+ if errors.Is(err, domain.ErrResourceExhausted) {
825+ return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
826+ }
827+ if err != nil {
828+ return fmt.Errorf("read the checks of %s: %w", head, err)
829+ }
830+ var failing, pending []string
831+ for _, c := range checks {
832+ switch {
833+ case c.Status != domain.CICompleted:
834+ pending = append(pending, c.Name)
835+ case !c.Conclusion.PassesMerge():
836+ failing = append(failing, c.Name)
837+ }
838+ }
839+ switch {
840+ case len(failing) > 0:
841+ return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
842+ case len(pending) > 0:
843+ return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
844+ }
845+ return nil
846+}
847+
798848 // checkDeletable refuses to delete the source branch of mr when it is the
799849 // default branch or the source or target of another open request. It runs
800850 // under the merge lock, which Create also holds while it checks its branches
M internal/usecase/pushes.go +14 -2
@@ -27,7 +27,8 @@ type Pushes struct {
2727 clock ports.Clock
2828 maint ports.MaintenanceScheduler
2929 auth ports.Revalidator
30- fresh ports.RepoFreshener // nil: the reader needs no telling
30+ fresh ports.RepoFreshener // nil: the reader needs no telling
31+ ci ports.WorkflowTrigger // nil: no CI
3132 }
3233
3334 var (
@@ -49,6 +50,13 @@ func (u *Pushes) WithFreshener(f ports.RepoFreshener) *Pushes {
4950 return u
5051 }
5152
53+// WithWorkflowTrigger makes Record start the workflows that match the push.
54+// Call it before the first push; it returns u.
55+func (u *Pushes) WithWorkflowTrigger(t ports.WorkflowTrigger) *Pushes {
56+ u.ci = t
57+ return u
58+}
59+
5260 // Open decides whether the principal may push to the repository and, with
5361 // create, creates it when it does not exist yet (private, published as
5462 // RepoCreated by Repos.Create). create is for the first request of a push only.
@@ -121,7 +129,8 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
121129
122130 // Record is the post-receive step of an accepted push: it sets pushed_at,
123131 // repairs HEAD when it points to a missing branch, tells the reader that the
124-// repository changed, publishes a PushEvent and schedules maintenance. Every
132+// repository changed, queues the workflows the push triggers, publishes a
133+// PushEvent and schedules maintenance. Every
125134 // step runs even when an earlier one fails; the failures are returned together
126135 // and the push itself is not affected.
127136 func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
@@ -139,6 +148,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
139148 if u.fresh != nil {
140149 u.fresh.Refresh(ctx, name)
141150 }
151+ if u.ci != nil {
152+ u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
153+ }
142154 u.bus.Publish(ctx, domain.PushEvent{
143155 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
144156 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
M internal/usecase/repos.go +8 -0
@@ -21,6 +21,7 @@ type Repos struct {
2121 git ports.GitRepos
2222 bus ports.EventBus
2323 clock ports.Clock
24+ ci ports.CIRunCleaner // nil: no CI logs to remove
2425 }
2526
2627 var _ ports.RepoService = (*Repos)(nil)
@@ -31,6 +32,13 @@ func NewRepos(meta ports.RepoMetaStore, git ports.GitRepos, bus ports.EventBus,
3132 return &Repos{meta: meta, git: git, bus: bus, clock: clock}
3233 }
3334
35+// WithCIRunCleaner makes Delete remove the CI logs of the repository. Call it
36+// before the first request; it returns r.
37+func (r *Repos) WithCIRunCleaner(c ports.CIRunCleaner) *Repos {
38+ r.ci = c
39+ return r
40+}
41+
3442 // Create makes a repository and publishes RepoCreated: it stages a bare repository, records the
3543 // metadata (which fails with domain.ErrAlreadyExists if the name is taken,
3644 // also when a concurrent Create wins) and publishes the directory. A failure
M internal/usecase/repos_write.go +14 -0
@@ -191,9 +191,23 @@ func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.Repo
191191 case err != nil:
192192 return fmt.Errorf("trash repository %q: %w", name, err)
193193 }
194+ var runIDs []int64
195+ if r.ci != nil {
196+ // Listed before the records go: the logs are found by run id.
197+ if ids, err := r.ci.RunIDs(ctx, name); err == nil {
198+ runIDs = ids
199+ } else {
200+ log.Warn().Err(err).Str("repo", name.String()).Msg("list the CI runs of the repository; their logs are left to the sweep")
201+ }
202+ }
194203 if err := r.meta.Delete(ctx, name); err != nil {
195204 return fmt.Errorf("delete metadata of %q: %w", name, err)
196205 }
206+ if len(runIDs) > 0 {
207+ if err := r.ci.RemoveLogs(context.WithoutCancel(ctx), runIDs); err != nil {
208+ log.Warn().Err(err).Str("repo", name.String()).Msg("remove the CI logs of the repository; the sweep will")
209+ }
210+ }
197211 log.Info().Str("repo", name.String()).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("repository deleted")
198212 r.bus.Publish(ctx, domain.RepoDeleted{Repo: name, Visibility: repo.Visibility, At: r.clock.Now().UTC(), CreatedAt: repo.CreatedAt})
199213 return nil
A proto/ermite/storage/ci.proto +214 -0
@@ -0,0 +1,214 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Internal, unversioned storage schema of ermite: CI runs, jobs, checks and
4+// runners (ADR 0025). Logs are not stored here: they are files under
5+// $ERMITE_DATA_DIR/ci/logs.
6+
7+syntax = "proto3";
8+
9+package ermite.storage;
10+
11+import "google/protobuf/timestamp.proto";
12+
13+option go_package = "bnema.dev/ermite/gen/ermite/storage;storagepb";
14+
15+// CIStatus is where a run, a job or a check is in its life.
16+enum CIStatus {
17+ CI_STATUS_UNSPECIFIED = 0;
18+ CI_STATUS_QUEUED = 1;
19+ CI_STATUS_IN_PROGRESS = 2;
20+ CI_STATUS_COMPLETED = 3;
21+}
22+
23+// CIConclusion is how a run, a job, a step or a check ended.
24+enum CIConclusion {
25+ CI_CONCLUSION_UNSPECIFIED = 0;
26+ CI_CONCLUSION_SUCCESS = 1;
27+ CI_CONCLUSION_FAILURE = 2;
28+ CI_CONCLUSION_CANCELLED = 3;
29+ CI_CONCLUSION_SKIPPED = 4;
30+ CI_CONCLUSION_TIMED_OUT = 5;
31+ CI_CONCLUSION_NEUTRAL = 6;
32+ CI_CONCLUSION_ACTION_REQUIRED = 7;
33+}
34+
35+// CIStepState is where a step is in its life.
36+enum CIStepState {
37+ CI_STEP_STATE_UNSPECIFIED = 0;
38+ CI_STEP_STATE_PENDING = 1;
39+ CI_STEP_STATE_IN_PROGRESS = 2;
40+ CI_STEP_STATE_COMPLETED = 3;
41+}
42+
43+// CIJobPhase is where a job is for the server.
44+enum CIJobPhase {
45+ CI_JOB_PHASE_UNSPECIFIED = 0;
46+ // Waits for the jobs it needs, or for a free max-parallel slot.
47+ CI_JOB_PHASE_WAITING = 1;
48+ // Planned and waiting for a runner.
49+ CI_JOB_PHASE_READY = 2;
50+ // Given to a runner that has not acknowledged it yet.
51+ CI_JOB_PHASE_ASSIGNED = 3;
52+ // Acknowledged by its runner.
53+ CI_JOB_PHASE_RUNNING = 4;
54+ // Over.
55+ CI_JOB_PHASE_ENDED = 5;
56+}
57+
58+// CIIsolation is how a runner isolates jobs.
59+enum CIIsolation {
60+ CI_ISOLATION_UNSPECIFIED = 0;
61+ CI_ISOLATION_VM = 1;
62+ CI_ISOLATION_CONTAINER_SYSBOX = 2;
63+ CI_ISOLATION_CONTAINER_PRIVILEGED = 3;
64+}
65+
66+// CICancelReason says why a job is asked to stop.
67+enum CICancelReason {
68+ CI_CANCEL_REASON_UNSPECIFIED = 0;
69+ CI_CANCEL_REASON_USER = 1;
70+ CI_CANCEL_REASON_FAIL_FAST = 2;
71+ CI_CANCEL_REASON_TIMEOUT = 3;
72+}
73+
74+// CIRun is stored under "run/<repo>/<id:020d>".
75+message CIRun {
76+ // 18 and 19 were the workflow file and the github context, which moved to
77+ // CIRunSource.
78+ reserved 18, 19;
79+ string repo = 1;
80+ int64 id = 2;
81+ int64 number = 3;
82+ int32 attempt = 4;
83+ string workflow_path = 5;
84+ string workflow_name = 6;
85+ string title = 7;
86+ string event = 8;
87+ string ref = 9;
88+ string sha = 10;
89+ string actor = 11;
90+ CIStatus status = 12;
91+ CIConclusion conclusion = 13;
92+ string error = 14;
93+ google.protobuf.Timestamp created_at = 15;
94+ google.protobuf.Timestamp started_at = 16;
95+ google.protobuf.Timestamp completed_at = 17;
96+ bool cancel_requested = 20;
97+ google.protobuf.Timestamp cancel_requested_at = 21;
98+}
99+
100+// CIRunSource is stored under "runsrc/<run id:020d>": what a run was planned
101+// from, kept apart so that listing runs does not decode it.
102+message CIRunSource {
103+ int64 run_id = 4;
104+ // The workflow file at sha, as is.
105+ string workflow_yaml = 1;
106+ // The "github" context of the run as JSON, without "job" and "run_attempt".
107+ string github_json = 2;
108+ // Repository of the run: the key of the run record is run/<repo>/<id>, and
109+ // the runner side knows a run by its id only.
110+ string repo = 3;
111+}
112+
113+// CIStep is a step of a job.
114+message CIStep {
115+ // 8 and 10 were the end of the step, which is derived from the next step's
116+ // start and the line count.
117+ reserved 8, 10;
118+ int32 number = 1;
119+ string name = 2;
120+ CIStepState state = 3;
121+ CIConclusion conclusion = 4;
122+ google.protobuf.Timestamp started_at = 5;
123+ google.protobuf.Timestamp completed_at = 6;
124+ // Number of the first log line of the step; meaningful with has_lines. The
125+ // end of a step is the start of the next step that has lines, or the line
126+ // count of the job: it is never stored.
127+ int64 log_start = 7;
128+ bool has_lines = 9;
129+}
130+
131+// CIJob is stored under "job/<run_id:020d>/<run_attempt:010d>/<job id>".
132+message CIJob {
133+ // 36 was the line count of the log, which the log file holds.
134+ reserved 36;
135+ string id = 1;
136+ int64 run_id = 2;
137+ // Attempt in which the job executed (the one of its log).
138+ int32 attempt = 3;
139+ string repo = 4;
140+ string key = 5;
141+ string name = 6;
142+ CIJobPhase phase = 7;
143+ CIConclusion conclusion = 8;
144+ string error = 9;
145+ repeated string labels = 10;
146+ repeated string needs = 11;
147+ string runner_id = 12;
148+ string runner_name = 13;
149+ CIIsolation isolation = 14;
150+ repeated CIStep steps = 15;
151+ google.protobuf.Timestamp created_at = 16;
152+ google.protobuf.Timestamp started_at = 17;
153+ google.protobuf.Timestamp completed_at = 18;
154+ // Position of the job in the workflow file, and of the instance in its
155+ // matrix. A combo of -1 is a job whose matrix is not expanded yet.
156+ int32 index = 19;
157+ int32 combo = 20;
158+ string matrix_json = 21;
159+ // Number of instances of the job's matrix, for the strategy context.
160+ int32 combo_total = 22;
161+ int32 timeout_minutes = 23;
162+ bool fail_fast = 24;
163+ int32 max_parallel = 25;
164+ map<string, string> outputs = 26;
165+ bytes token_hash = 27;
166+ google.protobuf.Timestamp ready_at = 28;
167+ google.protobuf.Timestamp assigned_at = 29;
168+ google.protobuf.Timestamp acked_at = 30;
169+ int32 assign_cancels = 31;
170+ google.protobuf.Timestamp token_valid_until = 32;
171+ CICancelReason cancel_reason = 33;
172+ string cancel_message = 34;
173+ google.protobuf.Timestamp cancel_requested_at = 35;
174+ // Set when the job is planned: runs-on, name and contexts are evaluated.
175+ bool planned = 37;
176+ // Attempt of the run this record belongs to; it is the key. A job kept by a
177+ // re-run of the failed jobs is copied with the run attempt of the re-run and
178+ // keeps its own attempt.
179+ int32 run_attempt = 38;
180+ // The contexts JSON given to the runner, computed when the job is planned
181+ // (needs outputs are final then).
182+ string contexts_json = 39;
183+}
184+
185+// CICheck is stored under "check/<repo>/<sha>/<id>": the id of its job, or
186+// "run-<run id>" for a run that failed without a job (invalid workflow file).
187+message CICheck {
188+ string id = 1;
189+ string repo = 2;
190+ string sha = 3;
191+ string name = 4;
192+ CIStatus status = 5;
193+ CIConclusion conclusion = 6;
194+ int64 run_id = 7;
195+ string job_id = 8;
196+ int32 attempt = 9;
197+ string workflow_name = 10;
198+ google.protobuf.Timestamp created_at = 11;
199+ google.protobuf.Timestamp started_at = 12;
200+ google.protobuf.Timestamp completed_at = 13;
201+}
202+
203+// CIRunner is stored under "runner/<id>".
204+message CIRunner {
205+ string id = 1;
206+ string name = 2;
207+ repeated string labels = 3;
208+ CIIsolation isolation = 4;
209+ string version = 5;
210+ string arch = 6;
211+ string os = 7;
212+ google.protobuf.Timestamp registered_at = 8;
213+ bytes token_hash = 9;
214+}

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