detached b4c1e2a

Merge origin/main into feat/passkeys

commit
b4c1e2af04fdf69e188abc00b5412bd84d564ae2
author
brice · · 2026-10-12 20:17 UTC
merge of
db04eb5 + 98d2616 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
175 files · +23618 -641
M README.md +53 -3
@@ -27,32 +27,63 @@ 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
31-`ermite version` prints the version (and the server's when `ERMITE_URL` is set).
33+`ermite version` prints the version (and the server's when a server URL is configured, see Remote CLI).
3234
3335 ## Remote CLI
3436
3537 The same binary drives a running server over its API. It reads `ERMITE_URL` (the server address, `http://` or `https://`) and `ERMITE_TOKEN` (a bearer token; optional for public reads). A token sent over plain `http://` to a host that is not loopback triggers a warning on every call.
3638
39+Each setting comes from the environment or from a config file, the environment first:
40+
41+| Setting | Environment | Config file key |
42+|---|---|---|
43+| server address | `ERMITE_URL` | `url` |
44+| bearer token (optional for public reads) | `ERMITE_TOKEN` | `token.cmd`, a command that prints it |
45+
46+The file is `~/.config/ermite/config` (`$XDG_CONFIG_HOME/ermite/config` when set; `~/Library/Application Support/ermite/config` on macOS and `%AppData%\ermite\config` on Windows; `ERMITE_CONFIG` names another file). `ermite config show` prints the path in use. It holds no secret: `token.cmd` is a command line that fetches the token from your secret manager (pass, gopass, op, bw, secret-tool, …).
47+
48+```ini
49+# ~/.config/ermite/config
50+url = https://git.example.com
51+token.cmd = pass show ermite/token
52+```
53+
54+```sh
55+ermite config set url https://git.example.com
56+ermite config set token.cmd 'pass show ermite/token' # or: gopass show -o ermite/token, op read op://vault/ermite/token, secret-tool lookup service ermite
57+ermite config show [--json] # path and stored values; unset removes a key
58+```
59+
60+`token.cmd` runs with `sh -c` (`cmd /C` on Windows) only when a command needs the token, at most once per run, with your environment and, when standard input is a terminal, that terminal (so a pinentry or biometric prompt works; otherwise it reads nothing). On a timeout or when ermite is interrupted or terminated, the command and everything it started are killed (when standard input is a terminal, only the shell is, so that prompts keep working). It must print the token alone on one line, in under 4 KiB, within 60 seconds. It is not run when `ERMITE_TOKEN` is set, nor by `ermite version` or `ermite config`, and not when the config file, or the directory holding it, can be written by other users or the file belongs to another user (`ermite config show` and `set` warn about it). On Windows this check does not exist: keep the file in your own profile, the default place. It belongs to the server of the file: when `ERMITE_URL` names a different server than the file's `url`, it is not run and the call is anonymous (with a notice; set `ERMITE_TOKEN` to authenticate there). A failure is reported in one line without its output. Shell completion never runs it: it is anonymous unless `ERMITE_TOKEN` is set.
61+
3762 ```sh
3863 export ERMITE_URL=https://git.example.com ERMITE_TOKEN=ermite_...
3964 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
65+ermite token create <label> [--preset cli|agent|ci|runner] [--scope repo:read …] [--repo name …] [--branch-prefix agent/x/ …] [--expires 30d] # prints the secret once
4166 ermite token list | revoke <id>
67+# a token for an AI agent that cannot touch main:
68+ermite token create claude --preset agent --scope repo:read --repo myrepo --branch-prefix agent/claude/
4269 ermite mr create <repo> --source <branch> --title … | list <repo> | show <repo> <n> [--diff]
4370 ermite mr comment <repo> <n> -m … [--path … --line … --side old|new] | resolve <repo> <n> <comment>
4471 ermite mr merge <repo> <n> --head <id> [--squash] [--delete-branch] | close <repo> <n>
72+ermite run list <repo> [--workflow …] [--branch …] [--status …] | view <repo> <id> [--attempt n] [--log [--job …]]
73+ermite run watch <repo> <id> [--log] | cancel <repo> <id> | rerun <repo> <id> [--failed]
74+ermite runner list | remove <id>
4575 ermite web login # prints a one-time sign-in link (60 s)
4676 ermite web logout --all # signs every browser out
4777 ermite web passkey list | remove <id> # the owner's passkeys; removing one signs every browser out
4878 ermite signing-key show # public key and allowed_signers line of squash commits
79+ermite config show [--json] | set <url|token.cmd> <value> | unset <key> # the CLI config file
4980 ermite version # client and server, warns about missing server features
5081 ermite watch [repo] [--json] # follows events, reconnects and resumes
5182 ```
5283
5384 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.
5485
55-`--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.
86+`--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.
5687
5788 ## Git over HTTP
5889
@@ -63,10 +94,29 @@ git push http://u:<token>@git.example.com/<repo>.git main
6394
6495 Use the admin token (`ERMITE_ADMIN_TOKEN`) or a stored token as the password; the username is ignored. Store it in a Git credential helper rather than in the URL. Pushing to a new name creates a private repository. Repository names are lowercase, `[a-z0-9._-]`. TLS is terminated by your proxy; list its network in `ERMITE_TRUSTED_PROXIES` so that failed-login limits see the real client address (`X-Forwarded-For`).
6596
97+## Branch-limited tokens
98+
99+For an AI agent that should propose changes, not publish them: `--branch-prefix agent/claude/` (repeatable, up to 8, each ending with `/`) limits a token to the branches under its prefixes.
100+
101+- It can push, force-push and delete `refs/heads/agent/claude/…`. Any other ref (`main`, other branches, tags, notes, anything outside `refs/heads/`) refuses the whole push, and the message names the ref.
102+- It cannot create a repository (push to a new name, or `repo create`), delete one, or change its settings (default branch, visibility, description, merge modes, failing-checks rule): those decide what happens to branches outside its prefixes. It never has the rights of the owner (it cannot open web sessions even with every scope), and can only create tokens whose prefixes lie within its own.
103+- It opens, comments on and closes merge requests as usual; with `mr:merge` it can merge only into a branch under its prefixes, and delete only a source branch under them. You review and merge the agent's request.
104+- CI: it reads runs, jobs, logs and checks as any token that can read the repository. With `ci:write` it can cancel and re-run only the runs of branches under its prefixes, never those of `main`, other branches or tags. It never manages runners (`runner list|remove`, registration), even with `runner:register`: a runner runs every branch of every repository.
105+- `ermite token list` shows a `branches` column (`all` when unlimited); `--json` has `branch_prefixes`.
106+- This build stores tokens in storage schema 2. Once it has started on your data, an older ermite refuses to start on it (it would read limited tokens as unrestricted): to go back, restore the backup taken before the upgrade.
107+
66108 ## Merge requests
67109
68110 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).
69111
112+## CI
113+
114+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.
115+
116+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.
117+
118+`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`.
119+
70120 ## Web sign-in
71121
72122 Two ways in, both for the owner, both giving the same session cookie for 12 hours. Neither works over plain `http` except on localhost: the cookie is `Secure`.
A gen/ermite/storage/ci.pb.go +1603 -0 generated file: collapsed
M gen/ermite/storage/repo.pb.go +16 -5 generated file: collapsed
M go.mod +4 -2
@@ -12,12 +12,14 @@ tool (
1212 )
1313
1414 require (
15- bnema.dev/ermite-api v0.0.0-20261012153643-fad97fd3242a
15+ bnema.dev/ermite-actions v0.0.0-20261012192445-fcc31accb5a4
16+ bnema.dev/ermite-api v0.0.0-20261012155218-f8f64413e63c
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
1920 github.com/a-h/templ v0.3.1020
2021 github.com/alecthomas/chroma/v2 v2.27.0
22+ github.com/bnema/kvconf v0.1.2
2123 github.com/bnema/zerowrap v1.4.1
2224 github.com/descope/virtualwebauthn v1.0.5
2325 github.com/go-webauthn/webauthn v0.18.2
@@ -27,6 +29,7 @@ require (
2729 github.com/yuin/goldmark v1.8.6
2830 golang.org/x/crypto v0.57.0
2931 golang.org/x/sys v0.49.0
32+ golang.org/x/term v0.46.0
3033 google.golang.org/genproto/googleapis/rpc v0.0.0-20260904194346-d0f1323225a4
3134 google.golang.org/protobuf v1.36.12
3235 )
@@ -159,7 +162,6 @@ require (
159162 golang.org/x/mod v0.41.0 // indirect
160163 golang.org/x/net v0.59.0 // indirect
161164 golang.org/x/sync v0.23.0 // indirect
162- golang.org/x/term v0.46.0 // indirect
163165 golang.org/x/text v0.42.0 // indirect
164166 golang.org/x/tools v0.51.0 // indirect
165167 google.golang.org/genproto/googleapis/api v0.0.0-20260904194346-d0f1323225a4 // indirect
M go.sum +6 -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 +5 -2
@@ -139,13 +139,16 @@ 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.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners},
143143 client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, client.FeaturePasskeys, "other"}))
144144 assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{
145- client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession, client.FeaturePasskeys,
145+ client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSession,
146+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners,
147+ client.FeaturePasskeys,
146148 }))
147149 assert.Equal(t, []string{client.FeaturePasskeys}, client.MissingFeatures([]string{
148150 client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureMergeDeleteBranch, client.FeatureSigningKey, client.FeatureSession,
151+ client.FeatureTokenBranchPrefixes, client.FeatureCIRuns, client.FeatureCIRunners,
149152 }))
150153 assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil))
151154 }
M internal/adapters/client/config.go +8 -7
@@ -48,18 +48,19 @@ type Config struct {
4848 UserAgent string
4949 }
5050
51-// ConfigFromEnv reads ERMITE_URL and ERMITE_TOKEN through getenv and validates
52-// them. Timeout and UserAgent are left for the caller to set.
53-func ConfigFromEnv(getenv func(string) string) (Config, error) {
54- raw := strings.TrimSpace(getenv(EnvURL))
51+// NewConfig validates the server address and the token, however they were
52+// found (environment, config file or token command). The token is trimmed and
53+// may be empty (anonymous). Timeout and UserAgent are left for the caller.
54+func NewConfig(rawURL, token string) (Config, error) {
55+ raw := strings.TrimSpace(rawURL)
5556 if raw == "" {
56- return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com", EnvURL)
57+ return Config{}, fmt.Errorf("%s is not set: set it to the address of your ermite server, for example https://git.example.com, or run `ermite config set url <url>`", EnvURL)
5758 }
5859 base, err := ParseURL(raw)
5960 if err != nil {
60- return Config{}, fmt.Errorf("%s: %w", EnvURL, err)
61+ return Config{}, fmt.Errorf("invalid server URL: %w", err)
6162 }
62- return Config{URL: base.String(), Token: strings.TrimSpace(getenv(EnvToken))}, nil
63+ return Config{URL: base.String(), Token: strings.TrimSpace(token)}, nil
6364 }
6465
6566 // ParseURL validates a server address: an absolute http(s) URL with a host,
M internal/adapters/client/config_test.go +11 -10
@@ -11,20 +11,21 @@ import (
1111 "bnema.dev/ermite/internal/adapters/client"
1212 )
1313
14-func env(kv map[string]string) func(string) string {
15- return func(k string) string { return kv[k] }
16-}
17-
18-func TestConfigFromEnv(t *testing.T) {
19- cfg, err := client.ConfigFromEnv(env(map[string]string{
20- client.EnvURL: " HTTPS://Git.Example.com/base/ ", client.EnvToken: " ermite_abc\n",
21- }))
14+func TestNewConfig(t *testing.T) {
15+ cfg, err := client.NewConfig(" HTTPS://Git.Example.com/base/ ", " ermite_abc\n")
2216 require.NoError(t, err)
2317 assert.Equal(t, "https://Git.Example.com/base", cfg.URL)
2418 assert.Equal(t, "ermite_abc", cfg.Token)
2519 }
2620
27-func TestConfigFromEnvRejects(t *testing.T) {
21+func TestNewConfigUnsetTellsHowToFixIt(t *testing.T) {
22+ _, err := client.NewConfig(" ", "")
23+ require.Error(t, err)
24+ assert.Contains(t, err.Error(), "ERMITE_URL")
25+ assert.Contains(t, err.Error(), "ermite config set url")
26+}
27+
28+func TestNewConfigRejects(t *testing.T) {
2829 tests := []struct {
2930 name, url, want string
3031 }{
@@ -39,7 +40,7 @@ func TestConfigFromEnvRejects(t *testing.T) {
3940 }
4041 for _, tt := range tests {
4142 t.Run(tt.name, func(t *testing.T) {
42- _, err := client.ConfigFromEnv(env(map[string]string{client.EnvURL: tt.url}))
43+ _, err := client.NewConfig(tt.url, "")
4344 require.Error(t, err)
4445 assert.Contains(t, err.Error(), tt.want)
4546 assert.NotContains(t, err.Error(), "s3cret", "credentials must never be echoed")
M internal/adapters/client/features.go +12 -1
@@ -26,11 +26,22 @@ const (
2626 // (`ermite web passkey`). A server lists it only when passkeys are
2727 // available (https public URL with a DNS host name).
2828 FeaturePasskeys = "passkeys"
29+ // FeatureTokenBranchPrefixes is Token.branch_prefixes and
30+ // CreateTokenRequest.branch_prefixes (`ermite token create --branch-prefix`).
31+ // A server without it ignores the field and would create an unlimited
32+ // token: `token create --branch-prefix` reads the features first and
33+ // refuses when it is missing (the main guard), and also checks the token it
34+ // gets back (a backstop).
35+ FeatureTokenBranchPrefixes = "token.branch_prefixes"
36+ // FeatureCIRuns is RunService (`ermite run`), FeatureCIRunners its runner
37+ // calls (`ermite runner`).
38+ FeatureCIRuns = "ci.runs"
39+ FeatureCIRunners = "ci.runners"
2940 )
3041
3142 // UsedFeatures lists the features the CLI uses, in the order they are reported.
3243 func UsedFeatures() []string {
33- return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeaturePasskeys}
44+ return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureMergeDeleteBranch, FeatureSigningKey, FeatureSession, FeatureTokenBranchPrefixes, FeatureCIRuns, FeatureCIRunners, FeaturePasskeys}
3445 }
3546
3647 // 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/mergerequests_test.go +3 -2
@@ -358,7 +358,8 @@ func assertNoMergeRequestKeys(t *testing.T, mrs *kvstore.MergeRequests, n domain
358358 }
359359
360360 // TestDeleteRacingWithWrites: writes racing with Delete either land before it
361-// and are deleted, or are refused after it; nothing is left for a repository
361+// and are deleted, or are refused (not found after it, or a conflict when the
362+// retries run out under this contention); nothing is left for a repository
362363 // created again under the name.
363364 func TestDeleteRacingWithWrites(t *testing.T) {
364365 ctx := context.Background()
@@ -376,7 +377,7 @@ func TestDeleteRacingWithWrites(t *testing.T) {
376377 if w%2 == 0 {
377378 err := mrs.SetRead(ctx, n, mr.Number, oid('a'))
378379 if err != nil {
379- assert.ErrorIs(t, err, domain.ErrNotFound)
380+ assert.True(t, errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrNotFound), "round %d: %v", round, err)
380381 return
381382 }
382383 continue
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/kvstore/store.go +23 -4
@@ -17,8 +17,11 @@ import (
1717 "bnema.dev/ermite/internal/ports"
1818 )
1919
20-// SchemaVersion is the storage schema this build reads and writes.
21-const SchemaVersion = 1
20+// SchemaVersion is the storage schema this build reads and writes. Version 2
21+// adds branch prefixes to tokens: a build that does not know them would treat
22+// a branch-limited token as unrestricted, so it must refuse the data
23+// (ErrSchemaTooNew) instead.
24+const SchemaVersion = 2
2225
2326 const schemaKey = "meta/schema"
2427
@@ -33,8 +36,8 @@ type Store struct {
3336 var _ ports.RepoMetaStore = (*Store)(nil)
3437
3538 // Open opens (or creates) the database in dir and checks the storage schema:
36-// a missing version is written as SchemaVersion, a higher one stops with
37-// ErrSchemaTooNew. dir must exist.
39+// a missing version is written as SchemaVersion, a lower one is upgraded to it,
40+// a higher one stops with ErrSchemaTooNew. dir must exist.
3841 func Open(ctx context.Context, dir string) (*Store, error) {
3942 db, err := kv.Open(ctx, dir, kv.Options{})
4043 if err != nil {
@@ -107,6 +110,22 @@ func (s *Store) checkSchema(ctx context.Context) error {
107110 case sv.GetVersion() == 0:
108111 return fmt.Errorf("decode storage schema: invalid version 0")
109112 }
113+ if sv.GetVersion() < SchemaVersion {
114+ // Older data stays readable as it is (the changes are additive);
115+ // the new version is recorded so that older builds refuse it.
116+ var b kv.Batch
117+ b.RequireVersion(key, item.Version)
118+ b.Put(key, mustMarshal(&storagepb.SchemaVersion{Version: SchemaVersion}))
119+ _, err := s.db.Apply(ctx, &b)
120+ if errors.Is(err, kv.ErrConflict) {
121+ continue // changed meanwhile: read it again
122+ }
123+ if err != nil {
124+ return fmt.Errorf("upgrade storage schema: %w", err)
125+ }
126+ log.Info().Uint32("from", sv.GetVersion()).Uint32("schema", SchemaVersion).Msg("storage schema upgraded")
127+ return nil
128+ }
110129 log.Info().Uint32("schema", sv.GetVersion()).Msg("storage schema checked")
111130 return nil
112131 }
M internal/adapters/kvstore/store_test.go +18 -5
@@ -191,12 +191,12 @@ func readSchema(t *testing.T, dir string) uint32 {
191191 func TestSchema(t *testing.T) {
192192 ctx := context.Background()
193193
194- t.Run("missing is written as 1", func(t *testing.T) {
194+ t.Run("missing is written as the current version", func(t *testing.T) {
195195 dir := t.TempDir()
196196 s, err := kvstore.Open(ctx, dir)
197197 require.NoError(t, err)
198198 require.NoError(t, s.Close(ctx))
199- assert.Equal(t, uint32(1), readSchema(t, dir))
199+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
200200 })
201201
202202 t.Run("current is accepted and kept", func(t *testing.T) {
@@ -207,15 +207,28 @@ func TestSchema(t *testing.T) {
207207 s, err = kvstore.Open(ctx, dir)
208208 require.NoError(t, err)
209209 require.NoError(t, s.Close(ctx))
210- assert.Equal(t, uint32(1), readSchema(t, dir))
210+ assert.Equal(t, uint32(kvstore.SchemaVersion), readSchema(t, dir))
211+ })
212+
213+ t.Run("version 1 is upgraded to 2, its data kept", func(t *testing.T) {
214+ dir := t.TempDir()
215+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 1}))
216+ putRaw(t, dir, "repo/old", mustProto(t, &storagepb.Repo{Name: "old", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC}))
217+ s, err := kvstore.Open(ctx, dir)
218+ require.NoError(t, err)
219+ got, err := s.Get(ctx, name(t, "old"))
220+ require.NoError(t, err)
221+ assert.Equal(t, "old", got.Name.String())
222+ require.NoError(t, s.Close(ctx))
223+ assert.Equal(t, uint32(2), readSchema(t, dir))
211224 })
212225
213226 t.Run("newer stops startup and releases the lock", func(t *testing.T) {
214227 dir := t.TempDir()
215- putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 2}))
228+ putRaw(t, dir, "meta/schema", mustProto(t, &storagepb.SchemaVersion{Version: 3}))
216229 _, err := kvstore.Open(ctx, dir)
217230 require.ErrorIs(t, err, kvstore.ErrSchemaTooNew)
218- assert.Equal(t, uint32(2), readSchema(t, dir), "the lock was released and the value kept")
231+ assert.Equal(t, uint32(3), readSchema(t, dir), "the lock was released and the value kept")
219232 })
220233
221234 t.Run("garbage and zero are errors", func(t *testing.T) {
M internal/adapters/kvstore/tokens.go +13 -0
@@ -191,6 +191,11 @@ func (s *Tokens) Touch(ctx context.Context, id string, at time.Time) error {
191191 return fmt.Errorf("touch token %q: %w", id, domain.ErrConflict)
192192 }
193193
194+// encodeToken refuses what the use case already refused (scopes, branch
195+// prefixes): a malformed prefix must never reach the store, where it would
196+// limit a token wrongly or not at all.
197+//
198+//hexcheck:ignore suspicious-business-logic-in-adapter schema encoding: it re-checks the syntax the use case enforced, it decides no policy
194199 func encodeToken(t domain.Token) ([]byte, error) {
195200 if !validTokenID(t.ID) {
196201 return nil, fmt.Errorf("%w: invalid id %q", domain.ErrInvalidToken, t.ID)
@@ -221,6 +226,12 @@ func encodeToken(t domain.Token) ([]byte, error) {
221226 }
222227 pb.Repos = append(pb.Repos, r.String())
223228 }
229+ for _, bp := range t.BranchPrefixes {
230+ if _, err := domain.ParseBranchPrefix(bp); err != nil {
231+ return nil, err
232+ }
233+ pb.BranchPrefixes = append(pb.BranchPrefixes, bp)
234+ }
224235 return mustMarshal(pb), nil
225236 }
226237
@@ -256,5 +267,7 @@ func decodeToken(val []byte) (domain.Token, error) {
256267 }
257268 t.Repos = append(t.Repos, name)
258269 }
270+ // Kept as stored: a token with prefixes stays limited, whatever they say.
271+ t.BranchPrefixes = slices.Clone(pb.GetBranchPrefixes())
259272 return t, nil
260273 }
M internal/adapters/kvstore/tokens_test.go +53 -0
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/require"
1414
15+ storagepb "bnema.dev/ermite/gen/ermite/storage"
1516 "bnema.dev/ermite/internal/domain"
1617 )
1718
@@ -192,3 +193,55 @@ func TestTokensConcurrentTouchAndRevoke(t *testing.T) {
192193 assert.Len(t, list, 1)
193194 }
194195 }
196+
197+func TestTokensBranchPrefixes(t *testing.T) {
198+ ctx := context.Background()
199+
200+ t.Run("round trip, order kept", func(t *testing.T) {
201+ s := openStore(t, t.TempDir())
202+ tok := newToken(t, "t1", "s1")
203+ tok.BranchPrefixes = []string{"b/", "agent/claude/"}
204+ require.NoError(t, s.Tokens().Create(ctx, tok))
205+ got, err := s.Tokens().Get(ctx, "t1")
206+ require.NoError(t, err)
207+ assert.Equal(t, tok, got)
208+ byHash, err := s.Tokens().FindByHash(ctx, tok.Hash)
209+ require.NoError(t, err)
210+ assert.Equal(t, []string{"b/", "agent/claude/"}, byHash.BranchPrefixes)
211+ })
212+
213+ t.Run("a token stored before the field existed decodes with no prefixes", func(t *testing.T) {
214+ dir := t.TempDir()
215+ old := newToken(t, "old", "s-old")
216+ putRaw(t, dir, "token/old", mustProto(t, &storagepb.Token{
217+ Id: "old", Label: "old", Hash: old.Hash[:], Scopes: []string{"repo:write"},
218+ }))
219+ s := openStore(t, dir)
220+ got, err := s.Tokens().Get(ctx, "old")
221+ require.NoError(t, err)
222+ assert.Empty(t, got.BranchPrefixes)
223+ assert.Equal(t, []domain.Scope{domain.ScopeRepoWrite}, got.Scopes)
224+ })
225+
226+ t.Run("an invalid prefix is not stored", func(t *testing.T) {
227+ s := openStore(t, t.TempDir())
228+ for _, bad := range []string{"", "agent", "/", "a b/"} {
229+ tok := newToken(t, "t1", "s1")
230+ tok.BranchPrefixes = []string{bad}
231+ assert.ErrorIs(t, s.Tokens().Create(ctx, tok), domain.ErrInvalidToken, "%q", bad)
232+ }
233+ _, err := s.Tokens().Get(ctx, "t1")
234+ assert.ErrorIs(t, err, domain.ErrNotFound)
235+ })
236+
237+ t.Run("a stored prefix survives touch and stays limiting", func(t *testing.T) {
238+ s := openStore(t, t.TempDir())
239+ tok := newToken(t, "t1", "s1")
240+ tok.BranchPrefixes = []string{"agent/"}
241+ require.NoError(t, s.Tokens().Create(ctx, tok))
242+ require.NoError(t, s.Tokens().Touch(ctx, "t1", created.Add(time.Hour)))
243+ got, err := s.Tokens().Get(ctx, "t1")
244+ require.NoError(t, err)
245+ assert.Equal(t, []string{"agent/"}, got.BranchPrefixes)
246+ })
247+}
M internal/adapters/rpc/api.go +12 -1
@@ -28,7 +28,7 @@ const MaxRequestBytes = 1 << 20
2828 // Features are the optional capabilities of the contract that this server
2929 // always implements, as GetVersion lists them. FeaturePasskeys comes on top
3030 // when Config.Passkeys is set.
31-var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}
31+var Features = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}
3232
3333 // FeaturePasskeys is listed by GetVersion when passkeys are available.
3434 const FeaturePasskeys = "passkeys"
@@ -51,6 +51,11 @@ type Config struct {
5151 // budget of failures. Leave it unset rather than assigning a typed nil
5252 // pointer (a nil *T in the interface is not nil, and would be called).
5353 Limiter ports.FailureLimiter
54+ // Runner serves RunnerService and Runs serves RunService; without them the
55+ // CI services are not served. Leave them unset rather than assigning a
56+ // typed nil pointer.
57+ Runner ports.RunnerService
58+ Runs ports.RunService
5459 // Sessions are the owner's web sessions; nil serves no SessionService.
5560 // Leave it unset rather than assigning a typed nil pointer (a nil *T in
5661 // the interface is not nil, and the service would be served and called).
@@ -94,6 +99,12 @@ func New(cfg Config) *API {
9499 if cfg.Sessions != nil {
95100 mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{sessions: cfg.Sessions, passkeys: cfg.Passkeys}, opts...))
96101 }
102+ if cfg.Runner != nil {
103+ mux.Handle(ermiteconnect.NewRunnerServiceHandler(&runnerService{runners: cfg.Runner, limiter: cfg.Limiter}, opts...))
104+ }
105+ if cfg.Runs != nil {
106+ mux.Handle(ermiteconnect.NewRunServiceHandler(&runService{cfg.Runs}, opts...))
107+ }
97108 return &API{handler: mux}
98109 }
99110
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 +8 -0
@@ -5,6 +5,7 @@ package rpc
55 import (
66 "encoding/base64"
77 "fmt"
8+ "slices"
89 "time"
910
1011 "google.golang.org/protobuf/types/known/timestamppb"
@@ -156,6 +157,7 @@ func tokenToPB(t domain.Token) *pb.Token {
156157 for _, r := range t.Repos {
157158 out.Repos = append(out.Repos, r.String())
158159 }
160+ out.BranchPrefixes = slices.Clone(t.BranchPrefixes)
159161 return out
160162 }
161163
@@ -392,6 +394,12 @@ func watchEventToPB(we ports.WatchEvent) *pb.Event {
392394 case domain.CommentAdded:
393395 out.Time = timestamp(ev.At)
394396 out.Kind = &pb.Event_CommentAdded{CommentAdded: &pb.CommentAdded{Repo: ev.Repo.String(), Number: ev.Number, Comment: commentToPB(ev.Comment)}}
397+ case domain.RunUpdated:
398+ out.Time = timestamp(ev.At)
399+ out.Kind = runEventToPB(ev)
400+ case domain.JobUpdated:
401+ out.Time = timestamp(ev.At)
402+ out.Kind = jobEventToPB(ev)
395403 default:
396404 return nil
397405 }
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/rpc/token.go +1 -0
@@ -34,6 +34,7 @@ func (s *tokenService) CreateToken(ctx context.Context, req *connect.Request[pb.
3434 }
3535 params.Repos = append(params.Repos, name)
3636 }
37+ params.BranchPrefixes = req.Msg.GetBranchPrefixes()
3738 created, err := s.tokens.Create(ctx, caller(ctx), params)
3839 if err != nil {
3940 return nil, connectError(ctx, err)
M internal/adapters/rpc/token_test.go +31 -0
@@ -64,6 +64,37 @@ func TestTokenServiceCreateTokenPassesParams(t *testing.T) {
6464 require.NoError(t, err)
6565 }
6666
67+func TestTokenServiceBranchPrefixes(t *testing.T) {
68+ t.Parallel()
69+ tokens := mocks.NewTokenService(t)
70+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateTokenParams) bool {
71+ return assert.ObjectsAreEqual([]string{"agent/claude/", "bot/"}, p.BranchPrefixes)
72+ })).Return(ports.CreatedToken{
73+ Token: domain.Token{ID: "t1", BranchPrefixes: []string{"agent/claude/", "bot/"}},
74+ Secret: "s",
75+ }, nil).Once()
76+ resp, err := (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
77+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"},
78+ }))
79+ require.NoError(t, err)
80+ assert.Equal(t, []string{"agent/claude/", "bot/"}, resp.Msg.GetToken().GetBranchPrefixes())
81+
82+ // A malformed prefix is the use case's invalid_token: invalid_argument.
83+ tokens.EXPECT().Create(mock.Anything, domain.Owner(), mock.Anything).Return(ports.CreatedToken{}, domain.ErrInvalidToken).Once()
84+ _, err = (&tokenService{tokens}).CreateToken(ownerCtx(), connect.NewRequest(&pb.CreateTokenRequest{
85+ Label: "ci", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent"},
86+ }))
87+ requireCode(t, connect.CodeInvalidArgument, err)
88+
89+ // Listing returns them too; a token without any has none.
90+ tokens.EXPECT().ListPage(mock.Anything, domain.Owner(), 0, "").
91+ Return(ports.TokenPage{Tokens: []domain.Token{{ID: "a", BranchPrefixes: []string{"x/"}}, {ID: "b"}}}, nil).Once()
92+ list, err := (&tokenService{tokens}).ListTokens(ownerCtx(), connect.NewRequest(&pb.ListTokensRequest{}))
93+ require.NoError(t, err)
94+ assert.Equal(t, []string{"x/"}, list.Msg.GetTokens()[0].GetBranchPrefixes())
95+ assert.Empty(t, list.Msg.GetTokens()[1].GetBranchPrefixes())
96+}
97+
6798 func TestTokenServiceRevokeToken(t *testing.T) {
6899 t.Parallel()
69100 tests := []struct {
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/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
1415 | `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 | |
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, Passkey, Icon string
26+ CSS, HTMX, Live, SSE, App, Files, Passkey, 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.
@@ -57,6 +57,7 @@ var scripts = []struct {
5757 {"ermite.js", func(a *Assets, u string) { a.App = u }},
5858 {"files.js", func(a *Assets, u string) { a.Files = u }},
5959 {"passkey.js", func(a *Assets, u string) { a.Passkey = u }},
60+ {"log.js", func(a *Assets, u string) { a.Log = u }},
6061 }
6162
6263 // 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 generated file: collapsed
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
@@ -1703,14 +1703,18 @@ func mrRail(p MRPage) templ.Component {
17031703 return templ_7745c5c3_Err
17041704 }
17051705 }
1706- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<section aria-labelledby=\"r-checks\"><h2 id=\"r-checks\">Checks <span class=\"count\">0</span></h2><p class=\"dim\">no checks</p><p class=\"mute\">there is no CI yet: nothing blocks the merge.</p></section><section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
1706+ templ_7745c5c3_Err = MRChecks(p.Checks).Render(ctx, templ_7745c5c3_Buffer)
1707+ if templ_7745c5c3_Err != nil {
1708+ return templ_7745c5c3_Err
1709+ }
1710+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 146, "<section aria-labelledby=\"r-settings\"><h2 id=\"r-settings\">Settings</h2><dl class=\"kv\"><dt>modes</dt><dd>")
17071711 if templ_7745c5c3_Err != nil {
17081712 return templ_7745c5c3_Err
17091713 }
17101714 var templ_7745c5c3_Var86 string
17111715 templ_7745c5c3_Var86, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(p.Modes, ", "))
17121716 if templ_7745c5c3_Err != nil {
1713- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 314, Col: 36}
1717+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 310, Col: 36}
17141718 }
17151719 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var86))
17161720 if templ_7745c5c3_Err != nil {
@@ -1738,7 +1742,7 @@ func mrRail(p MRPage) templ.Component {
17381742 var templ_7745c5c3_Var87 string
17391743 templ_7745c5c3_Var87, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Commits)))
17401744 if templ_7745c5c3_Err != nil {
1741- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 71}
1745+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 322, Col: 71}
17421746 }
17431747 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var87))
17441748 if templ_7745c5c3_Err != nil {
@@ -1766,7 +1770,7 @@ func mrRail(p MRPage) templ.Component {
17661770 var templ_7745c5c3_Var88 templ.SafeURL
17671771 templ_7745c5c3_Var88, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.Href))
17681772 if templ_7745c5c3_Err != nil {
1769- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 36}
1773+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 36}
17701774 }
17711775 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var88))
17721776 if templ_7745c5c3_Err != nil {
@@ -1779,7 +1783,7 @@ func mrRail(p MRPage) templ.Component {
17791783 var templ_7745c5c3_Var89 string
17801784 templ_7745c5c3_Var89, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
17811785 if templ_7745c5c3_Err != nil {
1782- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 330, Col: 48}
1786+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 326, Col: 48}
17831787 }
17841788 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var89))
17851789 if templ_7745c5c3_Err != nil {
@@ -1792,7 +1796,7 @@ func mrRail(p MRPage) templ.Component {
17921796 var templ_7745c5c3_Var90 string
17931797 templ_7745c5c3_Var90, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
17941798 if templ_7745c5c3_Err != nil {
1795- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 32}
1799+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 327, Col: 32}
17961800 }
17971801 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var90))
17981802 if templ_7745c5c3_Err != nil {
@@ -1805,7 +1809,7 @@ func mrRail(p MRPage) templ.Component {
18051809 var templ_7745c5c3_Var91 string
18061810 templ_7745c5c3_Var91, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
18071811 if templ_7745c5c3_Err != nil {
1808- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 333, Col: 16}
1812+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 329, Col: 16}
18091813 }
18101814 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var91))
18111815 if templ_7745c5c3_Err != nil {
@@ -1819,7 +1823,7 @@ func mrRail(p MRPage) templ.Component {
18191823 var templ_7745c5c3_Var92 string
18201824 templ_7745c5c3_Var92, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
18211825 if templ_7745c5c3_Err != nil {
1822- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 15}
1826+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 15}
18231827 }
18241828 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var92))
18251829 if templ_7745c5c3_Err != nil {
@@ -1832,7 +1836,7 @@ func mrRail(p MRPage) templ.Component {
18321836 var templ_7745c5c3_Var93 string
18331837 templ_7745c5c3_Var93, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
18341838 if templ_7745c5c3_Err != nil {
1835- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 42}
1839+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 42}
18361840 }
18371841 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var93)
18381842 if templ_7745c5c3_Err != nil {
@@ -1845,7 +1849,7 @@ func mrRail(p MRPage) templ.Component {
18451849 var templ_7745c5c3_Var94 string
18461850 templ_7745c5c3_Var94, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
18471851 if templ_7745c5c3_Err != nil {
1848- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 335, Col: 53}
1852+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 331, Col: 53}
18491853 }
18501854 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var94))
18511855 if templ_7745c5c3_Err != nil {
@@ -1860,7 +1864,7 @@ func mrRail(p MRPage) templ.Component {
18601864 var templ_7745c5c3_Var95 string
18611865 templ_7745c5c3_Var95, templ_7745c5c3_Err = templ.JoinStringErrs(" · new")
18621866 if templ_7745c5c3_Err != nil {
1863- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 338, Col: 18}
1867+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 334, Col: 18}
18641868 }
18651869 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var95))
18661870 if templ_7745c5c3_Err != nil {
@@ -1890,7 +1894,7 @@ func mrRail(p MRPage) templ.Component {
18901894 var templ_7745c5c3_Var96 string
18911895 templ_7745c5c3_Var96, templ_7745c5c3_Err = templ.JoinStringErrs(p.CommitsNote)
18921896 if templ_7745c5c3_Err != nil {
1893- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 348, Col: 34}
1897+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 344, Col: 34}
18941898 }
18951899 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var96))
18961900 if templ_7745c5c3_Err != nil {
@@ -1908,7 +1912,7 @@ func mrRail(p MRPage) templ.Component {
19081912 var templ_7745c5c3_Var97 string
19091913 templ_7745c5c3_Var97, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
19101914 if templ_7745c5c3_Err != nil {
1911- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 352, Col: 65}
1915+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 348, Col: 65}
19121916 }
19131917 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var97))
19141918 if templ_7745c5c3_Err != nil {
@@ -1967,7 +1971,7 @@ func mergeBox(p MRPage) templ.Component {
19671971 var templ_7745c5c3_Var99 string
19681972 templ_7745c5c3_Var99, templ_7745c5c3_Err = templ.JoinStringErrs(p.HeadShort)
19691973 if templ_7745c5c3_Err != nil {
1970- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 369, Col: 18}
1974+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 365, Col: 18}
19711975 }
19721976 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var99))
19731977 if templ_7745c5c3_Err != nil {
@@ -1981,7 +1985,7 @@ func mergeBox(p MRPage) templ.Component {
19811985 var templ_7745c5c3_Var100 string
19821986 templ_7745c5c3_Var100, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
19831987 if templ_7745c5c3_Err != nil {
1984- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 373, Col: 17}
1988+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 369, Col: 17}
19851989 }
19861990 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var100))
19871991 if templ_7745c5c3_Err != nil {
@@ -1999,7 +2003,7 @@ func mergeBox(p MRPage) templ.Component {
19992003 var templ_7745c5c3_Var101 templ.SafeURL
20002004 templ_7745c5c3_Var101, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.MergeAction))
20012005 if templ_7745c5c3_Err != nil {
2002- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 376, Col: 79}
2006+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 372, Col: 79}
20032007 }
20042008 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var101))
20052009 if templ_7745c5c3_Err != nil {
@@ -2012,7 +2016,7 @@ func mergeBox(p MRPage) templ.Component {
20122016 var templ_7745c5c3_Var102 string
20132017 templ_7745c5c3_Var102, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
20142018 if templ_7745c5c3_Err != nil {
2015- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 58}
2019+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 373, Col: 58}
20162020 }
20172021 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var102)
20182022 if templ_7745c5c3_Err != nil {
@@ -2030,7 +2034,7 @@ func mergeBox(p MRPage) templ.Component {
20302034 var templ_7745c5c3_Var103 string
20312035 templ_7745c5c3_Var103, templ_7745c5c3_Err = templ.ResolveAttributeValue(m.Value)
20322036 if templ_7745c5c3_Err != nil {
2033- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 29}
2037+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 29}
20342038 }
20352039 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var103)
20362040 if templ_7745c5c3_Err != nil {
@@ -2043,7 +2047,7 @@ func mergeBox(p MRPage) templ.Component {
20432047 var templ_7745c5c3_Var104 string
20442048 templ_7745c5c3_Var104, templ_7745c5c3_Err = templ.JoinStringErrs(m.Label)
20452049 if templ_7745c5c3_Err != nil {
2046- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 41}
2050+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 377, Col: 41}
20472051 }
20482052 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var104))
20492053 if templ_7745c5c3_Err != nil {
@@ -2066,7 +2070,7 @@ func mergeBox(p MRPage) templ.Component {
20662070 var templ_7745c5c3_Var105 string
20672071 templ_7745c5c3_Var105, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
20682072 if templ_7745c5c3_Err != nil {
2069- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 385, Col: 101}
2073+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 381, Col: 101}
20702074 }
20712075 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var105))
20722076 if templ_7745c5c3_Err != nil {
@@ -2094,7 +2098,7 @@ func mergeBox(p MRPage) templ.Component {
20942098 var templ_7745c5c3_Var106 templ.SafeURL
20952099 templ_7745c5c3_Var106, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.ReadAction))
20962100 if templ_7745c5c3_Err != nil {
2097- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 394, Col: 64}
2101+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 390, Col: 64}
20982102 }
20992103 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var106))
21002104 if templ_7745c5c3_Err != nil {
@@ -2107,7 +2111,7 @@ func mergeBox(p MRPage) templ.Component {
21072111 var templ_7745c5c3_Var107 string
21082112 templ_7745c5c3_Var107, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Box.Head)
21092113 if templ_7745c5c3_Err != nil {
2110- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 59}
2114+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 391, Col: 59}
21112115 }
21122116 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var107)
21132117 if templ_7745c5c3_Err != nil {
@@ -2125,7 +2129,7 @@ func mergeBox(p MRPage) templ.Component {
21252129 var templ_7745c5c3_Var108 templ.SafeURL
21262130 templ_7745c5c3_Var108, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Box.CloseAction))
21272131 if templ_7745c5c3_Err != nil {
2128- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 399, Col: 64}
2132+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 395, Col: 64}
21292133 }
21302134 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var108))
21312135 if templ_7745c5c3_Err != nil {
@@ -2195,7 +2199,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
21952199 var templ_7745c5c3_Var111 string
21962200 templ_7745c5c3_Var111, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
21972201 if templ_7745c5c3_Err != nil {
2198- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 415, Col: 17}
2202+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 411, Col: 17}
21992203 }
22002204 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var111))
22012205 if templ_7745c5c3_Err != nil {
@@ -2208,7 +2212,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22082212 var templ_7745c5c3_Var112 string
22092213 templ_7745c5c3_Var112, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22102214 if templ_7745c5c3_Err != nil {
2211- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 415, Col: 34}
2215+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 411, Col: 34}
22122216 }
22132217 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var112))
22142218 if templ_7745c5c3_Err != nil {
@@ -2221,7 +2225,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22212225 var templ_7745c5c3_Var113 templ.SafeURL
22222226 templ_7745c5c3_Var113, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.SourceHref))
22232227 if templ_7745c5c3_Err != nil {
2224- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 45}
2228+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 45}
22252229 }
22262230 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var113))
22272231 if templ_7745c5c3_Err != nil {
@@ -2234,7 +2238,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22342238 var templ_7745c5c3_Var114 string
22352239 templ_7745c5c3_Var114, templ_7745c5c3_Err = templ.JoinStringErrs(p.Source)
22362240 if templ_7745c5c3_Err != nil {
2237- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 58}
2241+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 414, Col: 58}
22382242 }
22392243 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var114))
22402244 if templ_7745c5c3_Err != nil {
@@ -2247,7 +2251,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22472251 var templ_7745c5c3_Var115 templ.SafeURL
22482252 templ_7745c5c3_Var115, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.TargetHref))
22492253 if templ_7745c5c3_Err != nil {
2250- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 45}
2254+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 45}
22512255 }
22522256 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var115))
22532257 if templ_7745c5c3_Err != nil {
@@ -2260,7 +2264,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22602264 var templ_7745c5c3_Var116 string
22612265 templ_7745c5c3_Var116, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
22622266 if templ_7745c5c3_Err != nil {
2263- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 420, Col: 58}
2267+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 416, Col: 58}
22642268 }
22652269 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var116))
22662270 if templ_7745c5c3_Err != nil {
@@ -2273,7 +2277,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22732277 var templ_7745c5c3_Var117 string
22742278 templ_7745c5c3_Var117, templ_7745c5c3_Err = templ.JoinStringErrs(p.AheadText)
22752279 if templ_7745c5c3_Err != nil {
2276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 422, Col: 21}
2280+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 418, Col: 21}
22772281 }
22782282 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var117))
22792283 if templ_7745c5c3_Err != nil {
@@ -2291,7 +2295,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
22912295 var templ_7745c5c3_Var118 string
22922296 templ_7745c5c3_Var118, templ_7745c5c3_Err = templ.JoinStringErrs(p.Note)
22932297 if templ_7745c5c3_Err != nil {
2294- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 426, Col: 29}
2298+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 422, Col: 29}
22952299 }
22962300 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var118))
22972301 if templ_7745c5c3_Err != nil {
@@ -2314,7 +2318,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23142318 var templ_7745c5c3_Var119 templ.SafeURL
23152319 templ_7745c5c3_Var119, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Existing.Href))
23162320 if templ_7745c5c3_Err != nil {
2317- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 114}
2321+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 425, Col: 114}
23182322 }
23192323 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var119))
23202324 if templ_7745c5c3_Err != nil {
@@ -2327,7 +2331,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23272331 var templ_7745c5c3_Var120 string
23282332 templ_7745c5c3_Var120, templ_7745c5c3_Err = templ.JoinStringErrs(p.Existing.Label)
23292333 if templ_7745c5c3_Err != nil {
2330- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 135}
2334+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 425, Col: 135}
23312335 }
23322336 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var120))
23332337 if templ_7745c5c3_Err != nil {
@@ -2350,7 +2354,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23502354 var templ_7745c5c3_Var121 templ.SafeURL
23512355 templ_7745c5c3_Var121, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Create.Action))
23522356 if templ_7745c5c3_Err != nil {
2353- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 83}
2357+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 428, Col: 83}
23542358 }
23552359 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var121))
23562360 if templ_7745c5c3_Err != nil {
@@ -2363,7 +2367,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23632367 var templ_7745c5c3_Var122 string
23642368 templ_7745c5c3_Var122, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Source)
23652369 if templ_7745c5c3_Err != nil {
2366- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 433, Col: 62}
2370+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 429, Col: 62}
23672371 }
23682372 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var122)
23692373 if templ_7745c5c3_Err != nil {
@@ -2376,7 +2380,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23762380 var templ_7745c5c3_Var123 string
23772381 templ_7745c5c3_Var123, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Target)
23782382 if templ_7745c5c3_Err != nil {
2379- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 434, Col: 62}
2383+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 430, Col: 62}
23802384 }
23812385 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var123)
23822386 if templ_7745c5c3_Err != nil {
@@ -2389,7 +2393,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
23892393 var templ_7745c5c3_Var124 string
23902394 templ_7745c5c3_Var124, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.Create.Title)
23912395 if templ_7745c5c3_Err != nil {
2392- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 436, Col: 97}
2396+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 432, Col: 97}
23932397 }
23942398 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var124)
23952399 if templ_7745c5c3_Err != nil {
@@ -2412,7 +2416,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24122416 var templ_7745c5c3_Var125 string
24132417 templ_7745c5c3_Var125, templ_7745c5c3_Err = templ.JoinStringErrs(plural(p.NFiles, "file", "files"))
24142418 if templ_7745c5c3_Err != nil {
2415- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 57}
2419+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 442, Col: 57}
24162420 }
24172421 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var125))
24182422 if templ_7745c5c3_Err != nil {
@@ -2425,7 +2429,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24252429 var templ_7745c5c3_Var126 string
24262430 templ_7745c5c3_Var126, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Adds))
24272431 if templ_7745c5c3_Err != nil {
2428- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 97}
2432+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 442, Col: 97}
24292433 }
24302434 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var126))
24312435 if templ_7745c5c3_Err != nil {
@@ -2438,7 +2442,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24382442 var templ_7745c5c3_Var127 string
24392443 templ_7745c5c3_Var127, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(p.Dels))
24402444 if templ_7745c5c3_Err != nil {
2441- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 446, Col: 142}
2445+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 442, Col: 142}
24422446 }
24432447 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var127))
24442448 if templ_7745c5c3_Err != nil {
@@ -2482,7 +2486,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
24822486 var templ_7745c5c3_Var128 string
24832487 templ_7745c5c3_Var128, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
24842488 if templ_7745c5c3_Err != nil {
2485- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 461, Col: 23}
2489+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 457, Col: 23}
24862490 }
24872491 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var128)
24882492 if templ_7745c5c3_Err != nil {
@@ -2500,7 +2504,7 @@ func Compare(l Layout, p ComparePage) templ.Component {
25002504 var templ_7745c5c3_Var129 string
25012505 templ_7745c5c3_Var129, templ_7745c5c3_Err = templ.ResolveAttributeValue(p.View)
25022506 if templ_7745c5c3_Err != nil {
2503- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 463, Col: 23}
2507+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 459, Col: 23}
25042508 }
25052509 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var129)
25062510 if templ_7745c5c3_Err != nil {
@@ -2570,7 +2574,7 @@ func compareRail(p ComparePage) templ.Component {
25702574 var templ_7745c5c3_Var131 string
25712575 templ_7745c5c3_Var131, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Ahead)))
25722576 if templ_7745c5c3_Err != nil {
2573- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 67}
2577+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 475, Col: 67}
25742578 }
25752579 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var131))
25762580 if templ_7745c5c3_Err != nil {
@@ -2588,7 +2592,7 @@ func compareRail(p ComparePage) templ.Component {
25882592 var templ_7745c5c3_Var132 templ.SafeURL
25892593 templ_7745c5c3_Var132, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
25902594 if templ_7745c5c3_Err != nil {
2591- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 37}
2595+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 37}
25922596 }
25932597 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var132))
25942598 if templ_7745c5c3_Err != nil {
@@ -2601,7 +2605,7 @@ func compareRail(p ComparePage) templ.Component {
26012605 var templ_7745c5c3_Var133 string
26022606 templ_7745c5c3_Var133, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
26032607 if templ_7745c5c3_Err != nil {
2604- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 483, Col: 49}
2608+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 479, Col: 49}
26052609 }
26062610 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var133))
26072611 if templ_7745c5c3_Err != nil {
@@ -2614,7 +2618,7 @@ func compareRail(p ComparePage) templ.Component {
26142618 var templ_7745c5c3_Var134 string
26152619 templ_7745c5c3_Var134, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
26162620 if templ_7745c5c3_Err != nil {
2617- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 32}
2621+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 480, Col: 32}
26182622 }
26192623 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var134))
26202624 if templ_7745c5c3_Err != nil {
@@ -2627,7 +2631,7 @@ func compareRail(p ComparePage) templ.Component {
26272631 var templ_7745c5c3_Var135 string
26282632 templ_7745c5c3_Var135, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
26292633 if templ_7745c5c3_Err != nil {
2630- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 486, Col: 16}
2634+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 482, Col: 16}
26312635 }
26322636 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var135))
26332637 if templ_7745c5c3_Err != nil {
@@ -2641,7 +2645,7 @@ func compareRail(p ComparePage) templ.Component {
26412645 var templ_7745c5c3_Var136 string
26422646 templ_7745c5c3_Var136, templ_7745c5c3_Err = templ.JoinStringErrs(" · ")
26432647 if templ_7745c5c3_Err != nil {
2644- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 15}
2648+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 15}
26452649 }
26462650 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var136))
26472651 if templ_7745c5c3_Err != nil {
@@ -2654,7 +2658,7 @@ func compareRail(p ComparePage) templ.Component {
26542658 var templ_7745c5c3_Var137 string
26552659 templ_7745c5c3_Var137, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
26562660 if templ_7745c5c3_Err != nil {
2657- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 42}
2661+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 42}
26582662 }
26592663 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var137)
26602664 if templ_7745c5c3_Err != nil {
@@ -2667,7 +2671,7 @@ func compareRail(p ComparePage) templ.Component {
26672671 var templ_7745c5c3_Var138 string
26682672 templ_7745c5c3_Var138, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
26692673 if templ_7745c5c3_Err != nil {
2670- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 488, Col: 53}
2674+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 484, Col: 53}
26712675 }
26722676 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var138))
26732677 if templ_7745c5c3_Err != nil {
@@ -2695,7 +2699,7 @@ func compareRail(p ComparePage) templ.Component {
26952699 var templ_7745c5c3_Var139 string
26962700 templ_7745c5c3_Var139, templ_7745c5c3_Err = templ.JoinStringErrs(p.Target)
26972701 if templ_7745c5c3_Err != nil {
2698- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 495, Col: 47}
2702+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 491, Col: 47}
26992703 }
27002704 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var139))
27012705 if templ_7745c5c3_Err != nil {
@@ -2718,7 +2722,7 @@ func compareRail(p ComparePage) templ.Component {
27182722 var templ_7745c5c3_Var140 string
27192723 templ_7745c5c3_Var140, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(len(p.Files)))
27202724 if templ_7745c5c3_Err != nil {
2721- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 500, Col: 66}
2725+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/mr.templ`, Line: 496, Col: 66}
27222726 }
27232727 _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var140))
27242728 if templ_7745c5c3_Err != nil {
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 +33 -29 generated file: collapsed
A internal/adapters/web/passkeys_actions_test.go +89 -0
@@ -0,0 +1,89 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+
14+ "bnema.dev/ermite/internal/domain"
15+ "bnema.dev/ermite/internal/ports/mocks"
16+)
17+
18+// Passkeys add an anonymous way to start a session, and the Actions pages add
19+// POSTs that cancel and re-run runs. The two must not meet: the only POSTs an
20+// anonymous browser may make are the two passkey login endpoints (and a login
21+// link), so the run actions still need the Origin of the public URL and a
22+// session, whether that session came from a link or from a passkey.
23+func TestPasskeysDoNotOpenTheActionsPosts(t *testing.T) {
24+ t.Parallel()
25+ repo := repoName(t, "proj")
26+ runs := mocks.NewRunService(t) // a call the gate should have stopped fails the test
27+ sessions := newSessions(t, "tok")
28+ srv := newTestServer(t, Config{
29+ Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs,
30+ PublicURL: forgeOrigin, Sessions: sessions, Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{},
31+ Now: func() time.Time { return fixedNow },
32+ })
33+ f := &passkeyFixture{srv: srv}
34+ actions := []string{
35+ "/proj/actions/runs/1/cancel", "/proj/actions/runs/1/rerun", "/proj/actions/runs/1/rerun-failed",
36+ }
37+ do := func(target, origin, cookie string) *httptest.ResponseRecorder {
38+ return f.do(t, req{method: "POST", target: target, origin: origin, cookie: cookie, contentType: "application/x-www-form-urlencoded"})
39+ }
40+
41+ for _, target := range actions {
42+ // Anonymous, with or without the right Origin, and with a cookie the
43+ // sessions do not know: forbidden, before any use case runs.
44+ for _, tc := range []struct{ origin, cookie string }{
45+ {forgeOrigin, ""}, {"", ""}, {forgeOrigin, "stale"},
46+ } {
47+ rec := do(target, tc.origin, tc.cookie)
48+ assert.Equal(t, http.StatusForbidden, rec.Code, "%s origin=%q cookie=%q", target, tc.origin, tc.cookie)
49+ }
50+ // A signed-in browser from another origin: forbidden as well.
51+ for _, origin := range []string{"", "null", "https://evil.example"} {
52+ assert.Equal(t, http.StatusForbidden, do(target, origin, "tok").Code, "%s origin=%q", target, origin)
53+ }
54+ }
55+
56+ // The same paths are not passkey endpoints: lookalikes of the anonymous
57+ // ones do not pass either.
58+ for _, target := range []string{"/proj/actions/runs/1/cancel/-/passkeys/login/begin", "/-/passkeys/login/begin/../../proj/actions/runs/1/cancel"} {
59+ assert.NotEqual(t, http.StatusSeeOther, do(target, forgeOrigin, "").Code, target)
60+ }
61+
62+ // A browser signed in (by a link or by a passkey, the cookie is the same)
63+ // reaches the use case as the owner, once per action.
64+ owner := mock.MatchedBy(func(p domain.Principal) bool { return p.IsOwner() })
65+ runs.EXPECT().CancelRun(mock.Anything, owner, repo, int64(1)).Return(domain.CIRun{}, nil).Once()
66+ runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), false).Return(domain.CIRun{}, nil).Once()
67+ runs.EXPECT().RerunRun(mock.Anything, owner, repo, int64(1), true).Return(domain.CIRun{}, nil).Once()
68+ for _, target := range actions {
69+ rec := do(target, forgeOrigin, "tok")
70+ assert.Equal(t, http.StatusSeeOther, rec.Code, target)
71+ assert.Equal(t, "/proj/actions/runs/1", rec.Header().Get("Location"), target)
72+ }
73+}
74+
75+// The refusal of the use case still shows as the page of a forbidden action
76+// for a signed-in browser.
77+func TestPasskeySessionRefusedByTheRunUseCase(t *testing.T) {
78+ t.Parallel()
79+ repo := repoName(t, "proj")
80+ runs := mocks.NewRunService(t)
81+ runs.EXPECT().CancelRun(mock.Anything, mock.Anything, repo, int64(1)).Return(domain.CIRun{}, domain.ErrForbidden).Once()
82+ srv := newTestServer(t, Config{
83+ Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs,
84+ PublicURL: forgeOrigin, Sessions: newSessions(t, "tok"), Passkeys: mocks.NewWebPasskeyService(t), Limiter: &blockingLimiter{},
85+ })
86+ f := &passkeyFixture{srv: srv}
87+ rec := f.do(t, req{method: "POST", target: "/proj/actions/runs/1/cancel", origin: forgeOrigin, cookie: "tok", contentType: "application/x-www-form-urlencoded"})
88+ assert.Equal(t, http.StatusForbidden, rec.Code)
89+}
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
@@ -23,6 +23,7 @@ templ Shell(l Layout, rail templ.Component) {
2323 <script src={ l.Assets.App } defer></script>
2424 <script src={ l.Assets.Files } defer></script>
2525 <script src={ l.Assets.Passkey } defer></script>
26+ <script src={ l.Assets.Log } defer></script>
2627 </head>
2728 <body
2829 data-ref={ l.RefPath }
M internal/adapters/web/shell_templ.go +158 -145 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
@@ -138,6 +138,9 @@ type CommitRow struct {
138138 Branches []string
139139 Tags []string
140140 Cursor bool
141+ // Checks is the combined state of the CI checks of the commit; empty when
142+ // it has none.
143+ Checks ChecksSummary
141144 }
142145
143146 // SummaryPage is the repository summary.
@@ -165,6 +168,8 @@ type LatestTag struct {
165168 type RefRow struct {
166169 Name, Href, Short, CHref, Subject, When, WhenAt string
167170 Default, Cursor bool
171+ // Checks is the combined state of the CI checks of the commit.
172+ Checks ChecksSummary
168173 }
169174
170175 // 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.
@@ -169,6 +173,17 @@ func (s *Server) routes() *http.ServeMux {
169173 mux.HandleFunc("POST /{repo}/merge-requests/{n}/close", s.mrAction(s.mrClose))
170174 mux.HandleFunc("POST /{repo}/merge-requests/{n}/read", s.mrAction(s.mrRead))
171175 }
176+ if s.cfg.Runs != nil {
177+ mux.HandleFunc("GET /{repo}/actions", s.page(s.actions))
178+ mux.HandleFunc("GET /{repo}/actions/{$}", s.page(s.actions))
179+ mux.HandleFunc("GET /{repo}/actions/runs/{id}", s.page(s.run))
180+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}", s.page(s.job))
181+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/raw", s.rawLog)
182+ mux.HandleFunc("GET /{repo}/actions/runs/{id}/job/{job}/stream", s.logStream)
183+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/cancel", s.ciAction(s.runCancel))
184+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun", s.ciAction(s.runRerun(false)))
185+ mux.HandleFunc("POST /{repo}/actions/runs/{id}/rerun-failed", s.ciAction(s.runRerun(true)))
186+ }
172187 mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound))
173188 return mux
174189 }
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", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, resp.Msg.GetFeatures(), who)
200200 assert.NotEmpty(t, resp.Msg.GetVersion())
201201 }
202202 })
A internal/app/ci_branch_token_test.go +140 -0
@@ -0,0 +1,140 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "testing"
7+
8+ "connectrpc.com/connect"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+
12+ pb "bnema.dev/ermite-api/gen/ermite"
13+ "bnema.dev/ermite/internal/domain"
14+)
15+
16+const everyBranchWorkflow = `name: all
17+on:
18+ push:
19+ branches: ['**']
20+ tags: ['v*']
21+jobs:
22+ j:
23+ runs-on: ubuntu-latest
24+ steps:
25+ - run: echo hi
26+`
27+
28+// A token limited to branch prefixes (ADR 0027) and CI. Reading runs, jobs and
29+// logs is not a write and stays open to whoever can read the repository.
30+// Cancelling and re-running a run is a write on the ref of the run (it stops or
31+// repeats the workflow of that ref), so it follows the prefixes: allowed on a
32+// branch under one, refused on any other branch and on a tag. Runners serve
33+// every repository and branch, so a limited token never manages them, even with
34+// runner:register.
35+func TestCIBranchLimitedToken(t *testing.T) {
36+ c := newCIHarness(t, nil)
37+ runner := c.registerRunner("alpha", "ubuntu-latest")
38+ wf := map[string]string{".github/workflows/all.yml": everyBranchWorkflow}
39+
40+ c.push("main", wf, "")
41+ c.waitFor("the run of main", func() bool { return len(c.runs()) == 1 })
42+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of main")
43+ c.push("agent/claude/x", nil, "")
44+ c.waitFor("the run of the agent branch", func() bool { return len(c.runs()) == 2 })
45+ runner.runJob(runner.await(), pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "log of the agent branch")
46+ c.mustGit(c.wt, "tag", "v1")
47+ c.mustGit(c.wt, "push", "-q", c.urlAuth(adminToken, "/proj.git"), "v1")
48+ c.waitFor("the run of the tag", func() bool { return len(c.runs()) == 3 })
49+ c.waitFor("main is done", func() bool { return c.runs()[2].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
50+
51+ var mainRun, agentRun, tagRun *pb.Run
52+ for _, r := range c.runs() {
53+ switch r.GetRef() {
54+ case "refs/heads/main":
55+ mainRun = r
56+ case "refs/heads/agent/claude/x":
57+ agentRun = r
58+ case "refs/tags/v1":
59+ tagRun = r
60+ }
61+ }
62+ require.NotNil(t, mainRun)
63+ require.NotNil(t, agentRun)
64+ require.NotNil(t, tagRun)
65+
66+ all := scopes(domain.ScopeRepoRead, domain.ScopeCIWrite, domain.ScopeRunnerRegister)
67+ secret, _ := c.branchToken(all, []string{"agent/claude/"}, "proj")
68+ limited := c.api(secret)
69+ // The same token without the limit, to prove the refusals come from it.
70+ unlimited := c.api(c.token(all, "proj"))
71+ cancel := func(api *clients, r *pb.Run) error {
72+ _, err := api.run.CancelRun(c.ctx, connect.NewRequest(&pb.CancelRunRequest{Repo: "proj", RunId: r.GetId()}))
73+ return err
74+ }
75+ rerun := func(api *clients, r *pb.Run) error {
76+ _, err := api.run.RerunRun(c.ctx, connect.NewRequest(&pb.RerunRunRequest{Repo: "proj", RunId: r.GetId()}))
77+ return err
78+ }
79+
80+ t.Run("reads", func(t *testing.T) {
81+ list, err := limited.run.ListRuns(c.ctx, connect.NewRequest(&pb.ListRunsRequest{Repo: "proj"}))
82+ require.NoError(t, err)
83+ assert.Len(t, list.Msg.GetRuns(), 3, "every run is readable, not only those of its prefixes")
84+ got, err := limited.run.GetRun(c.ctx, connect.NewRequest(&pb.GetRunRequest{Repo: "proj", RunId: mainRun.GetId()}))
85+ require.NoError(t, err)
86+ require.NotEmpty(t, got.Msg.GetJobs())
87+ log, err := limited.run.GetJobLog(c.ctx, connect.NewRequest(&pb.GetJobLogRequest{Repo: "proj", JobId: got.Msg.GetJobs()[0].GetId()}))
88+ require.NoError(t, err)
89+ var text string
90+ for log.Receive() {
91+ for _, l := range log.Msg().GetLines() {
92+ text += l.GetText()
93+ }
94+ }
95+ require.NoError(t, log.Err())
96+ assert.Contains(t, text, "log of main")
97+ _, err = limited.run.ListChecks(c.ctx, connect.NewRequest(&pb.ListChecksRequest{Repo: "proj", Sha: mainRun.GetSha()}))
98+ require.NoError(t, err)
99+ })
100+
101+ t.Run("writes outside the prefixes are refused", func(t *testing.T) {
102+ for name, r := range map[string]*pb.Run{"main": mainRun, "a tag": tagRun} {
103+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(cancel(limited, r)), "cancel "+name)
104+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(rerun(limited, r)), "rerun "+name)
105+ }
106+ // Still there: the refused rerun started no new attempt.
107+ assert.EqualValues(t, 1, c.getRun(mainRun.GetId()).GetRun().GetAttempt())
108+ // Without the limit the same token is allowed (rerun) or reaches the
109+ // state rule of the run (cancel of a completed run).
110+ assert.Equal(t, connect.CodeFailedPrecondition, ciErrCode(cancel(unlimited, mainRun)))
111+ require.NoError(t, rerun(unlimited, mainRun))
112+ })
113+
114+ t.Run("writes under the prefixes are allowed", func(t *testing.T) {
115+ require.NoError(t, rerun(limited, agentRun))
116+ assert.EqualValues(t, 2, c.getRun(agentRun.GetId()).GetRun().GetAttempt())
117+ require.NoError(t, cancel(limited, agentRun))
118+ })
119+
120+ t.Run("runners are out of reach", func(t *testing.T) {
121+ // Not restricted to a repository, so only the branch limit can refuse it.
122+ secret, _ := c.branchToken(scopes(domain.ScopeRunnerRegister), []string{"agent/claude/"})
123+ limited := c.api(secret)
124+ _, err := limited.runner.RegisterRunner(c.ctx, connect.NewRequest(&pb.RegisterRunnerRequest{
125+ Name: "evil", Version: "v1", Isolation: pb.RunnerIsolation_RUNNER_ISOLATION_VM, Arch: "x64", Os: "linux", Replace: true,
126+ }))
127+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "register")
128+ _, err = limited.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
129+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "list")
130+ _, err = limited.run.RemoveRunner(c.ctx, connect.NewRequest(&pb.RemoveRunnerRequest{RunnerId: runner.id}))
131+ assert.Equal(t, connect.CodePermissionDenied, ciErrCode(err), "remove")
132+
133+ // The same scopes without the limit manage runners (not restricted to a
134+ // repository, which the runner rules already demand).
135+ open := c.api(c.token(scopes(domain.ScopeRunnerRegister)))
136+ list, err := open.run.ListRunners(c.ctx, connect.NewRequest(&pb.ListRunnersRequest{}))
137+ require.NoError(t, err)
138+ assert.Len(t, list.Msg.GetRunners(), 1, "the runner is still registered")
139+ })
140+}
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.go +69 -10
@@ -9,6 +9,7 @@ import (
99 "io"
1010 "os"
1111 "strings"
12+ "sync"
1213 "text/tabwriter"
1314 "time"
1415
@@ -30,10 +31,28 @@ type cliEnv struct {
3031 // defaults.
3132 backoff client.Backoff
3233 sleep func(context.Context, time.Duration) error
34+
35+ // configPath returns the CLI config file path; nil means the default
36+ // (ERMITE_CONFIG, else ~/.config/ermite/config).
37+ configPath func() (string, error)
38+ // runTokenCmd runs the token.cmd command line and returns its raw standard
39+ // output. Tests replace it.
40+ runTokenCmd func(ctx context.Context, cmd string) (string, error)
41+
42+ // The config file is read at most once and token.cmd run at most once per
43+ // process, and only when a command needs them (see cli_configfile.go).
44+ fileOnce sync.Once
45+ file *cliFile
46+ filePath string
47+ fileTrust error // why token.cmd must not run from this file, if so
48+ fileErr error
49+ tokenOnce sync.Once
50+ token string
51+ tokenErr error
3352 }
3453
3554 func defaultCLIEnv() *cliEnv {
36- return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion()}
55+ return &cliEnv{getenv: os.Getenv, now: time.Now, version: buildVersion(), runTokenCmd: runTokenShell}
3756 }
3857
3958 // userAgent identifies this CLI to the server.
@@ -54,23 +73,56 @@ func (e *cliEnv) ctx(cmd *cobra.Command) context.Context {
5473 return ctx
5574 }
5675
57-// config reads and validates ERMITE_URL and ERMITE_TOKEN.
58-func (e *cliEnv) config() (client.Config, error) {
59- cfg, err := client.ConfigFromEnv(e.getenv)
76+// buildConfig builds the client configuration: ERMITE_URL and ERMITE_TOKEN
77+// win, then the config file (url, and the output of token.cmd for the token).
78+// runTokenCmd false leaves token.cmd alone (ERMITE_TOKEN is still used), for
79+// the calls that need no authentication.
80+func (e *cliEnv) buildConfig(cmd *cobra.Command, warn, runTokenCmd bool) (client.Config, error) {
81+ rawURL, err := e.resolveURL(cmd, warn)
6082 if err != nil {
61- return cfg, err
83+ return client.Config{}, err
84+ }
85+ if strings.TrimSpace(rawURL) != "" {
86+ if _, err := client.ParseURL(strings.TrimSpace(rawURL)); err != nil {
87+ return client.Config{}, fmt.Errorf("%s: %w", e.urlSource(), err)
88+ }
89+ }
90+ // A missing or invalid URL is reported before token.cmd gets to prompt.
91+ cfg, err := client.NewConfig(rawURL, "")
92+ if err != nil {
93+ return client.Config{}, err
94+ }
95+ cfg.Token = strings.TrimSpace(e.getenv(client.EnvToken))
96+ if cfg.Token == "" && runTokenCmd {
97+ if cfg.Token, err = e.tokenFromCmd(cmd, warn); err != nil {
98+ return client.Config{}, err
99+ }
62100 }
63101 cfg.UserAgent = e.userAgent()
64102 return cfg, nil
65103 }
66104
67-// client builds the API client. Commands warn when the token would travel in
68-// the clear; completion stays silent.
69-func (e *cliEnv) client(cmd *cobra.Command, warn bool) (*client.Client, error) {
70- cfg, err := e.config()
105+// client builds the API client. It warns when the token would travel in the
106+// clear.
107+func (e *cliEnv) client(cmd *cobra.Command) (*client.Client, error) {
108+ cfg, err := e.buildConfig(cmd, true, true)
71109 if err != nil {
72110 return nil, err
73111 }
112+ return newClient(cmd, cfg, true)
113+}
114+
115+// clientNoTokenCmd is client without running token.cmd (ERMITE_TOKEN is still
116+// used), for GetVersion, which is anonymous, and for completions.
117+func (e *cliEnv) clientNoTokenCmd(cmd *cobra.Command, warn bool) (*client.Client, error) {
118+ cfg, err := e.buildConfig(cmd, warn, false)
119+ if err != nil {
120+ return nil, err
121+ }
122+ return newClient(cmd, cfg, warn)
123+}
124+
125+func newClient(cmd *cobra.Command, cfg client.Config, warn bool) (*client.Client, error) {
74126 if w := cfg.InsecureTokenWarning(); w != "" && warn {
75127 warnf(cmd, "%s", w)
76128 }
@@ -128,13 +180,20 @@ func collect[T any](ctx context.Context, fetch func(token string) ([]T, string,
128180 }
129181 }
130182
183+// completionClient is the client of the shell completions: silent, and
184+// anonymous unless ERMITE_TOKEN is set, because completing must never run
185+// token.cmd (it may prompt, and a completion script cannot answer).
186+func (e *cliEnv) completionClient(cmd *cobra.Command) (*client.Client, error) {
187+ return e.clientNoTokenCmd(cmd, false)
188+}
189+
131190 // complete builds a shell completion: it opens a client (silently giving up
132191 // on any failure), lists with a 3 s timeout and keeps the entries whose
133192 // first element starts with the prefix.
134193 func complete[T any](env *cliEnv, cmd *cobra.Command, prefix string,
135194 list func(context.Context, *client.Client) ([]T, error), entry func(T) (key, text string),
136195 ) []string {
137- c, err := env.client(cmd, false)
196+ c, err := env.completionClient(cmd)
138197 if err != nil {
139198 return nil
140199 }
A internal/app/cli_config.go +214 -0
@@ -0,0 +1,214 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "errors"
7+ "fmt"
8+ "io/fs"
9+ "slices"
10+ "strings"
11+
12+ "github.com/bnema/kvconf"
13+ "github.com/spf13/cobra"
14+
15+ "bnema.dev/ermite/internal/adapters/client"
16+)
17+
18+// notSet is what `config show` prints for a key without a value.
19+const notSet = "(not set)"
20+
21+// configKeys are the keys of the CLI config file.
22+var configKeys = []string{keyURL, keyTokenCmd}
23+
24+func newConfigCmd(env *cliEnv) *cobra.Command {
25+ cmd := &cobra.Command{
26+ Use: "config",
27+ Short: "Show and edit the config file of the remote commands",
28+ Long: "The remote commands read the server address and a way to get a token from a config file,\n" +
29+ "~/.config/ermite/config (the config directory of the platform on macOS and Windows; or the file\n" +
30+ "named by ERMITE_CONFIG; `ermite config show` prints the path in use):\n\n" +
31+ " url = https://git.example.com\n" +
32+ " token.cmd = pass show ermite/token\n\n" +
33+ "token.cmd is a command line run with the system shell when a command needs the token (at most\n" +
34+ "once per run): it must print the token alone on one line. Use any secret manager (pass, gopass,\n" +
35+ "op, bw, secret-tool…). The token itself never goes in the file. ERMITE_URL and ERMITE_TOKEN\n" +
36+ "take precedence over the file. token.cmd belongs to the server of the file: it is not run when\n" +
37+ "ERMITE_URL names another server than the url of the file (the call is then anonymous, with a\n" +
38+ "notice: set ERMITE_TOKEN). `ermite version`, `ermite config` and shell completion never run it\n" +
39+ "(completion stays anonymous unless ERMITE_TOKEN is set).\n\n" +
40+ "token.cmd is also not run when the file belongs to another user, or when the file or its directory\n" +
41+ "can be written by other users (Unix); `ermite config show` and `set` warn about it. On Windows\n" +
42+ "this check does not exist: keep the file in your own profile (the default place).",
43+ }
44+ cmd.AddCommand(newConfigShowCmd(env), newConfigSetCmd(env), newConfigUnsetCmd(env))
45+ return cmd
46+}
47+
48+// jsonConfig is the stable JSON shape of `ermite config show --json`.
49+type jsonConfig struct {
50+ File string `json:"file"`
51+ URL *string `json:"url"`
52+ TokenCmd *string `json:"token_cmd"`
53+}
54+
55+func newConfigShowCmd(env *cliEnv) *cobra.Command {
56+ cmd := &cobra.Command{
57+ Use: "show",
58+ Short: "Print the config file path and its settings",
59+ Long: "Print the path of the config file, then the url and token.cmd stored in it ((not set) when\n" +
60+ "absent). It never runs token.cmd, and warns when token.cmd would be refused (file permissions).\n\n" +
61+ "JSON fields (--json): file; url and token_cmd, null when not set.",
62+ Args: cobra.NoArgs,
63+ }
64+ asJSON := addJSONFlag(cmd)
65+ cmd.RunE = func(cmd *cobra.Command, _ []string) error {
66+ f, path, err := env.readFile(cmd, true)
67+ if path == "" {
68+ p, perr := env.path()
69+ if perr != nil {
70+ return perr
71+ }
72+ path = p
73+ }
74+ if err != nil {
75+ // Say which file is unreadable.
76+ _ = fields(cmd.OutOrStdout(), [][2]string{{"file", path}})
77+ return err
78+ }
79+ if strings.TrimSpace(env.getenv(client.EnvURL)) != "" && f.URL != "" {
80+ noticef(cmd, "%s is set and takes precedence over url", client.EnvURL)
81+ }
82+ if strings.TrimSpace(env.getenv(client.EnvToken)) != "" && f.TokenCmd != "" {
83+ noticef(cmd, "%s is set: token.cmd is not used", client.EnvToken)
84+ }
85+ if f.TokenCmd != "" && env.fileTrust != nil {
86+ warnf(cmd, "%s", env.fileTrust)
87+ }
88+ if *asJSON {
89+ return writeJSON(cmd.OutOrStdout(), jsonConfig{File: path, URL: nilIfBlank(f.URL), TokenCmd: nilIfBlank(f.TokenCmd)})
90+ }
91+ return fields(cmd.OutOrStdout(), [][2]string{
92+ {"file", path},
93+ {keyURL, orNotSet(f.URL)},
94+ {keyTokenCmd, orNotSet(f.TokenCmd)},
95+ })
96+ }
97+ return cmd
98+}
99+
100+func nilIfBlank(s string) *string {
101+ if strings.TrimSpace(s) == "" {
102+ return nil
103+ }
104+ return &s
105+}
106+
107+func orNotSet(s string) string {
108+ if strings.TrimSpace(s) == "" {
109+ return notSet
110+ }
111+ return s
112+}
113+
114+func newConfigSetCmd(env *cliEnv) *cobra.Command {
115+ return &cobra.Command{
116+ Use: "set <url|token.cmd> <value>",
117+ Short: "Set the server address or the token command in the config file",
118+ Long: "Write a setting to the config file, keeping its comments, and create the file (mode 0600) if needed.\n" +
119+ "url is checked: an http(s) address without credentials. token.cmd is a command line that prints the\n" +
120+ "token, for example `pass show ermite/token`; it is stored as is and not run.",
121+ Args: cobra.ExactArgs(2),
122+ ValidArgsFunction: completeConfigKeys,
123+ RunE: func(cmd *cobra.Command, args []string) error {
124+ key, value := args[0], strings.TrimSpace(args[1])
125+ if err := checkConfigKey(key); err != nil {
126+ return err
127+ }
128+ switch key {
129+ case keyURL:
130+ u, err := client.ParseURL(value)
131+ if err != nil {
132+ return fmt.Errorf("invalid url: %w", err)
133+ }
134+ value = u.String()
135+ case keyTokenCmd:
136+ if value == "" {
137+ return errors.New("token.cmd is empty: use `ermite config unset token.cmd` to remove it")
138+ }
139+ if strings.ContainsAny(value, "\r\n") {
140+ return errors.New("token.cmd must be a single line")
141+ }
142+ }
143+ path, err := env.path()
144+ if err != nil {
145+ return err
146+ }
147+ if err := kvconf.SetFile(path, map[string]*string{key: &value}, 0o600); err != nil {
148+ return fmt.Errorf("writing %s: %w", path, err)
149+ }
150+ noticef(cmd, "%s set in %s", key, path)
151+ // Say now, not at the first command, that token.cmd would be refused.
152+ if f, _, trust, err := loadConfigFile(path); err == nil && trust != nil && f.TokenCmd != "" {
153+ warnf(cmd, "%s", trust)
154+ }
155+ return nil
156+ },
157+ }
158+}
159+
160+func newConfigUnsetCmd(env *cliEnv) *cobra.Command {
161+ return &cobra.Command{
162+ Use: "unset <url|token.cmd>",
163+ Short: "Remove a setting from the config file",
164+ Args: cobra.ExactArgs(1),
165+ ValidArgsFunction: completeConfigKeys,
166+ RunE: func(cmd *cobra.Command, args []string) error {
167+ key := args[0]
168+ if err := checkConfigKey(key); err != nil {
169+ return err
170+ }
171+ path, err := env.path()
172+ if err != nil {
173+ return err
174+ }
175+ data, err := kvconf.ReadFile(path, kvconf.Options{})
176+ if errors.Is(err, fs.ErrNotExist) {
177+ noticef(cmd, "%s is not set: %s does not exist", key, path)
178+ return nil
179+ }
180+ if err != nil {
181+ return fmt.Errorf("reading %s: %w", path, err)
182+ }
183+ entries, _, err := kvconf.ParseBytes(data, kvconf.Options{})
184+ if err != nil {
185+ return fmt.Errorf("reading %s: %w", path, err)
186+ }
187+ if !slices.ContainsFunc(entries, func(e kvconf.Entry) bool { return e.Key == key }) {
188+ noticef(cmd, "%s is not set", key)
189+ return nil
190+ }
191+ if err := kvconf.SetFile(path, map[string]*string{key: nil}, 0o600); err != nil {
192+ return fmt.Errorf("writing %s: %w", path, err)
193+ }
194+ noticef(cmd, "%s removed from %s", key, path)
195+ return nil
196+ },
197+ }
198+}
199+
200+func checkConfigKey(key string) error {
201+ for _, k := range configKeys {
202+ if key == k {
203+ return nil
204+ }
205+ }
206+ return fmt.Errorf("unknown key %q: the keys are %s", key, strings.Join(configKeys, " and "))
207+}
208+
209+func completeConfigKeys(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
210+ if len(args) > 0 {
211+ return nil, cobra.ShellCompDirectiveNoFileComp
212+ }
213+ return configKeys, cobra.ShellCompDirectiveNoFileComp
214+}
A internal/app/cli_config_ci_test.go +114 -0
@@ -0,0 +1,114 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "testing"
8+
9+ "connectrpc.com/connect"
10+ "github.com/stretchr/testify/assert"
11+
12+ "bnema.dev/ermite-api/gen/ermite"
13+)
14+
15+// The RunService calls of the `run` and `runner` commands answer with empty
16+// results: these tests look at which address and token reach the server, not
17+// at the output.
18+func (f *fakeAPI) ListRuns(_ context.Context, r *connect.Request[ermite.ListRunsRequest]) (*connect.Response[ermite.ListRunsResponse], error) {
19+ return respond(f, "ListRuns", r, func(*ermite.ListRunsRequest) (*ermite.ListRunsResponse, error) {
20+ return &ermite.ListRunsResponse{}, nil
21+ })
22+}
23+
24+func (f *fakeAPI) CancelRun(_ context.Context, r *connect.Request[ermite.CancelRunRequest]) (*connect.Response[ermite.CancelRunResponse], error) {
25+ return respond(f, "CancelRun", r, func(*ermite.CancelRunRequest) (*ermite.CancelRunResponse, error) {
26+ return &ermite.CancelRunResponse{}, nil
27+ })
28+}
29+
30+func (f *fakeAPI) RerunRun(_ context.Context, r *connect.Request[ermite.RerunRunRequest]) (*connect.Response[ermite.RerunRunResponse], error) {
31+ return respond(f, "RerunRun", r, func(*ermite.RerunRunRequest) (*ermite.RerunRunResponse, error) {
32+ return &ermite.RerunRunResponse{}, nil
33+ })
34+}
35+
36+func (f *fakeAPI) ListRunners(_ context.Context, r *connect.Request[ermite.ListRunnersRequest]) (*connect.Response[ermite.ListRunnersResponse], error) {
37+ return respond(f, "ListRunners", r, func(*ermite.ListRunnersRequest) (*ermite.ListRunnersResponse, error) {
38+ return &ermite.ListRunnersResponse{Runners: []*ermite.Runner{{Id: "r1", Name: "alpha"}}}, nil
39+ })
40+}
41+
42+func (f *fakeAPI) RemoveRunner(_ context.Context, r *connect.Request[ermite.RemoveRunnerRequest]) (*connect.Response[ermite.RemoveRunnerResponse], error) {
43+ return respond(f, "RemoveRunner", r, func(*ermite.RemoveRunnerRequest) (*ermite.RemoveRunnerResponse, error) {
44+ return &ermite.RemoveRunnerResponse{}, nil
45+ })
46+}
47+
48+// The `run` and `runner` commands read the server address and the token like
49+// every other remote command: the config file (url, token.cmd), with
50+// ERMITE_URL / ERMITE_TOKEN winning, and token.cmd run once and only for the
51+// file's own url.
52+func TestCIcommandsUseTheConfigFile(t *testing.T) {
53+ commands := map[string][]string{
54+ "run list": {"run", "list", "proj"},
55+ "run cancel": {"run", "cancel", "proj", "1"},
56+ "run rerun": {"run", "rerun", "proj", "1"},
57+ "runner list": {"runner", "list"},
58+ "runner remove": {"runner", "remove", "r1"},
59+ }
60+ for name, args := range commands {
61+ t.Run(name, func(t *testing.T) {
62+ f := &fakeAPI{}
63+ url := f.serve(t)
64+
65+ // url and token.cmd from the file
66+ env := testEnv(t, nil)
67+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
68+ ran := tokenCmdStub(env, "ermite_cmd\n", nil)
69+ runCLIWith(t, env, "", args...).ok(t)
70+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
71+ assert.Len(t, *ran, 1)
72+
73+ // ERMITE_TOKEN wins and token.cmd stays idle
74+ env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
75+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
76+ ran = tokenCmdStub(env, "ermite_cmd", nil)
77+ runCLIWith(t, env, "", args...).ok(t)
78+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
79+ assert.Empty(t, *ran)
80+
81+ // another ERMITE_URL never receives the file's token
82+ other := &fakeAPI{}
83+ env = testEnv(t, map[string]string{"ERMITE_URL": other.serve(t)})
84+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
85+ ran = tokenCmdStub(env, "ermite_cmd", nil)
86+ runCLIWith(t, env, "", args...).ok(t)
87+ assert.Empty(t, lastAuth(other))
88+ assert.Empty(t, *ran)
89+ })
90+ }
91+}
92+
93+// Completing a repository name for `run` or a runner id for `runner remove`
94+// must not run token.cmd: it may prompt, and a completion script cannot answer.
95+func TestCICompletionNeverRunsTokenCmd(t *testing.T) {
96+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
97+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
98+ }}
99+ url := f.serve(t)
100+ for name, args := range map[string][]string{
101+ "run list": {"__complete", "run", "list", ""},
102+ "run cancel": {"__complete", "run", "cancel", ""},
103+ "runner remove": {"__complete", "runner", "remove", ""},
104+ } {
105+ t.Run(name, func(t *testing.T) {
106+ env := testEnv(t, nil)
107+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
108+ ran := tokenCmdStub(env, "ermite_cmd", nil)
109+ runCLIWith(t, env, "", args...).ok(t)
110+ assert.Empty(t, *ran)
111+ assert.Empty(t, lastAuth(f))
112+ })
113+ }
114+}
A internal/app/cli_config_test.go +235 -0
@@ -0,0 +1,235 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "os"
9+ "path/filepath"
10+ "runtime"
11+ "strconv"
12+ "strings"
13+ "testing"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+)
18+
19+func TestConfigShowWithoutFile(t *testing.T) {
20+ env := testEnv(t, nil)
21+ path, _ := env.configPath()
22+ res := runCLIWith(t, env, "", "config", "show").ok(t)
23+ assert.Equal(t, "file "+path+"\nurl (not set)\ntoken.cmd (not set)\n", res.stdout)
24+ _, err := os.Stat(path)
25+ assert.ErrorIs(t, err, os.ErrNotExist, "show does not create the file")
26+}
27+
28+func TestConfigSetUnsetShowRoundTrip(t *testing.T) {
29+ env := testEnv(t, nil)
30+ path, _ := env.configPath()
31+ writeConfigFile(t, env, "# my forge\nurl = https://old.example.com\n\n# secrets stay in pass\n", 0o600)
32+
33+ runCLIWith(t, env, "", "config", "set", "url", "https://git.example.com/").ok(t)
34+ runCLIWith(t, env, "", "config", "set", "token.cmd", "pass show ermite/token").ok(t)
35+ // a fresh process sees the new content
36+ res := runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
37+ assert.Contains(t, res.stdout, "url https://git.example.com\n")
38+ assert.Contains(t, res.stdout, "token.cmd pass show ermite/token\n")
39+
40+ data, err := os.ReadFile(path)
41+ require.NoError(t, err)
42+ assert.Contains(t, string(data), "# my forge\n")
43+ assert.Contains(t, string(data), "# secrets stay in pass\n")
44+
45+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "token.cmd").ok(t)
46+ runCLIWith(t, testEnvAt(t, path), "", "config", "unset", "url").ok(t)
47+ data, err = os.ReadFile(path)
48+ require.NoError(t, err)
49+ assert.NotContains(t, string(data), "token.cmd")
50+ assert.NotContains(t, string(data), "url")
51+ assert.Contains(t, string(data), "# my forge\n", "comments survive")
52+
53+ res = runCLIWith(t, testEnvAt(t, path), "", "config", "show").ok(t)
54+ assert.Contains(t, res.stdout, "url (not set)\n")
55+ assert.Contains(t, res.stdout, "token.cmd (not set)\n")
56+}
57+
58+// testEnvAt is testEnv with the config file at path: a new process.
59+func testEnvAt(t *testing.T, path string) *cliEnv {
60+ t.Helper()
61+ env := testEnv(t, nil)
62+ env.configPath = func() (string, error) { return path, nil }
63+ return env
64+}
65+
66+func TestConfigSetCreatesPrivateFile(t *testing.T) {
67+ env := testEnv(t, nil)
68+ path, _ := env.configPath()
69+ res := runCLIWith(t, env, "", "config", "set", "token.cmd", "gopass show -o ermite").ok(t)
70+ assert.Contains(t, res.stderr, path)
71+ info, err := os.Stat(path)
72+ require.NoError(t, err)
73+ if runtime.GOOS != "windows" {
74+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
75+ }
76+ data, _ := os.ReadFile(path)
77+ assert.Equal(t, "token.cmd = gopass show -o ermite\n", string(data))
78+}
79+
80+func TestConfigSetRefusesBadInput(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ args []string
84+ want string
85+ }{
86+ {"unknown key", []string{"set", "token", "x"}, `unknown key "token": the keys are url and token.cmd`},
87+ {"unknown key unset", []string{"unset", "colour"}, `unknown key "colour": the keys are url and token.cmd`},
88+ {"no scheme", []string{"set", "url", "git.example.com"}, "invalid url"},
89+ {"credentials", []string{"set", "url", "https://u:s3cret@git.example.com"}, "must not contain credentials"},
90+ {"ftp", []string{"set", "url", "ftp://git.example.com"}, "invalid url"},
91+ {"empty token.cmd", []string{"set", "token.cmd", " "}, "token.cmd is empty"},
92+ {"multiline token.cmd", []string{"set", "token.cmd", "a\nb"}, "single line"},
93+ {"missing value", []string{"set", "url"}, "accepts 2 arg"},
94+ }
95+ for _, tt := range tests {
96+ t.Run(tt.name, func(t *testing.T) {
97+ env := testEnv(t, nil)
98+ path, _ := env.configPath()
99+ res := runCLIWith(t, env, "", append([]string{"config"}, tt.args...)...)
100+ require.Error(t, res.err)
101+ assert.Contains(t, res.err.Error(), tt.want)
102+ assert.NotContains(t, res.err.Error(), "s3cret")
103+ _, err := os.Stat(path)
104+ assert.ErrorIs(t, err, os.ErrNotExist, "nothing is written")
105+ })
106+ }
107+}
108+
109+func TestConfigUnsetWithoutFile(t *testing.T) {
110+ env := testEnv(t, nil)
111+ path, _ := env.configPath()
112+ res := runCLIWith(t, env, "", "config", "unset", "url").ok(t)
113+ assert.Contains(t, res.stderr, "does not exist")
114+ _, err := os.Stat(path)
115+ assert.ErrorIs(t, err, os.ErrNotExist)
116+}
117+
118+func TestConfigShowNeverRunsTokenCmdAndNotesPrecedence(t *testing.T) {
119+ env := testEnv(t, map[string]string{"ERMITE_URL": "https://env.example.com", "ERMITE_TOKEN": "ermite_x"})
120+ writeConfigFile(t, env, "url = https://file.example.com\ntoken.cmd = get-token\n", 0o666)
121+ res := runCLIWith(t, env, "", "config", "show").ok(t) // testEnv fails the test if token.cmd runs
122+ assert.Contains(t, res.stdout, "url https://file.example.com\n", "show prints what is stored")
123+ assert.Contains(t, res.stderr, "ERMITE_URL is set and takes precedence")
124+ assert.Contains(t, res.stderr, "ERMITE_TOKEN is set: token.cmd is not used")
125+ assert.False(t, strings.Contains(res.stdout+res.stderr, "ermite_x"), "the token is never printed")
126+}
127+
128+func TestHelpDocumentsTheConfigFileContract(t *testing.T) {
129+ root := runCLI(t, nil, "", "--help").ok(t).stdout
130+ cfg := runCLI(t, nil, "", "config", "--help").ok(t).stdout
131+ for name, help := range map[string]string{"root": root, "config": cfg} {
132+ assert.Contains(t, help, "ermite/config", "%s help names the file", name)
133+ assert.Contains(t, help, "ERMITE_CONFIG", "%s help names the override", name)
134+ assert.Contains(t, help, "ERMITE_URL", "%s help states the precedence", name)
135+ assert.Contains(t, help, "ERMITE_TOKEN", "%s help states the precedence", name)
136+ }
137+ assert.Contains(t, cfg, "token.cmd = ", "the config help has an example")
138+ assert.Contains(t, cfg, "Windows", "the config help states the Windows limit")
139+}
140+
141+func TestConfigShowNamesTheFileItCannotRead(t *testing.T) {
142+ env := testEnv(t, nil)
143+ path := writeConfigFile(t, env, "url = https://git.example.com\n", 0o600)
144+ require.NoError(t, os.Remove(path))
145+ require.NoError(t, os.Mkdir(path, 0o700)) // a directory is not a config file
146+ res := runCLIWith(t, env, "", "config", "show")
147+ require.Error(t, res.err)
148+ assert.Contains(t, res.stdout, path)
149+ assert.Contains(t, res.err.Error(), "config:")
150+}
151+
152+func TestConfigUnsetOfAnAbsentKey(t *testing.T) {
153+ env := testEnv(t, nil)
154+ path := writeConfigFile(t, env, "# only a comment\nurl = https://git.example.com\n", 0o600)
155+ res := runCLIWith(t, env, "", "config", "unset", "token.cmd").ok(t)
156+ assert.Equal(t, "ermite: token.cmd is not set\n", res.stderr)
157+ data, err := os.ReadFile(path)
158+ require.NoError(t, err)
159+ assert.Equal(t, "# only a comment\nurl = https://git.example.com\n", string(data), "the file is untouched")
160+}
161+
162+func TestConfigShowJSON(t *testing.T) {
163+ env := testEnv(t, nil)
164+ path, _ := env.configPath()
165+ res := runCLIWith(t, env, "", "config", "show", "--json").ok(t)
166+ assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":null,"token_cmd":null}`, res.stdout)
167+
168+ env = testEnv(t, nil)
169+ path = writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = pass show ermite/token\n", 0o600)
170+ res = runCLIWith(t, env, "", "config", "show", "--json").ok(t) // testEnv fails the test if token.cmd runs
171+ assert.JSONEq(t, `{"file":`+strconv.Quote(path)+`,"url":"https://git.example.com","token_cmd":"pass show ermite/token"}`, res.stdout)
172+}
173+
174+func TestConfigShowAndSetWarnWhenTokenCmdWouldBeRefused(t *testing.T) {
175+ if runtime.GOOS == "windows" {
176+ t.Skip("no Unix permission bits")
177+ }
178+ env := testEnv(t, nil)
179+ path := writeConfigFile(t, env, "url = https://git.example.com\ntoken.cmd = get-token\n", 0o666)
180+
181+ res := runCLIWith(t, env, "", "config", "show").ok(t)
182+ assert.Contains(t, res.stderr, "ermite: warning: config file "+path+" is writable by others: token.cmd not run")
183+
184+ // a fresh process; set keeps the mode of an existing file
185+ res = runCLIWith(t, testEnvAt(t, path), "", "config", "set", "url", "https://git2.example.com").ok(t)
186+ assert.Contains(t, res.stderr, "writable by others")
187+
188+ // no warning without a token.cmd, or with a private file
189+ env = testEnv(t, nil)
190+ writeConfigFile(t, env, "url = https://git.example.com\n", 0o666)
191+ assert.NotContains(t, runCLIWith(t, env, "", "config", "show").ok(t).stderr, "warning")
192+ res = runCLIWith(t, testEnv(t, nil), "", "config", "set", "token.cmd", "get-token").ok(t)
193+ assert.NotContains(t, res.stderr, "warning")
194+}
195+
196+func TestDefaultConfigPathUsesXDGConfigHome(t *testing.T) {
197+ xdg := t.TempDir()
198+ t.Setenv("XDG_CONFIG_HOME", xdg)
199+ t.Setenv("ERMITE_CONFIG", "")
200+ f := listFake()
201+
202+ env := defaultCLIEnv() // the real path resolution, the real environment
203+ res := runCLIWith(t, env, "", "config", "set", "url", f.serve(t)).ok(t)
204+ want := filepath.Join(xdg, "ermite", "config")
205+ assert.Contains(t, res.stderr, want)
206+ info, err := os.Stat(want)
207+ require.NoError(t, err)
208+ if runtime.GOOS != "windows" {
209+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
210+ }
211+
212+ env = defaultCLIEnv()
213+ env.runTokenCmd = func(context.Context, string) (string, error) { return "", errors.New("must not run") }
214+ t.Setenv("ERMITE_URL", "")
215+ t.Setenv("ERMITE_TOKEN", "")
216+ runCLIWith(t, env, "", "repo", "list").ok(t)
217+ assert.Equal(t, 1, callCount(f, "ListRepos"))
218+
219+ // ERMITE_CONFIG wins over the default
220+ other := filepath.Join(t.TempDir(), "other")
221+ t.Setenv("ERMITE_CONFIG", other)
222+ res = runCLIWith(t, defaultCLIEnv(), "", "config", "show").ok(t)
223+ assert.Contains(t, res.stdout, other)
224+}
225+
226+func callCount(f *fakeAPI, name string) int {
227+ calls, _ := f.snapshot()
228+ n := 0
229+ for _, c := range calls {
230+ if c == name {
231+ n++
232+ }
233+ }
234+ return n
235+}
A internal/app/cli_configfile.go +272 -0
@@ -0,0 +1,272 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "context"
8+ "errors"
9+ "fmt"
10+ "io"
11+ "io/fs"
12+ "os"
13+ "os/exec"
14+ "os/signal"
15+ "strings"
16+ "syscall"
17+ "time"
18+
19+ "github.com/bnema/kvconf"
20+ "github.com/spf13/cobra"
21+ "golang.org/x/term"
22+
23+ "bnema.dev/ermite/internal/adapters/client"
24+)
25+
26+const (
27+ // envConfig selects the CLI config file instead of ~/.config/ermite/config.
28+ envConfig = "ERMITE_CONFIG"
29+ // Keys of the CLI config file.
30+ keyURL = "url"
31+ keyTokenCmd = "token.cmd"
32+
33+ // tokenCmdTimeout bounds one run of token.cmd, prompts included.
34+ tokenCmdTimeout = 60 * time.Second
35+ // tokenCmdMaxOutput bounds what is read from its standard output.
36+ tokenCmdMaxOutput = 4 << 10
37+)
38+
39+// cliFile is the CLI config file. It holds no secret: the token is fetched
40+// from a secret manager by the token.cmd command line.
41+type cliFile struct {
42+ URL string `kv:"url"`
43+ TokenCmd string `kv:"token.cmd"`
44+}
45+
46+// path is the config file: $ERMITE_CONFIG, else <config dir>/ermite/config.
47+func (e *cliEnv) path() (string, error) {
48+ if e.configPath != nil {
49+ return e.configPath()
50+ }
51+ if p := strings.TrimSpace(e.getenv(envConfig)); p != "" {
52+ return p, nil
53+ }
54+ p, err := kvconf.DefaultPath("ermite", "config")
55+ if err != nil {
56+ return "", fmt.Errorf("no home directory to find the config file: set %s", envConfig)
57+ }
58+ return p, nil
59+}
60+
61+// readFile reads the config file, once per process. A missing file, or no
62+// place to look for it, gives an empty one. Problems in the file are warnings
63+// on stderr, printed once, and only when warn is set. The path is returned
64+// even when reading fails.
65+//
66+// The file is opened once: what is parsed and what checkPrivate vets (see
67+// fetchToken) are the same file, so token.cmd cannot come from a file that was
68+// swapped after the check.
69+func (e *cliEnv) readFile(cmd *cobra.Command, warn bool) (*cliFile, string, error) {
70+ e.fileOnce.Do(func() {
71+ p, err := e.path()
72+ if err != nil {
73+ // Reading is best effort when the location is unknown; the
74+ // `ermite config` commands report this error.
75+ e.file = &cliFile{}
76+ return
77+ }
78+ e.filePath = p
79+ f, warnings, trust, err := loadConfigFile(p)
80+ if err != nil {
81+ e.fileErr = fmt.Errorf("config: %w", err)
82+ return
83+ }
84+ e.fileTrust = trust
85+ if warn {
86+ for _, w := range warnings {
87+ warnf(cmd, "config: %s", w)
88+ }
89+ }
90+ e.file = f
91+ })
92+ return e.file, e.filePath, e.fileErr
93+}
94+
95+// loadConfigFile parses the file at path (missing: empty). trust is nil when
96+// token.cmd may be run from this file, else the reason why not.
97+func loadConfigFile(path string) (f *cliFile, warnings []kvconf.Warning, trust, err error) {
98+ h, err := openConfig(path)
99+ if errors.Is(err, fs.ErrNotExist) {
100+ return &cliFile{}, nil, nil, nil
101+ }
102+ if err != nil {
103+ return nil, nil, nil, err
104+ }
105+ defer h.Close() //nolint:errcheck // read only
106+ info, err := h.Stat()
107+ if err != nil {
108+ return nil, nil, nil, err
109+ }
110+ if !info.Mode().IsRegular() {
111+ return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrNotRegular)
112+ }
113+ trust = checkPrivate(path, info, os.Geteuid())
114+ data, err := io.ReadAll(io.LimitReader(h, kvconf.DefaultMaxBytes+1))
115+ if err != nil {
116+ return nil, nil, nil, err
117+ }
118+ if len(data) > kvconf.DefaultMaxBytes {
119+ return nil, nil, nil, fmt.Errorf("%s: %w", path, kvconf.ErrTooLarge)
120+ }
121+ f = &cliFile{}
122+ warnings, err = kvconf.Unmarshal(data, f, kvconf.Options{})
123+ if err != nil {
124+ return nil, nil, nil, fmt.Errorf("%s: %w", path, err)
125+ }
126+ return f, warnings, trust, nil
127+}
128+
129+// urlSource says where the server address in use comes from, for error messages.
130+func (e *cliEnv) urlSource() string {
131+ if strings.TrimSpace(e.getenv(client.EnvURL)) != "" {
132+ return client.EnvURL
133+ }
134+ return fmt.Sprintf("url in %s (fix it with `ermite config set url <url>`)", e.filePath)
135+}
136+
137+// resolveURL is ERMITE_URL if set, else the url of the config file, else "".
138+func (e *cliEnv) resolveURL(cmd *cobra.Command, warn bool) (string, error) {
139+ if v := strings.TrimSpace(e.getenv(client.EnvURL)); v != "" {
140+ return v, nil
141+ }
142+ f, _, err := e.readFile(cmd, warn)
143+ if err != nil {
144+ return "", err
145+ }
146+ return strings.TrimSpace(f.URL), nil
147+}
148+
149+// tokenFromCmd is the output of token.cmd, or "" when the file sets none or
150+// when ERMITE_URL points somewhere else than the url of the file: the token
151+// the command fetches belongs to the server of the file, and must not be sent
152+// to another one.
153+func (e *cliEnv) tokenFromCmd(cmd *cobra.Command, warn bool) (string, error) {
154+ f, _, err := e.readFile(cmd, warn)
155+ if err != nil {
156+ return "", err
157+ }
158+ line := strings.TrimSpace(f.TokenCmd)
159+ if line == "" {
160+ return "", nil
161+ }
162+ e.tokenOnce.Do(func() {
163+ if !e.urlIsFromFile(f) {
164+ if warn {
165+ noticef(cmd, "%s differs from the config file url: token.cmd not run (set %s)", client.EnvURL, client.EnvToken)
166+ }
167+ return
168+ }
169+ e.token, e.tokenErr = e.fetchToken(e.ctx(cmd), line)
170+ })
171+ return e.token, e.tokenErr
172+}
173+
174+// urlIsFromFile reports whether the server in use is the one of the config
175+// file: ERMITE_URL is unset, or names the same address as the file's url.
176+func (e *cliEnv) urlIsFromFile(f *cliFile) bool {
177+ envURL := strings.TrimSpace(e.getenv(client.EnvURL))
178+ if envURL == "" {
179+ return true
180+ }
181+ a, err := client.ParseURL(envURL)
182+ if err != nil {
183+ return false
184+ }
185+ b, err := client.ParseURL(strings.TrimSpace(f.URL))
186+ return err == nil && a.String() == b.String()
187+}
188+
189+// fetchToken runs token.cmd. Neither its output nor its error text reach the
190+// caller's messages: the output is the secret.
191+func (e *cliEnv) fetchToken(ctx context.Context, line string) (string, error) {
192+ if e.fileTrust != nil {
193+ return "", e.fileTrust
194+ }
195+ out, err := e.runTokenCmd(ctx, line)
196+ if err != nil {
197+ return "", fmt.Errorf("token.cmd failed: %w", err)
198+ }
199+ out = strings.TrimSpace(out)
200+ switch {
201+ case out == "":
202+ return "", errors.New("token.cmd printed nothing")
203+ case strings.ContainsAny(out, "\r\n"):
204+ return "", errors.New("token.cmd printed more than one line")
205+ }
206+ return out, nil
207+}
208+
209+// runTokenShell is the default runTokenCmd: the system shell, for at most
210+// tokenCmdTimeout.
211+func runTokenShell(ctx context.Context, line string) (string, error) {
212+ return runShell(ctx, line, tokenCmdTimeout)
213+}
214+
215+// runShell runs line with the system shell. Standard error is the terminal's,
216+// and so is standard input when it is a terminal, so a pinentry or a biometric
217+// prompt works; a script or an agent gets no input. Standard output is
218+// captured, up to tokenCmdMaxOutput bytes. The environment is the process's.
219+// An interrupt or SIGTERM sent to ermite, or the end of ctx, stops the command
220+// like a timeout does (see shellCommand for what is killed).
221+func runShell(ctx context.Context, line string, timeout time.Duration) (string, error) {
222+ sigCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
223+ defer stop()
224+ runCtx, cancel := context.WithTimeout(sigCtx, timeout)
225+ defer cancel()
226+
227+ foreground := term.IsTerminal(int(os.Stdin.Fd()))
228+ c := shellCommand(runCtx, line, foreground)
229+ out := &cappedBuffer{max: tokenCmdMaxOutput}
230+ c.Stdout, c.Stderr = out, os.Stderr
231+ if foreground {
232+ c.Stdin = os.Stdin
233+ } // else nil: the command reads os.DevNull
234+ // Do not wait forever for a background process that kept the pipe open.
235+ c.WaitDelay = 2 * time.Second
236+ err := c.Run()
237+ if err != nil {
238+ switch {
239+ case sigCtx.Err() != nil:
240+ return "", errors.New("interrupted")
241+ case errors.Is(runCtx.Err(), context.DeadlineExceeded):
242+ return "", fmt.Errorf("timed out after %s", timeout)
243+ }
244+ if exit := (*exec.ExitError)(nil); errors.As(err, &exit) {
245+ return "", errors.New(exit.String())
246+ }
247+ return "", err
248+ }
249+ if out.over {
250+ return "", fmt.Errorf("printed more than %d bytes", tokenCmdMaxOutput)
251+ }
252+ return out.buf.String(), nil
253+}
254+
255+// cappedBuffer keeps the first max bytes written and notes an overflow.
256+type cappedBuffer struct {
257+ buf bytes.Buffer
258+ max int
259+ over bool
260+}
261+
262+func (b *cappedBuffer) Write(p []byte) (int, error) {
263+ n := len(p) // report everything as written: a short write would abort the copy
264+ if room := b.max - b.buf.Len(); len(p) > room {
265+ b.over = true
266+ p = p[:room]
267+ }
268+ b.buf.Write(p)
269+ return n, nil
270+}
271+
272+var _ io.Writer = (*cappedBuffer)(nil)
A internal/app/cli_configfile_test.go +450 -0
@@ -0,0 +1,450 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "context"
7+ "errors"
8+ "os"
9+ "path/filepath"
10+ "runtime"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+ "golang.org/x/term"
18+
19+ "bnema.dev/ermite-api/gen/ermite"
20+)
21+
22+// writeConfigFile writes the config file of env with the given mode.
23+func writeConfigFile(t *testing.T, env *cliEnv, content string, mode os.FileMode) string {
24+ t.Helper()
25+ path, err := env.configPath()
26+ require.NoError(t, err)
27+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
28+ require.NoError(t, os.WriteFile(path, []byte(content), mode))
29+ require.NoError(t, os.Chmod(path, mode)) // umask
30+ return path
31+}
32+
33+// tokenCmdStub makes runTokenCmd return out and err, and counts the runs.
34+func tokenCmdStub(env *cliEnv, out string, err error) *[]string {
35+ var ran []string
36+ env.runTokenCmd = func(_ context.Context, line string) (string, error) {
37+ ran = append(ran, line)
38+ return out, err
39+ }
40+ return &ran
41+}
42+
43+func listFake() *fakeAPI {
44+ return &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
45+ return &ermite.ListReposResponse{}, nil
46+ }}
47+}
48+
49+func lastAuth(f *fakeAPI) string {
50+ f.mu.Lock()
51+ defer f.mu.Unlock()
52+ if len(f.header) == 0 {
53+ return "<no call>"
54+ }
55+ return f.header[len(f.header)-1].Get("Authorization")
56+}
57+
58+func TestConfigFileSuppliesURLAndToken(t *testing.T) {
59+ f := listFake()
60+ url := f.serve(t)
61+ env := testEnv(t, nil)
62+ writeConfigFile(t, env, "# my server\nurl = "+url+"\ntoken.cmd = pass show ermite/token\n", 0o600)
63+ ran := tokenCmdStub(env, " ermite_fromcmd\n", nil)
64+
65+ runCLIWith(t, env, "", "repo", "list").ok(t)
66+ assert.Equal(t, "Bearer ermite_fromcmd", lastAuth(f))
67+ assert.Equal(t, []string{"pass show ermite/token"}, *ran)
68+}
69+
70+func TestConfigFileURLWithoutTokenCmdIsAnonymous(t *testing.T) {
71+ f := listFake()
72+ env := testEnv(t, nil)
73+ writeConfigFile(t, env, "url = "+f.serve(t)+"\n", 0o600)
74+
75+ runCLIWith(t, env, "", "repo", "list").ok(t)
76+ assert.Empty(t, lastAuth(f))
77+}
78+
79+func TestEnvironmentWinsOverConfigFile(t *testing.T) {
80+ fileSrv, envSrv := listFake(), listFake()
81+ env := testEnv(t, map[string]string{"ERMITE_URL": envSrv.serve(t), "ERMITE_TOKEN": "ermite_env"})
82+ writeConfigFile(t, env, "url = "+fileSrv.serve(t)+"\ntoken.cmd = echo nope\n", 0o600)
83+
84+ runCLIWith(t, env, "", "repo", "list").ok(t) // the default runTokenCmd of testEnv fails the test if called
85+ assert.Equal(t, "Bearer ermite_env", lastAuth(envSrv))
86+ calls, _ := fileSrv.snapshot()
87+ assert.Empty(t, calls, "the url of the file is not used when ERMITE_URL is set")
88+}
89+
90+func TestEnvURLDifferentFromFileKeepsTokenCmdOut(t *testing.T) {
91+ f := listFake()
92+ tests := []struct {
93+ name string
94+ envURL string // "" means the fake server
95+ file string // the content of the config file
96+ wantErr bool
97+ }{
98+ {name: "other url in the file", file: "url = http://file.invalid\ntoken.cmd = get-token\n"},
99+ {name: "file without url", file: "token.cmd = get-token\n"},
100+ {name: "invalid ERMITE_URL", envURL: "nope", file: "url = http://file.invalid\ntoken.cmd = get-token\n", wantErr: true},
101+ }
102+ for _, tt := range tests {
103+ t.Run(tt.name, func(t *testing.T) {
104+ envURL := tt.envURL
105+ if envURL == "" {
106+ envURL = f.serve(t)
107+ }
108+ env := testEnv(t, map[string]string{"ERMITE_URL": envURL})
109+ writeConfigFile(t, env, tt.file, 0o600)
110+ ran := tokenCmdStub(env, "ermite_cmd", nil)
111+
112+ res := runCLIWith(t, env, "", "repo", "list")
113+ assert.Empty(t, *ran, "the token of the file's server must not go to another server")
114+ if tt.wantErr {
115+ require.Error(t, res.err)
116+ return
117+ }
118+ require.NoError(t, res.err)
119+ assert.Empty(t, lastAuth(f))
120+ assert.Equal(t, 1, strings.Count(res.stderr, "token.cmd not run"), res.stderr)
121+ assert.Contains(t, res.stderr, "ERMITE_TOKEN")
122+ })
123+ }
124+}
125+
126+func TestEnvURLSameAsFileUsesTokenCmd(t *testing.T) {
127+ f := listFake()
128+ url := f.serve(t)
129+ // the same address, written differently
130+ env := testEnv(t, map[string]string{"ERMITE_URL": " " + strings.ToUpper(url[:4]) + url[4:] + "/ "})
131+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
132+ ran := tokenCmdStub(env, "ermite_cmd", nil)
133+
134+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
135+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
136+ assert.Len(t, *ran, 1)
137+ assert.NotContains(t, res.stderr, "token.cmd")
138+}
139+
140+func TestTokenCmdNotRunWhenEnvTokenSet(t *testing.T) {
141+ f := listFake()
142+ env := testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
143+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o666) // even an unsafe file is not an issue
144+ ran := tokenCmdStub(env, "other", nil)
145+
146+ runCLIWith(t, env, "", "repo", "list").ok(t)
147+ assert.Empty(t, *ran)
148+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
149+}
150+
151+func TestTokenCmdNotRunForVersionNorCompletion(t *testing.T) {
152+ f := &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
153+ return &ermite.GetVersionResponse{Version: "v0.4.0"}, nil
154+ }}
155+ url := f.serve(t)
156+
157+ for name, args := range map[string][]string{
158+ "version": {"version"},
159+ "version --json": {"version", "--json"},
160+ "completion": {"completion", "bash"},
161+ } {
162+ t.Run(name, func(t *testing.T) {
163+ env := testEnv(t, nil)
164+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
165+ ran := tokenCmdStub(env, "", errors.New("must not run"))
166+ runCLIWith(t, env, "", args...).ok(t)
167+ assert.Empty(t, *ran)
168+ })
169+ }
170+
171+ t.Run("version without any url", func(t *testing.T) {
172+ env := testEnv(t, nil)
173+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
174+ ran := tokenCmdStub(env, "", errors.New("must not run"))
175+ res := runCLIWith(t, env, "", "version").ok(t)
176+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
177+ assert.Empty(t, *ran)
178+ })
179+
180+ t.Run("version uses the url of the file and sends no token", func(t *testing.T) {
181+ env := testEnv(t, nil)
182+ writeConfigFile(t, env, "url = "+url+"\ntoken.cmd = get-token\n", 0o600)
183+ tokenCmdStub(env, "ermite_cmd", nil)
184+ res := runCLIWith(t, env, "", "version").ok(t)
185+ assert.Contains(t, res.stdout, "v0.4.0 at "+url)
186+ assert.Empty(t, lastAuth(f))
187+ })
188+}
189+
190+func TestTokenCmdRunsOncePerProcess(t *testing.T) {
191+ f := listFake()
192+ env := testEnv(t, nil)
193+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
194+ ran := tokenCmdStub(env, "ermite_cmd", nil)
195+ root := newRootCmdWith(env)
196+ for range 2 {
197+ c, _, err := root.Find([]string{"repo", "list"})
198+ require.NoError(t, err)
199+ _, err = env.client(c)
200+ require.NoError(t, err)
201+ }
202+ assert.Len(t, *ran, 1)
203+}
204+
205+func TestTokenCmdRefusedWhenConfigFileIsWritableByOthers(t *testing.T) {
206+ if runtime.GOOS == "windows" {
207+ t.Skip("no Unix permission bits")
208+ }
209+ for _, mode := range []os.FileMode{0o666, 0o662, 0o620} {
210+ t.Run(mode.String(), func(t *testing.T) {
211+ env := testEnv(t, nil)
212+ path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", mode)
213+ ran := tokenCmdStub(env, "ermite_cmd", nil)
214+ res := runCLIWith(t, env, "", "repo", "list")
215+ require.Error(t, res.err)
216+ assert.Equal(t, "config file "+path+" is writable by others: token.cmd not run", res.err.Error())
217+ assert.Empty(t, *ran)
218+ })
219+ }
220+ t.Run("group readable is fine", func(t *testing.T) {
221+ f := listFake()
222+ env := testEnv(t, nil)
223+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o644)
224+ tokenCmdStub(env, "ermite_cmd", nil)
225+ runCLIWith(t, env, "", "repo", "list").ok(t)
226+ })
227+}
228+
229+func TestTokenCmdFailuresNeverShowOutput(t *testing.T) {
230+ const secret = "s3cret-token-value"
231+ tests := []struct {
232+ name string
233+ out string
234+ err error
235+ want string
236+ }{
237+ {"failure", secret, errors.New("exit status 3"), "token.cmd failed: exit status 3"},
238+ {"empty", " \n", nil, "token.cmd printed nothing"},
239+ {"multiline", secret + "\n" + secret + "\n", nil, "token.cmd printed more than one line"},
240+ {"crlf multiline", secret + "\r\n" + secret, nil, "token.cmd printed more than one line"},
241+ }
242+ for _, tt := range tests {
243+ t.Run(tt.name, func(t *testing.T) {
244+ env := testEnv(t, nil)
245+ writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
246+ tokenCmdStub(env, tt.out, tt.err)
247+ res := runCLIWith(t, env, "", "repo", "list")
248+ require.Error(t, res.err)
249+ assert.Equal(t, tt.want, res.err.Error())
250+ assert.NotContains(t, res.err.Error()+res.stdout+res.stderr, secret)
251+ })
252+ }
253+}
254+
255+func TestMissingURLIsReportedBeforeTokenCmdRuns(t *testing.T) {
256+ env := testEnv(t, nil)
257+ writeConfigFile(t, env, "token.cmd = get-token\n", 0o600)
258+ ran := tokenCmdStub(env, "ermite_cmd", nil)
259+ res := runCLIWith(t, env, "", "repo", "list")
260+ require.Error(t, res.err)
261+ assert.Contains(t, res.err.Error(), "ERMITE_URL is not set")
262+ assert.Contains(t, res.err.Error(), "ermite config set url")
263+ assert.Empty(t, *ran)
264+}
265+
266+func TestInvalidFileURLIsReported(t *testing.T) {
267+ env := testEnv(t, nil)
268+ path := writeConfigFile(t, env, "url = git.example.com\n", 0o600)
269+ res := runCLIWith(t, env, "", "repo", "list")
270+ require.Error(t, res.err)
271+ assert.Contains(t, res.err.Error(), "url in "+filepath.Dir(path)) // the file named
272+ assert.Contains(t, res.err.Error(), "ermite config set url")
273+ assert.Contains(t, res.err.Error(), "http")
274+}
275+
276+func TestConfigFileWarnings(t *testing.T) {
277+ f := listFake()
278+ env := testEnv(t, nil)
279+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ncolour = blue\n", 0o600)
280+ res := runCLIWith(t, env, "", "repo", "list").ok(t)
281+ assert.Equal(t, 1, strings.Count(res.stderr, "ermite: warning: config: "), res.stderr)
282+ assert.Contains(t, res.stderr, "colour")
283+ assert.NotContains(t, res.stderr, "blue", "values are never echoed")
284+}
285+
286+func TestCompletionStaysSilentAboutTheConfigFile(t *testing.T) {
287+ env := testEnv(t, nil)
288+ writeConfigFile(t, env, "colour = blue\nurl = nope\n", 0o600)
289+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
290+ assert.NotContains(t, res.stderr, "warning")
291+}
292+
293+func TestERMITE_CONFIGSelectsTheFile(t *testing.T) {
294+ f := listFake()
295+ path := filepath.Join(t.TempDir(), "other", "conf")
296+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o700))
297+ require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\n"), 0o600))
298+
299+ env := testEnv(t, map[string]string{"ERMITE_CONFIG": path})
300+ env.configPath = nil // the default resolution, which reads ERMITE_CONFIG
301+ runCLIWith(t, env, "", "repo", "list").ok(t)
302+}
303+
304+func TestTokenCmdRunsThroughTheShell(t *testing.T) {
305+ if runtime.GOOS == "windows" {
306+ t.Skip("uses sh")
307+ }
308+ f := listFake()
309+ env := testEnv(t, nil)
310+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = printf secret\n", 0o600)
311+ env.runTokenCmd = runTokenShell // the real thing
312+
313+ runCLIWith(t, env, "", "repo", "list").ok(t)
314+ assert.Equal(t, "Bearer secret", lastAuth(f))
315+}
316+
317+func TestRunShell(t *testing.T) {
318+ if runtime.GOOS == "windows" {
319+ t.Skip("uses sh")
320+ }
321+ tests := []struct {
322+ name string
323+ line string
324+ want string // the output on success
325+ errHas string // the error on failure
326+ errNot string // what the error must not show
327+ wantErr bool
328+ }{
329+ {name: "exit status", line: "echo s3cret; exit 3", wantErr: true, errHas: "exit status 3", errNot: "s3cret"},
330+ {name: "too much output", line: "head -c 5000 /dev/zero | tr '\\0' a", wantErr: true, errHas: "more than 4096 bytes", errNot: "aaa"},
331+ {name: "output", line: "echo ermite_ok", want: "ermite_ok\n"},
332+ }
333+ for _, tt := range tests {
334+ t.Run(tt.name, func(t *testing.T) {
335+ out, err := runShell(t.Context(), tt.line, time.Minute)
336+ if !tt.wantErr {
337+ require.NoError(t, err)
338+ assert.Equal(t, tt.want, out)
339+ return
340+ }
341+ require.Error(t, err)
342+ assert.Contains(t, err.Error(), tt.errHas)
343+ assert.NotContains(t, err.Error(), tt.errNot)
344+ })
345+ }
346+}
347+
348+// requireNoTerminal skips a test of the non-terminal path when the tests run
349+// with a terminal as standard input.
350+func requireNoTerminal(t *testing.T) {
351+ t.Helper()
352+ if runtime.GOOS == "windows" {
353+ t.Skip("uses sh")
354+ }
355+ if term.IsTerminal(int(os.Stdin.Fd())) {
356+ t.Skip("standard input is a terminal")
357+ }
358+}
359+
360+func TestTokenCmdTimesOut(t *testing.T) {
361+ requireNoTerminal(t)
362+ f := listFake()
363+ env := testEnv(t, nil)
364+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = sleep 30; :\n", 0o600)
365+ env.runTokenCmd = func(ctx context.Context, line string) (string, error) {
366+ return runShell(ctx, line, 100*time.Millisecond)
367+ }
368+
369+ start := time.Now()
370+ res := runCLIWith(t, env, "", "repo", "list")
371+ require.Error(t, res.err)
372+ assert.Equal(t, "token.cmd failed: timed out after 100ms", res.err.Error())
373+ assert.Less(t, time.Since(start), 10*time.Second)
374+}
375+
376+func TestTokenCmdGetsNoInputWithoutATerminal(t *testing.T) {
377+ requireNoTerminal(t)
378+ out, err := runShell(t.Context(), "cat; echo done", time.Minute)
379+ require.NoError(t, err)
380+ assert.Equal(t, "done\n", out, "the command read nothing")
381+}
382+
383+func TestTokenCmdRefusedWhenDirectoryIsWritableByOthers(t *testing.T) {
384+ if runtime.GOOS == "windows" {
385+ t.Skip("no Unix permission bits")
386+ }
387+ env := testEnv(t, nil)
388+ path := writeConfigFile(t, env, "url = http://ermite.invalid:1\ntoken.cmd = get-token\n", 0o600)
389+ dir := filepath.Dir(path)
390+ require.NoError(t, os.Chmod(dir, 0o777))
391+ ran := tokenCmdStub(env, "ermite_cmd", nil)
392+
393+ res := runCLIWith(t, env, "", "repo", "list")
394+ require.Error(t, res.err)
395+ assert.Equal(t, "config directory "+dir+" is writable by others: token.cmd not run", res.err.Error())
396+ assert.Empty(t, *ran)
397+
398+ t.Run("sticky directory is fine", func(t *testing.T) {
399+ require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky))
400+ f := listFake()
401+ env := testEnvAt(t, path)
402+ require.NoError(t, os.WriteFile(path, []byte("url = "+f.serve(t)+"\ntoken.cmd = get-token\n"), 0o600))
403+ tokenCmdStub(env, "ermite_cmd", nil)
404+ runCLIWith(t, env, "", "repo", "list").ok(t)
405+ })
406+}
407+
408+func TestTokenCmdFollowsASymlinkedConfigFile(t *testing.T) {
409+ if runtime.GOOS == "windows" {
410+ t.Skip("symlinks")
411+ }
412+ f := listFake()
413+ env := testEnv(t, nil)
414+ real := writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
415+ link := filepath.Join(t.TempDir(), "dotfile-link")
416+ require.NoError(t, os.Symlink(real, link))
417+ tokenCmdStub(env, "ermite_cmd", nil)
418+ env.configPath = func() (string, error) { return link, nil }
419+ runCLIWith(t, env, "", "repo", "list").ok(t)
420+ assert.Equal(t, "Bearer ermite_cmd", lastAuth(f))
421+}
422+
423+func TestVersionWarnsAboutAnUnreadableConfigFile(t *testing.T) {
424+ env := testEnv(t, nil)
425+ path := writeConfigFile(t, env, "", 0o600)
426+ require.NoError(t, os.Remove(path))
427+ require.NoError(t, os.Mkdir(path, 0o700))
428+ res := runCLIWith(t, env, "", "version").ok(t)
429+ assert.Equal(t, "ermite v0.0.1\n", res.stdout)
430+ assert.Contains(t, res.stderr, "ermite: warning: config:")
431+}
432+
433+func TestCompletionNeverRunsTokenCmd(t *testing.T) {
434+ f := &fakeAPI{listRepos: func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error) {
435+ return &ermite.ListReposResponse{Repos: []*ermite.Repo{repoAlpha}}, nil
436+ }}
437+ env := testEnv(t, nil)
438+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
439+ ran := tokenCmdStub(env, "ermite_cmd", nil)
440+ res := runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
441+ assert.Empty(t, *ran)
442+ assert.Contains(t, res.stdout, "alpha")
443+ assert.Empty(t, lastAuth(f))
444+
445+ // with ERMITE_TOKEN set the completion is authenticated
446+ env = testEnv(t, map[string]string{"ERMITE_TOKEN": "ermite_env"})
447+ writeConfigFile(t, env, "url = "+f.serve(t)+"\ntoken.cmd = get-token\n", 0o600)
448+ runCLIWith(t, env, "", "__complete", "repo", "show", "").ok(t)
449+ assert.Equal(t, "Bearer ermite_env", lastAuth(f))
450+}
M internal/app/cli_e2e_test.go +3 -3
@@ -326,7 +326,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
326326 ver := decode[versionOut](t, res.stdout)
327327 assert.Equal(t, c.url, ver.Server.URL)
328328 assert.NotEmpty(t, ver.Server.Version)
329- assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths"}, ver.Server.Features)
329+ assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "git.list_paths", "token.branch_prefixes", "ci.runs", "ci.runs.filters", "ci.runners"}, ver.Server.Features)
330330 assert.Equal(t, []string{"passkeys"}, ver.Server.MissingFeatures)
331331 stdout, stderr, code = c.bin(t, secret, "version")
332332 require.Equal(t, 0, code, stderr)
@@ -339,7 +339,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
339339 stdout, stderr, code = c.bin(t, secret, "token", "create", "tmp", "--preset", "ci", "--repo", "proj", "--expires", "7d", "--json")
340340 require.Equal(t, 0, code, stderr)
341341 res = result{stdout: stdout}
342- assert.Equal(t, []string{"created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
342+ assert.Equal(t, []string{"branch_prefixes", "created_at", "expires_at", "id", "label", "last_used_at", "repos", "scopes", "secret"}, keys(t, res.stdout))
343343 tmp := decode[tokenOut](t, res.stdout)
344344 assert.Equal(t, []string{"repo:read"}, tmp.Scopes)
345345 assert.Equal(t, []string{"proj"}, tmp.Repos)
@@ -369,7 +369,7 @@ func TestAC6CLIEndToEnd(t *testing.T) {
369369 assert.Equal(t, tmp.ID, byLabel["tmp"].ID)
370370 assert.NotContains(t, res.stdout, `"secret"`)
371371 res = c.cli(t, secret, "token", "list").ok(t)
372- assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+EXPIRES\s+LAST USED$`, res.stdout)
372+ assert.Regexp(t, `(?m)^ID\s+LABEL\s+SCOPES\s+REPOS\s+BRANCHES\s+EXPIRES\s+LAST USED$`, res.stdout)
373373 assert.Contains(t, res.stdout, tmp.ID)
374374
375375 stdout, stderr, code = c.bin(t, secret, "token", "revoke", tmp.ID)
M internal/app/cli_fake_test.go +31 -6
@@ -5,8 +5,10 @@ package app
55 import (
66 "bytes"
77 "context"
8+ "errors"
89 "net/http"
910 "net/http/httptest"
11+ "path/filepath"
1012 "strings"
1113 "sync"
1214 "testing"
@@ -30,6 +32,7 @@ type fakeAPI struct {
3032 ermiteconnect.UnimplementedEventServiceHandler
3133 ermiteconnect.UnimplementedMergeRequestServiceHandler
3234 ermiteconnect.UnimplementedSessionServiceHandler
35+ ermiteconnect.UnimplementedRunServiceHandler
3336
3437 mu sync.Mutex
3538 calls []string // procedure names, in order
@@ -172,6 +175,8 @@ func (f *fakeAPI) serve(t *testing.T) string {
172175 mount(p, h)
173176 p, h = ermiteconnect.NewSessionServiceHandler(f)
174177 mount(p, h)
178+ p, h = ermiteconnect.NewRunServiceHandler(f)
179+ mount(p, h)
175180 srv := httptest.NewServer(mux)
176181 t.Cleanup(srv.Close)
177182 return srv.URL
@@ -185,15 +190,35 @@ type result struct {
185190 err error
186191 }
187192
193+// testEnv is a cliEnv with the given variables, the fixed clock, and a config
194+// file path inside a fresh temp dir (no file yet) so that a test never reads
195+// the developer's own ~/.config/ermite/config. token.cmd must not run unless
196+// the test sets runTokenCmd.
197+func testEnv(t *testing.T, vars map[string]string) *cliEnv {
198+ t.Helper()
199+ path := filepath.Join(t.TempDir(), "ermite", "config")
200+ return &cliEnv{
201+ getenv: func(k string) string { return vars[k] },
202+ now: func() time.Time { return testNow },
203+ version: "v0.0.1",
204+ sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
205+ configPath: func() (string, error) { return path, nil },
206+ runTokenCmd: func(context.Context, string) (string, error) {
207+ t.Error("token.cmd must not run")
208+ return "", errors.New("token.cmd must not run")
209+ },
210+ }
211+}
212+
188213 // runCLI runs the command tree in process with the given environment.
189214 func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {
190215 t.Helper()
191- env := &cliEnv{
192- getenv: func(k string) string { return vars[k] },
193- now: func() time.Time { return testNow },
194- version: "v0.0.1",
195- sleep: func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
196- }
216+ return runCLIWith(t, testEnv(t, vars), stdin, args...)
217+}
218+
219+// runCLIWith runs the command tree in process with env.
220+func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result {
221+ t.Helper()
197222 root := newRootCmdWith(env)
198223 var out, errb bytes.Buffer
199224 root.SetOut(&out)
M internal/app/cli_mr.go +8 -8
@@ -204,7 +204,7 @@ func completeMRRepoThenNumber(env *cliEnv) func(*cobra.Command, []string, string
204204 case 0:
205205 return repoNames(env, cmd, toComplete), cobra.ShellCompDirectiveNoFileComp
206206 case 1:
207- c, err := env.client(cmd, false)
207+ c, err := env.completionClient(cmd)
208208 if err != nil {
209209 return nil, cobra.ShellCompDirectiveNoFileComp
210210 }
@@ -265,7 +265,7 @@ func newMRCreateCmd(env *cliEnv) *cobra.Command {
265265 Repo: args[0], Title: title, Description: desc,
266266 SourceBranch: trimBranch(source), TargetBranch: trimBranch(target),
267267 }
268- c, err := env.client(cmd, true)
268+ c, err := env.client(cmd)
269269 if err != nil {
270270 return err
271271 }
@@ -310,7 +310,7 @@ func newMRListCmd(env *cliEnv) *cobra.Command {
310310 if err != nil {
311311 return err
312312 }
313- c, err := env.client(cmd, true)
313+ c, err := env.client(cmd)
314314 if err != nil {
315315 return err
316316 }
@@ -474,7 +474,7 @@ func newMRShowCmd(env *cliEnv) *cobra.Command {
474474 if err != nil {
475475 return err
476476 }
477- c, err := env.client(cmd, true)
477+ c, err := env.client(cmd)
478478 if err != nil {
479479 return err
480480 }
@@ -687,7 +687,7 @@ func newMRCommentCmd(env *cliEnv) *cobra.Command {
687687 case line != 0 || cmd.Flags().Changed("side"):
688688 return errors.New("--line and --side need --path")
689689 }
690- c, err := env.client(cmd, true)
690+ c, err := env.client(cmd)
691691 if err != nil {
692692 return err
693693 }
@@ -722,7 +722,7 @@ func newMRResolveCmd(env *cliEnv) *cobra.Command {
722722 return err
723723 }
724724 id := strings.TrimPrefix(strings.TrimSpace(args[2]), "#")
725- c, err := env.client(cmd, true)
725+ c, err := env.client(cmd)
726726 if err != nil {
727727 return err
728728 }
@@ -781,7 +781,7 @@ func newMRMergeCmd(env *cliEnv) *cobra.Command {
781781 return fmt.Errorf("--head is required: run `ermite mr show %s %d` and give the head id it prints, "+
782782 "so that only the commit you reviewed is merged", args[0], n)
783783 }
784- c, err := env.client(cmd, true)
784+ c, err := env.client(cmd)
785785 if err != nil {
786786 return err
787787 }
@@ -830,7 +830,7 @@ func newMRCloseCmd(env *cliEnv) *cobra.Command {
830830 if err != nil {
831831 return err
832832 }
833- c, err := env.client(cmd, true)
833+ c, err := env.client(cmd)
834834 if err != nil {
835835 return err
836836 }
M internal/app/cli_repo.go +7 -7
@@ -86,7 +86,7 @@ func newRepoCmd(env *cliEnv) *cobra.Command {
8686 repo := &cobra.Command{
8787 Use: "repo",
8888 Short: "Manage repositories on a remote server",
89- Long: "Manage repositories on the server at ERMITE_URL, authenticated with ERMITE_TOKEN.",
89+ Long: "Manage repositories on the configured server (ERMITE_URL or the config file, see `ermite config`), authenticated with ERMITE_TOKEN or the token command of the config file.",
9090 }
9191 repo.AddCommand(
9292 newRepoListCmd(env), newRepoShowCmd(env), newRepoCreateCmd(env),
@@ -146,7 +146,7 @@ func newRepoListCmd(env *cliEnv) *cobra.Command {
146146 }
147147 asJSON := addJSONFlag(cmd)
148148 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
149- c, err := env.client(cmd, true)
149+ c, err := env.client(cmd)
150150 if err != nil {
151151 return err
152152 }
@@ -205,7 +205,7 @@ func newRepoShowCmd(env *cliEnv) *cobra.Command {
205205 }
206206 asJSON := addJSONFlag(cmd)
207207 cmd.RunE = func(cmd *cobra.Command, args []string) error {
208- c, err := env.client(cmd, true)
208+ c, err := env.client(cmd)
209209 if err != nil {
210210 return err
211211 }
@@ -239,7 +239,7 @@ func newRepoCreateCmd(env *cliEnv) *cobra.Command {
239239 if err != nil {
240240 return err
241241 }
242- c, err := env.client(cmd, true)
242+ c, err := env.client(cmd)
243243 if err != nil {
244244 return err
245245 }
@@ -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),
@@ -295,7 +295,7 @@ func newRepoEditCmd(env *cliEnv) *cobra.Command {
295295 if err != nil {
296296 return err
297297 }
298- c, err := env.client(cmd, true)
298+ c, err := env.client(cmd)
299299 if err != nil {
300300 return err
301301 }
@@ -377,7 +377,7 @@ func newRepoDeleteCmd(env *cliEnv) *cobra.Command {
377377 cmd.RunE = func(cmd *cobra.Command, args []string) error {
378378 name := args[0]
379379 // Check the configuration before asking anything.
380- c, err := env.client(cmd, true)
380+ c, err := env.client(cmd)
381381 if err != nil {
382382 return err
383383 }
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)
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)
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)
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)
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)
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)
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)
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+}
A internal/app/cli_shell_unix.go +80 -0
@@ -0,0 +1,80 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "fmt"
10+ "io/fs"
11+ "os"
12+ "os/exec"
13+ "path/filepath"
14+ "syscall"
15+)
16+
17+// shellCommand runs line with `sh -c`.
18+//
19+// When foreground is false (no terminal: a script or an agent), the command
20+// gets its own process group and a cancellation, a timeout or a signal kills
21+// the whole group, so a grandchild such as `sleep` or a secret manager helper
22+// cannot outlive ermite or keep the output pipe open.
23+//
24+// When foreground is true (standard input is a terminal) the command stays in
25+// the foreground process group of the terminal: a pinentry or a password
26+// prompt then works, and a background group would be stopped by SIGTTIN
27+// when it reads the terminal. Ctrl+C reaches the whole group. The limit: on a
28+// timeout or a SIGTERM only sh is killed, not what it started.
29+func shellCommand(ctx context.Context, line string, foreground bool) *exec.Cmd {
30+ c := exec.CommandContext(ctx, "sh", "-c", line)
31+ if !foreground {
32+ c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
33+ c.Cancel = func() error {
34+ // A negative pid is the process group, led by sh.
35+ return syscall.Kill(-c.Process.Pid, syscall.SIGKILL)
36+ }
37+ }
38+ return c
39+}
40+
41+// openConfig opens the config file without blocking on a FIFO swapped in for it.
42+func openConfig(path string) (*os.File, error) {
43+ return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
44+}
45+
46+// checkPrivate says whether token.cmd may be run from the config file at
47+// path, whose open handle reported info: whoever can change the file can make
48+// token.cmd any command. It returns nil when the file belongs to euid (the
49+// effective user id of the process) and cannot be written by group or others,
50+// and when its directory belongs to euid or root and cannot be written by
51+// group or others (a sticky directory excepted), as they could replace the
52+// file.
53+func checkPrivate(path string, info fs.FileInfo, euid int) error {
54+ if st, ok := info.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid {
55+ return fmt.Errorf("config file %s is owned by another user: token.cmd not run", path)
56+ }
57+ if info.Mode().Perm()&0o022 != 0 {
58+ return fmt.Errorf("config file %s is writable by others: token.cmd not run", path)
59+ }
60+ dir := filepath.Dir(path)
61+ if resolved, err := filepath.EvalSymlinks(path); err == nil {
62+ dir = filepath.Dir(resolved) // a symlinked dotfile: the directory of the target
63+ }
64+ return checkPrivateDir(dir, euid)
65+}
66+
67+// checkPrivateDir is the directory half of checkPrivate.
68+func checkPrivateDir(dir string, euid int) error {
69+ di, err := os.Stat(dir)
70+ if err != nil {
71+ return fmt.Errorf("config directory %s: %w: token.cmd not run", dir, err)
72+ }
73+ if st, ok := di.Sys().(*syscall.Stat_t); ok && int(st.Uid) != euid && st.Uid != 0 {
74+ return fmt.Errorf("config directory %s is owned by another user: token.cmd not run", dir)
75+ }
76+ if di.Mode().Perm()&0o022 != 0 && di.Mode()&fs.ModeSticky == 0 {
77+ return fmt.Errorf("config directory %s is writable by others: token.cmd not run", dir)
78+ }
79+ return nil
80+}
A internal/app/cli_shell_unix_test.go +160 -0
@@ -0,0 +1,160 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build !windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "errors"
10+ "io/fs"
11+ "os"
12+ "path/filepath"
13+ "strconv"
14+ "strings"
15+ "syscall"
16+ "testing"
17+ "time"
18+
19+ "github.com/stretchr/testify/assert"
20+ "github.com/stretchr/testify/require"
21+)
22+
23+// processGone reports whether pid is gone (a zombie that nobody reaped counts).
24+func processGone(pid int) bool {
25+ if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) {
26+ return true
27+ }
28+ stat, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
29+ if err != nil {
30+ return false
31+ }
32+ _, after, _ := strings.Cut(string(stat), ") ")
33+ return strings.HasPrefix(after, "Z")
34+}
35+
36+// readPid waits for the pid file written by a grandchild.
37+func readPid(t *testing.T, path string) int {
38+ t.Helper()
39+ var pid int
40+ require.Eventually(t, func() bool {
41+ data, err := os.ReadFile(path)
42+ if err != nil {
43+ return false
44+ }
45+ pid, err = strconv.Atoi(strings.TrimSpace(string(data)))
46+ return err == nil && pid > 0
47+ }, 5*time.Second, 10*time.Millisecond)
48+ return pid
49+}
50+
51+func TestRunShellKillsTheWholeProcessTreeOnTimeoutAndCancel(t *testing.T) {
52+ requireNoTerminal(t)
53+ for name, tc := range map[string]struct {
54+ ctx func(t *testing.T) context.Context
55+ timeout time.Duration
56+ want string
57+ }{
58+ "timeout": {func(t *testing.T) context.Context { return t.Context() }, 700 * time.Millisecond, "timed out after 700ms"},
59+ "cancel": {func(t *testing.T) context.Context {
60+ ctx, cancel := context.WithCancel(t.Context())
61+ time.AfterFunc(700*time.Millisecond, cancel)
62+ return ctx
63+ }, time.Minute, "interrupted"},
64+ } {
65+ t.Run(name, func(t *testing.T) {
66+ pidFile := filepath.Join(t.TempDir(), "pid")
67+ // sh starts a grandchild, records its pid and waits for it
68+ line := "sleep 30 & echo $! > '" + pidFile + "'; wait"
69+ _, err := runShell(tc.ctx(t), line, tc.timeout)
70+ require.Error(t, err)
71+ assert.Equal(t, tc.want, err.Error())
72+
73+ pid := readPid(t, pidFile)
74+ require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond,
75+ "the grandchild %d must not outlive the command", pid)
76+ })
77+ }
78+}
79+
80+func TestRunShellStopsOnSIGTERM(t *testing.T) {
81+ requireNoTerminal(t)
82+ pidFile := filepath.Join(t.TempDir(), "pid")
83+ line := "sleep 30 & echo $! > '" + pidFile + "'; wait"
84+ errc := make(chan error, 1)
85+ go func() {
86+ _, err := runShell(t.Context(), line, time.Minute)
87+ errc <- err
88+ }()
89+ pid := readPid(t, pidFile)
90+ // the signal is delivered to the test process itself: runShell catches it
91+ require.NoError(t, syscall.Kill(os.Getpid(), syscall.SIGTERM))
92+ select {
93+ case err := <-errc:
94+ require.Error(t, err)
95+ assert.Equal(t, "interrupted", err.Error())
96+ case <-time.After(10 * time.Second):
97+ t.Fatal("runShell did not stop on SIGTERM")
98+ }
99+ require.Eventually(t, func() bool { return processGone(pid) }, 5*time.Second, 20*time.Millisecond)
100+}
101+
102+// fakeInfo is a file whose owner and mode are chosen by the test.
103+type fakeInfo struct {
104+ fs.FileInfo
105+ mode fs.FileMode
106+ uid uint32
107+}
108+
109+func (f fakeInfo) Mode() fs.FileMode { return f.mode }
110+func (f fakeInfo) Sys() any { return &syscall.Stat_t{Uid: f.uid} }
111+
112+func TestCheckPrivate(t *testing.T) {
113+ dir := t.TempDir() // owned by the test user, mode 0700
114+ path := filepath.Join(dir, "config")
115+ require.NoError(t, os.WriteFile(path, nil, 0o600))
116+ real, err := os.Stat(path)
117+ require.NoError(t, err)
118+ me := os.Geteuid()
119+
120+ tests := []struct {
121+ name string
122+ mode fs.FileMode
123+ uid int // owner of the file
124+ euid int
125+ want string // error text, "" for accepted
126+ }{
127+ {"own private file", 0o600, me, me, ""},
128+ {"file of another user", 0o600, me + 1, me, "is owned by another user"},
129+ {"file owned by root, not me", 0o600, 0, me + 1, "is owned by another user"},
130+ {"group writable", 0o620, me, me, "is writable by others"},
131+ {"world writable", 0o602, me, me, "is writable by others"},
132+ {"group readable", 0o640, me, me, ""},
133+ // the directory is the real one: owned by me
134+ {"directory of another user", 0o600, me + 1, me + 1, "config directory"},
135+ }
136+ for _, tt := range tests {
137+ t.Run(tt.name, func(t *testing.T) {
138+ if tt.want == "config directory" && me == 0 {
139+ t.Skip("root owns every directory it creates")
140+ }
141+ err := checkPrivate(path, fakeInfo{FileInfo: real, mode: tt.mode, uid: uint32(tt.uid)}, tt.euid)
142+ if tt.want == "" {
143+ assert.NoError(t, err)
144+ return
145+ }
146+ require.Error(t, err)
147+ assert.Contains(t, err.Error(), tt.want)
148+ })
149+ }
150+
151+ t.Run("directory owned by root is fine", func(t *testing.T) {
152+ // /tmp and friends are root's; use a root-owned directory when there is one
153+ fi, err := os.Stat("/")
154+ require.NoError(t, err)
155+ if st, ok := fi.Sys().(*syscall.Stat_t); !ok || st.Uid != 0 || fi.Mode().Perm()&0o022 != 0 {
156+ t.Skip("/ is not a private root directory here")
157+ }
158+ assert.NoError(t, checkPrivateDir("/", me+1))
159+ })
160+}
A internal/app/cli_shell_windows.go +34 -0
@@ -0,0 +1,34 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+//go:build windows
4+
5+package app
6+
7+import (
8+ "context"
9+ "io/fs"
10+ "os"
11+ "os/exec"
12+ "syscall"
13+)
14+
15+// shellCommand runs line with `cmd /C`. The command line is passed as is:
16+// Go's argument quoting would mangle quotes cmd.exe means to read itself.
17+//
18+// The command is killed with exec.CommandContext on a timeout or a signal:
19+// only cmd.exe itself, not what it started (there is no process group kill
20+// here); foreground is ignored.
21+func shellCommand(ctx context.Context, line string, _ bool) *exec.Cmd {
22+ c := exec.CommandContext(ctx, "cmd")
23+ c.SysProcAttr = &syscall.SysProcAttr{CmdLine: "cmd /C " + line}
24+ return c
25+}
26+
27+// openConfig opens the config file.
28+func openConfig(path string) (*os.File, error) { return os.Open(path) }
29+
30+// checkPrivate is a no-op on Windows: it has no Unix mode bits or owner ids to
31+// check, so token.cmd is run from a config file whoever can write it. The
32+// per-user config directory (%AppData%) is private to the user by default;
33+// keep ERMITE_CONFIG out of shared folders.
34+func checkPrivate(string, fs.FileInfo, int) error { return nil }
M internal/app/cli_signing.go +1 -1
@@ -55,7 +55,7 @@ func newSigningKeyShowCmd(env *cliEnv) *cobra.Command {
5555 cmd.MarkFlagsMutuallyExclusive("json", "public-key")
5656 cmd.MarkFlagsMutuallyExclusive("json", "allowed-signers")
5757 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
58- c, err := env.client(cmd, true)
58+ c, err := env.client(cmd)
5959 if err != nil {
6060 return err
6161 }
M internal/app/cli_token.go +85 -22
@@ -21,13 +21,15 @@ import (
2121 // jsonToken is the stable JSON shape of a stored token. The secret is only
2222 // present in the output of `token create`.
2323 type jsonToken struct {
24- ID string `json:"id"`
25- Label string `json:"label"`
26- Scopes []string `json:"scopes"`
27- Repos []string `json:"repos"`
28- ExpiresAt *string `json:"expires_at"`
29- CreatedAt *string `json:"created_at"`
30- LastUsedAt *string `json:"last_used_at"`
24+ ID string `json:"id"`
25+ Label string `json:"label"`
26+ Scopes []string `json:"scopes"`
27+ Repos []string `json:"repos"`
28+ // BranchPrefixes limits the branches the token may write; empty means all.
29+ BranchPrefixes []string `json:"branch_prefixes"`
30+ ExpiresAt *string `json:"expires_at"`
31+ CreatedAt *string `json:"created_at"`
32+ LastUsedAt *string `json:"last_used_at"`
3133 }
3234
3335 type jsonCreatedToken struct {
@@ -44,16 +46,20 @@ func toJSONToken(t *ermite.Token) jsonToken {
4446 if repos == nil {
4547 repos = []string{}
4648 }
49+ prefixes := t.GetBranchPrefixes()
50+ if prefixes == nil {
51+ prefixes = []string{}
52+ }
4753 return jsonToken{
48- ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos,
54+ ID: t.GetId(), Label: t.GetLabel(), Scopes: scopes, Repos: repos, BranchPrefixes: prefixes,
4955 ExpiresAt: jsonTime(t.GetExpiresAt()), CreatedAt: jsonTime(t.GetCreatedAt()), LastUsedAt: jsonTime(t.GetLastUsedAt()),
5056 }
5157 }
5258
5359 const jsonTokenHelp = `
5460 JSON fields of a token (--json): id, label, scopes (array of names like "repo:read"),
55-repos (array; empty means every repository), expires_at, created_at and last_used_at
56-(RFC 3339 UTC, null when unset or never used).`
61+repos (array; empty means every repository), branch_prefixes (array; empty means every branch),
62+expires_at, created_at and last_used_at (RFC 3339 UTC, null when unset or never used).`
5763
5864 func newTokenCmd(env *cliEnv) *cobra.Command {
5965 token := &cobra.Command{
@@ -67,10 +73,11 @@ func newTokenCmd(env *cliEnv) *cobra.Command {
6773
6874 func newTokenCreateCmd(env *cliEnv) *cobra.Command {
6975 var (
70- preset string
71- scopes []string
72- repos []string
73- expires string
76+ preset string
77+ scopes []string
78+ repos []string
79+ prefixes []string
80+ expires string
7481 )
7582 cmd := &cobra.Command{
7683 Use: "create <label>",
@@ -81,9 +88,16 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
8188 "What the token may do is chosen with --preset and/or --scope (they add up); there is no default:\n" +
8289 " cli every scope, for your own daily use\n" +
8390 " agent repo:write, mr:write\n" +
91+ " runner runner:register (a CI runner host)\n" +
8492 " ci repo:read\n" +
8593 "A token cannot be more powerful than the one that creates it. --repo limits it to the named\n" +
8694 "repositories (default: all). --expires takes 30d, 2w, 12h or a date; default: never.\n\n" +
95+ "--branch-prefix limits the branches the token may write (default: all). Each prefix ends with /\n" +
96+ "(for example agent/claude/). The token can push, force-push and delete only branches under its\n" +
97+ "prefixes, and merge only into them; it cannot push tags, create repositories or delete one,\n" +
98+ "and it never has the rights of the owner. It can only create tokens within its own prefixes.\n" +
99+ "To give an AI agent its own space:\n" +
100+ " ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/<name>/\n\n" +
87101 "The label is free text of up to 100 bytes, except \"owner\" and \"root\" (in any case): they are\n" +
88102 "what the audit trail shows for the admin token and the web owner, so the server refuses them." + jsonTokenHelp,
89103 Args: cobra.ExactArgs(1),
@@ -91,6 +105,7 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
91105 cmd.Flags().StringVar(&preset, "preset", "", "scope preset: "+strings.Join(client.PresetNames, ", "))
92106 cmd.Flags().StringSliceVar(&scopes, "scope", nil, "scope to grant, repeatable or comma-separated (for example repo:read)")
93107 cmd.Flags().StringSliceVar(&repos, "repo", nil, "limit the token to this repository, repeatable (default: all)")
108+ cmd.Flags().StringSliceVar(&prefixes, "branch-prefix", nil, "limit the branches the token may write to this prefix, ending with / (for example agent/claude/); repeatable (default: all)")
94109 cmd.Flags().StringVar(&expires, "expires", "", "lifetime (30d, 2w, 12h) or date (2027-01-31); default: never")
95110 _ = cmd.RegisterFlagCompletionFunc("preset", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
96111 return client.PresetNames, cobra.ShellCompDirectiveNoFileComp
@@ -103,25 +118,59 @@ func newTokenCreateCmd(env *cliEnv) *cobra.Command {
103118 })
104119 asJSON := addJSONFlag(cmd)
105120 cmd.RunE = func(cmd *cobra.Command, args []string) error {
106- req, err := buildCreateToken(args[0], preset, scopes, repos, expires, env.now())
121+ req, err := buildCreateToken(args[0], preset, scopes, repos, prefixes, expires, env.now())
107122 if err != nil {
108123 return err
109124 }
110- c, err := env.client(cmd, true)
125+ c, err := env.client(cmd)
111126 if err != nil {
112127 return err
113128 }
129+ if len(req.GetBranchPrefixes()) > 0 {
130+ if err := requireBranchPrefixes(env.ctx(cmd), c); err != nil {
131+ return err
132+ }
133+ }
114134 res, err := c.Tokens().CreateToken(env.ctx(cmd), connect.NewRequest(req))
115135 if err != nil {
116136 return c.Describe(err)
117137 }
138+ if err := checkBranchPrefixes(req, res.Msg.GetToken()); err != nil {
139+ return err
140+ }
118141 return printCreatedToken(cmd, res.Msg, *asJSON)
119142 }
120143 return cmd
121144 }
122145
146+// requireBranchPrefixes refuses, before anything is created, to ask a server
147+// for a branch-limited token when it does not list token.branch_prefixes: such
148+// a server ignores the field. A server whose features cannot be read is refused
149+// too (fail closed).
150+func requireBranchPrefixes(ctx context.Context, c *client.Client) error {
151+ res, err := c.Server().GetVersion(ctx, connect.NewRequest(&ermite.GetVersionRequest{}))
152+ if err != nil {
153+ return c.Describe(err)
154+ }
155+ if !slices.Contains(res.Msg.GetFeatures(), client.FeatureTokenBranchPrefixes) {
156+ return fmt.Errorf("the server does not support %s, so it would ignore --branch-prefix and create an unlimited token: nothing was created (upgrade the server, see `ermite version`)", client.FeatureTokenBranchPrefixes)
157+ }
158+ return nil
159+}
160+
161+// checkBranchPrefixes is the backstop of requireBranchPrefixes: it refuses to hand out a token that is less limited than
162+// asked for. A server that predates branch prefixes ignores the field and
163+// creates an unlimited token: the secret is then not printed.
164+func checkBranchPrefixes(req *ermite.CreateTokenRequest, got *ermite.Token) error {
165+ if slices.Equal(req.GetBranchPrefixes(), got.GetBranchPrefixes()) {
166+ return nil
167+ }
168+ return fmt.Errorf("the server created token %s without the requested branch prefixes (it may be too old: "+
169+ "see `ermite version`); its secret is not shown, revoke it with `ermite token revoke %s`", got.GetId(), got.GetId())
170+}
171+
123172 // buildCreateToken maps the flags of `token create` to the request.
124-func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
173+func buildCreateToken(label, preset string, scopeFlags, repoFlags, prefixFlags []string, expires string, now time.Time) (*ermite.CreateTokenRequest, error) {
125174 label = strings.TrimSpace(label)
126175 if label == "" {
127176 return nil, errors.New("the label must not be empty")
@@ -136,6 +185,11 @@ func buildCreateToken(label, preset string, scopeFlags, repoFlags []string, expi
136185 req.Repos = append(req.Repos, r)
137186 }
138187 }
188+ for _, bp := range prefixFlags {
189+ if bp = strings.TrimSpace(bp); bp != "" && !slices.Contains(req.BranchPrefixes, bp) {
190+ req.BranchPrefixes = append(req.BranchPrefixes, bp)
191+ }
192+ }
139193 if expires != "" {
140194 at, err := client.ParseExpiry(expires, now)
141195 if err != nil {
@@ -155,10 +209,15 @@ func printCreatedToken(cmd *cobra.Command, res *ermite.CreateTokenResponse, asJS
155209 if len(t.GetRepos()) > 0 {
156210 repos = strings.Join(t.GetRepos(), ", ")
157211 }
212+ branches := "all"
213+ if len(t.GetBranchPrefixes()) > 0 {
214+ branches = strings.Join(t.GetBranchPrefixes(), ", ")
215+ }
158216 noticef(cmd, "created token %s %q", t.GetId(), clean(t.GetLabel()))
159217 _ = fields(cmd.ErrOrStderr(), [][2]string{
160218 {" scopes", strings.Join(client.ScopeNames(t.GetScopes()), ", ")},
161219 {" repos", repos},
220+ {" branches", branches},
162221 {" expires", shortTime(t.GetExpiresAt(), "never")},
163222 })
164223 noticef(cmd, "the secret below is shown once and cannot be retrieved later")
@@ -185,7 +244,7 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
185244 }
186245 asJSON := addJSONFlag(cmd)
187246 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
188- c, err := env.client(cmd, true)
247+ c, err := env.client(cmd)
189248 if err != nil {
190249 return err
191250 }
@@ -210,12 +269,16 @@ func newTokenListCmd(env *cliEnv) *cobra.Command {
210269 if len(t.GetRepos()) > 0 {
211270 repos = strings.Join(t.GetRepos(), ",")
212271 }
272+ branches := "all"
273+ if len(t.GetBranchPrefixes()) > 0 {
274+ branches = strings.Join(t.GetBranchPrefixes(), ",")
275+ }
213276 rows = append(rows, []string{
214- t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos,
277+ t.GetId(), t.GetLabel(), strings.Join(client.ScopeNames(t.GetScopes()), ","), repos, branches,
215278 shortTime(t.GetExpiresAt(), "never"), shortTime(t.GetLastUsedAt(), "never"),
216279 })
217280 }
218- return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "EXPIRES", "LAST USED"}, rows)
281+ return table(cmd.OutOrStdout(), []string{"ID", "LABEL", "SCOPES", "REPOS", "BRANCHES", "EXPIRES", "LAST USED"}, rows)
219282 }
220283 return cmd
221284 }
@@ -225,7 +288,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
225288 Use: "revoke <id>",
226289 Short: "Revoke a token",
227290 Long: "Revoke a token by id (see `ermite token list`). It stops working immediately.\n\n" +
228- "A token with every scope and no --repo list has the rights of the owner (it can open web\n" +
291+ "A token with every scope, no --repo list and no --branch-prefix has the rights of the owner (it can open web\n" +
229292 "sessions). Revoking one also signs every browser out, like `ermite web logout --all`:\n" +
230293 "web sessions are not tied to the token that opened them. Sign in again with `ermite web login`.",
231294 Args: cobra.ExactArgs(1),
@@ -239,7 +302,7 @@ func newTokenRevokeCmd(env *cliEnv) *cobra.Command {
239302 return ids, cobra.ShellCompDirectiveNoFileComp
240303 },
241304 RunE: func(cmd *cobra.Command, args []string) error {
242- c, err := env.client(cmd, true)
305+ c, err := env.client(cmd)
243306 if err != nil {
244307 return err
245308 }
M internal/app/cli_token_test.go +131 -6
@@ -3,9 +3,12 @@
33 package app
44
55 import (
6+ "errors"
7+ "strings"
68 "testing"
79 "time"
810
11+ "connectrpc.com/connect"
912 "github.com/stretchr/testify/assert"
1013 "github.com/stretchr/testify/require"
1114
@@ -42,6 +45,128 @@ func TestTokenCreateMapsFlags(t *testing.T) {
4245 assert.NotContains(t, res.stderr, "SECRETVALUE")
4346 }
4447
48+// lastCreateToken returns the last CreateToken request the fake received.
49+func lastCreateToken(t *testing.T, f *fakeAPI) *ermite.CreateTokenRequest {
50+ t.Helper()
51+ calls, reqs := f.snapshot()
52+ for i := len(calls) - 1; i >= 0; i-- {
53+ if calls[i] == "CreateToken" {
54+ return reqs[i].(*ermite.CreateTokenRequest)
55+ }
56+ }
57+ t.Fatal("no CreateToken call")
58+ return nil
59+}
60+
61+// withBranchPrefixes is a GetVersion that lists token.branch_prefixes.
62+func withBranchPrefixes(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
63+ return &ermite.GetVersionResponse{Version: "v0.4.0", Features: []string{"token.branch_prefixes"}}, nil
64+}
65+
66+func TestTokenCreateBranchPrefixes(t *testing.T) {
67+ echo := func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
68+ return &ermite.CreateTokenResponse{
69+ Token: &ermite.Token{
70+ Id: "tok1", Label: r.GetLabel(), Scopes: r.GetScopes(), BranchPrefixes: r.GetBranchPrefixes(),
71+ CreatedAt: ts("2026-10-10T12:00:00Z"),
72+ },
73+ Secret: "ermite_S",
74+ }, nil
75+ }
76+
77+ t.Run("maps the repeatable flag, trims and drops duplicates", func(t *testing.T) {
78+ f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes}
79+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "claude", "--preset", "agent", "--repo", "alpha",
80+ "--branch-prefix", "agent/claude/", "--branch-prefix", " bot/ ", "--branch-prefix", "agent/claude/").ok(t)
81+ assert.Equal(t, []string{"agent/claude/", "bot/"}, lastCreateToken(t, f).GetBranchPrefixes())
82+ assert.Equal(t, "ermite_S\n", res.stdout)
83+ assert.Contains(t, res.stderr, "branches")
84+ assert.Contains(t, res.stderr, "agent/claude/, bot/")
85+ })
86+
87+ t.Run("without the flag the token shows every branch", func(t *testing.T) {
88+ f := &fakeAPI{createToken: echo}
89+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
90+ _, reqs := f.snapshot()
91+ assert.Empty(t, reqs[0].(*ermite.CreateTokenRequest).GetBranchPrefixes())
92+ assert.Regexp(t, `branches\s+all`, res.stderr)
93+ })
94+
95+ t.Run("json", func(t *testing.T) {
96+ f := &fakeAPI{createToken: echo, getVersion: withBranchPrefixes}
97+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:write", "--branch-prefix", "a/", "--json").ok(t)
98+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:write"],"repos":[],"branch_prefixes":["a/"],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
99+ })
100+
101+ t.Run("a server without the feature is asked nothing", func(t *testing.T) {
102+ for _, tt := range []struct {
103+ name string
104+ version func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
105+ wantMsg string
106+ }{
107+ {"feature missing", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
108+ return &ermite.GetVersionResponse{Version: "v0.3.0", Features: []string{"repo.update", "session"}}, nil
109+ }, "does not support token.branch_prefixes"},
110+ {"version unreadable", nil, "does not implement this call"}, // the fake answers unimplemented
111+ {"server unavailable", func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
112+ return nil, connect.NewError(connect.CodeUnavailable, errors.New("connection refused"))
113+ }, "cannot reach the server"},
114+ } {
115+ f := &fakeAPI{createToken: echo, getVersion: tt.version}
116+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
117+ require.Error(t, res.err, tt.name)
118+ assert.Contains(t, strings.ToLower(res.err.Error()), tt.wantMsg, tt.name)
119+ calls, _ := f.snapshot()
120+ assert.NotContains(t, calls, "CreateToken", "%s: no token is created", tt.name)
121+ assert.Empty(t, res.stdout, tt.name)
122+ }
123+ })
124+
125+ t.Run("without --branch-prefix the features are not read", func(t *testing.T) {
126+ f := &fakeAPI{createToken: echo} // no getVersion: it would answer unimplemented
127+ runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "ci").ok(t)
128+ calls, _ := f.snapshot()
129+ assert.NotContains(t, calls, "GetVersion")
130+ })
131+
132+ t.Run("a server that ignores the field does not hand out an unlimited token", func(t *testing.T) {
133+ f := &fakeAPI{createToken: func(r *ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error) {
134+ return &ermite.CreateTokenResponse{
135+ Token: &ermite.Token{Id: "tok9", Label: r.GetLabel(), Scopes: r.GetScopes(), CreatedAt: ts("2026-10-10T12:00:00Z")},
136+ Secret: "ermite_UNLIMITED",
137+ }, nil
138+ }, getVersion: withBranchPrefixes}
139+ res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "x", "--preset", "agent", "--branch-prefix", "agent/x/")
140+ require.Error(t, res.err)
141+ assert.Contains(t, res.err.Error(), "without the requested branch prefixes")
142+ assert.Contains(t, res.err.Error(), "ermite token revoke tok9")
143+ assert.NotContains(t, res.stdout, "UNLIMITED")
144+ assert.NotContains(t, res.stderr, "UNLIMITED")
145+ assert.NotContains(t, res.err.Error(), "UNLIMITED")
146+ })
147+}
148+
149+func TestTokenListBranchPrefixes(t *testing.T) {
150+ f := &fakeAPI{listTokens: func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error) {
151+ return &ermite.ListTokensResponse{Tokens: []*ermite.Token{
152+ {Id: "t2", Label: "claude", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: []string{"agent/claude/", "bot/"}, CreatedAt: ts("2026-10-01T00:00:00Z")},
153+ {Id: "t1", Label: "ci", Scopes: []ermite.Scope{ermite.Scope_SCOPE_REPO_READ}, CreatedAt: ts("2026-09-01T00:00:00Z")},
154+ }}, nil
155+ }}
156+ url := f.serve(t)
157+ res := runCLI(t, envFor(url), "", "token", "list").ok(t)
158+ assert.Equal(t, ""+
159+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
160+ "t2 claude repo:write all agent/claude/,bot/ never never\n"+
161+ "t1 ci repo:read all all never never\n", res.stdout)
162+
163+ res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
164+ assert.JSONEq(t, `[
165+ {"id":"t2","label":"claude","scopes":["repo:write"],"repos":[],"branch_prefixes":["agent/claude/","bot/"],"expires_at":null,"created_at":"2026-10-01T00:00:00Z","last_used_at":null},
166+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
167+ ]`, res.stdout)
168+}
169+
45170 func TestTokenCreatePresetExpansionOnTheWire(t *testing.T) {
46171 for preset, want := range map[string][]ermite.Scope{
47172 "agent": {ermite.Scope_SCOPE_REPO_WRITE, ermite.Scope_SCOPE_MR_WRITE},
@@ -70,7 +195,7 @@ func TestTokenCreateJSON(t *testing.T) {
70195 }, nil
71196 }}
72197 res := runCLI(t, envFor(f.serve(t)), "", "token", "create", "l", "--scope", "repo:read", "--json").ok(t)
73- assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
198+ assert.JSONEq(t, `{"id":"tok1","label":"l","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-10-10T12:00:00Z","last_used_at":null,"secret":"ermite_S"}`, res.stdout)
74199 assert.Empty(t, res.stderr)
75200 }
76201
@@ -106,14 +231,14 @@ func TestTokenList(t *testing.T) {
106231 url := f.serve(t)
107232 res := runCLI(t, envFor(url), "", "token", "list").ok(t)
108233 assert.Equal(t, ""+
109- "ID LABEL SCOPES REPOS EXPIRES LAST USED\n"+
110- "t2 laptop repo:read,repo:write alpha,beta 2027-01-01 00:00 2026-10-09 08:30\n"+
111- "t1 ci repo:read all never never\n", res.stdout)
234+ "ID LABEL SCOPES REPOS BRANCHES EXPIRES LAST USED\n"+
235+ "t2 laptop repo:read,repo:write alpha,beta all 2027-01-01 00:00 2026-10-09 08:30\n"+
236+ "t1 ci repo:read all all never never\n", res.stdout)
112237
113238 res = runCLI(t, envFor(url), "", "token", "list", "--json").ok(t)
114239 assert.JSONEq(t, `[
115- {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
116- {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
240+ {"id":"t2","label":"laptop","scopes":["repo:read","repo:write"],"repos":["alpha","beta"],"branch_prefixes":[],"expires_at":"2027-01-01T00:00:00Z","created_at":"2026-10-01T00:00:00Z","last_used_at":"2026-10-09T08:30:00Z"},
241+ {"id":"t1","label":"ci","scopes":["repo:read"],"repos":[],"branch_prefixes":[],"expires_at":null,"created_at":"2026-09-01T00:00:00Z","last_used_at":null}
117242 ]`, res.stdout)
118243 assert.NotContains(t, res.stdout, "secret")
119244 }
M internal/app/cli_version.go +24 -14
@@ -33,11 +33,13 @@ type jsonServerVersion struct {
3333 func newVersionCmd(env *cliEnv) *cobra.Command {
3434 cmd := &cobra.Command{
3535 Use: "version",
36- Short: "Print the client version and, when ERMITE_URL is set, the server's",
37- Long: "Print the version of this client and, when ERMITE_URL is set, the version of the server and\n" +
38- "the optional features of the API it implements. A warning on standard error says which of the\n" +
39- "features this client uses the server lacks. Without ERMITE_URL only the client is shown.\n\n" +
40- "JSON fields (--json): client.version; server, null without ERMITE_URL, with url, version,\n" +
36+ Short: "Print the client version and, when a server URL is configured, the server's",
37+ Long: "Print the version of this client and, when a server URL is configured (ERMITE_URL or the url\n" +
38+ "of the config file, see `ermite config`), the version of the server and the optional features\n" +
39+ "of the API it implements. A warning on standard error says which of the features this client\n" +
40+ "uses the server lacks. Without a URL only the client is shown. The token command of the config\n" +
41+ "file is not run.\n\n" +
42+ "JSON fields (--json): client.version; server, null without a URL, with url, version,\n" +
4143 "features (array of identifiers) and missing_features (the ones this client uses that the\n" +
4244 "server lacks).",
4345 Args: cobra.NoArgs,
@@ -46,7 +48,13 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
4648 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
4749 out := cmd.OutOrStdout()
4850 clientLine := "ermite " + env.version
49- url := strings.TrimSpace(env.getenv(client.EnvURL))
51+ url, err := env.resolveURL(cmd, true)
52+ if err != nil {
53+ // The client version does not depend on the file: say what is wrong
54+ // and go on as if no URL were configured.
55+ warnf(cmd, "%s", err)
56+ url = ""
57+ }
5058 if url == "" {
5159 if *asJSON {
5260 return writeJSON(out, jsonVersion{Client: jsonClientVersion{Version: env.version}})
@@ -55,7 +63,8 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
5563 return err
5664 }
5765
58- c, err := env.client(cmd, true)
66+ // GetVersion is anonymous: token.cmd is not run for it.
67+ c, err := env.clientNoTokenCmd(cmd, true)
5968 if err != nil {
6069 return err
6170 }
@@ -100,11 +109,12 @@ func newVersionCmd(env *cliEnv) *cobra.Command {
100109
101110 // featureUse says what breaks without a feature, for the version warning.
102111 var featureUse = map[string]string{
103- client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
104- client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
105- client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
106- client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
107- client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
108- client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
109- client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
112+ client.FeatureRepoUpdate: "`ermite repo edit` needs: the command will fail on this server",
113+ client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed",
114+ client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server",
115+ client.FeatureMergeDeleteBranch: "`ermite mr merge --delete-branch` needs: the merge works but the branch is kept",
116+ client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server",
117+ client.FeatureSession: "`ermite web login` needs: the command will fail on this server",
118+ client.FeatureTokenBranchPrefixes: "`ermite token create --branch-prefix` needs: the command is refused, and `token list` does not show the limits",
119+ client.FeaturePasskeys: "`ermite web passkey` needs: the commands will fail on this server (passkeys need an https public URL with a DNS host name)",
110120 }
M internal/app/cli_version_test.go +5 -5
@@ -15,7 +15,7 @@ import (
1515 )
1616
1717 // allFeatures are the features this client uses.
18-var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys"}
18+var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys"}
1919
2020 func versionFake(features ...string) *fakeAPI {
2121 return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) {
@@ -36,15 +36,15 @@ func TestVersionWithServer(t *testing.T) {
3636 f := versionFake(allFeatures...)
3737 url := f.serve(t)
3838 res := runCLI(t, envFor(url), "", "version").ok(t)
39- 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, passkeys\n", res.stdout)
39+ 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, token.branch_prefixes, ci.runs, ci.runners, passkeys\n", res.stdout)
4040 assert.Empty(t, res.stderr, "no warning when every feature is there")
4141
4242 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
43- 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","passkeys"],"missing_features":[]}}`, res.stdout)
43+ 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","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":[]}}`, res.stdout)
4444 }
4545
4646 func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
47- f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "passkeys")
47+ f := versionFake("repo.update", "merge_requests", "merge_requests.delete_branch", "signing_key", "session", "token.branch_prefixes", "ci.runs", "ci.runners", "passkeys")
4848 url := f.serve(t)
4949 res := runCLI(t, envFor(url), "", "version").ok(t)
5050 assert.Contains(t, res.stdout, "v0.4.0")
@@ -52,7 +52,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) {
5252 assert.NotContains(t, res.stderr, "repo.update")
5353
5454 res = runCLI(t, envFor(url), "", "version", "--json").ok(t)
55- 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","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
55+ 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","token.branch_prefixes","ci.runs","ci.runners","passkeys"],"missing_features":["events.resume"]}}`, res.stdout)
5656 assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json")
5757
5858 f = versionFake()
M internal/app/cli_watch.go +1 -1
@@ -163,7 +163,7 @@ func newWatchCmd(env *cliEnv) *cobra.Command {
163163 if len(args) == 1 {
164164 topics = []string{"repo:" + args[0]}
165165 }
166- c, err := env.client(cmd, true)
166+ c, err := env.client(cmd)
167167 if err != nil {
168168 return err
169169 }
M internal/app/cli_web.go +5 -5
@@ -25,7 +25,7 @@ func newWebCmd(env *cliEnv) *cobra.Command {
2525 Use: "web",
2626 Short: "Sign the owner in to the web interface of a remote server",
2727 Long: "Manage the owner's browser sessions and passkeys on a remote server. They need the server's admin\n" +
28- "token, or a token that holds every scope and is not limited to repositories.",
28+ "token, or a token that holds every scope and is limited neither to repositories nor to branches.",
2929 }
3030 web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env), newWebPasskeyCmd(env))
3131 return web
@@ -45,7 +45,7 @@ func newWebLoginCmd(env *cliEnv) *cobra.Command {
4545 }
4646 asJSON := addJSONFlag(cmd)
4747 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
48- c, err := env.client(cmd, true)
48+ c, err := env.client(cmd)
4949 if err != nil {
5050 return err
5151 }
@@ -77,7 +77,7 @@ func newWebLogoutCmd(env *cliEnv) *cobra.Command {
7777 if !all {
7878 return errors.New("--all is required: this signs every browser out (one browser signs out from its page)")
7979 }
80- c, err := env.client(cmd, true)
80+ c, err := env.client(cmd)
8181 if err != nil {
8282 return err
8383 }
@@ -133,7 +133,7 @@ func newWebPasskeyListCmd(env *cliEnv) *cobra.Command {
133133 }
134134 asJSON := addJSONFlag(cmd)
135135 cmd.RunE = func(cmd *cobra.Command, _ []string) error {
136- c, err := env.client(cmd, true)
136+ c, err := env.client(cmd)
137137 if err != nil {
138138 return err
139139 }
@@ -188,7 +188,7 @@ func newWebPasskeyRemoveCmd(env *cliEnv) *cobra.Command {
188188 return ids, cobra.ShellCompDirectiveNoFileComp
189189 },
190190 RunE: func(cmd *cobra.Command, args []string) error {
191- c, err := env.client(cmd, true)
191+ c, err := env.client(cmd)
192192 if err != nil {
193193 return err
194194 }
A internal/app/e2e_branches_test.go +212 -0
@@ -0,0 +1,212 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package app
4+
5+import (
6+ "os"
7+ "path/filepath"
8+ "testing"
9+
10+ "connectrpc.com/connect"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+ "google.golang.org/protobuf/types/known/fieldmaskpb"
14+
15+ pb "bnema.dev/ermite-api/gen/ermite"
16+ "bnema.dev/ermite/internal/domain"
17+ "bnema.dev/ermite/internal/ports"
18+)
19+
20+// branchToken creates a stored token limited to branch prefixes, as root.
21+func (h *harness) branchToken(scopes []domain.Scope, prefixes []string, repos ...string) (secret, id string) {
22+ h.t.Helper()
23+ p := ports.CreateTokenParams{Label: "claude", Scopes: scopes, BranchPrefixes: prefixes}
24+ for _, r := range repos {
25+ p.Repos = append(p.Repos, h.name(r))
26+ }
27+ got, err := h.forge.Tokens.Create(h.ctx, domain.Root(), p)
28+ require.NoError(h.t, err)
29+ return got.Secret, got.Token.ID
30+}
31+
32+// AC5: a token limited to branch prefixes, through real git pushes and the API.
33+func TestAC5BranchLimitedToken(t *testing.T) {
34+ h := newHarness(t, harnessOpts{})
35+ wt := h.workTree("main", "a.txt")
36+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
37+ require.NoError(t, err, out)
38+ mainID := h.bareGit("proj", "rev-parse", "refs/heads/main")
39+
40+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite, domain.ScopeMRMerge),
41+ []string{"agent/claude/"}, "proj")
42+ remote := h.urlAuth(secret, "/proj.git")
43+ commit := func(file string) {
44+ require.NoError(t, os.WriteFile(filepath.Join(wt, file), []byte(file), 0o644))
45+ h.mustGit(wt, "add", file)
46+ h.mustGit(wt, "commit", "-q", "-m", file)
47+ }
48+ refs := func() string { return h.bareGit("proj", "for-each-ref", "--format=%(refname) %(objectname)") }
49+
50+ // It pushes, force-pushes and deletes under its prefix.
51+ h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/x")
52+ commit("x1")
53+ out, err = h.git(wt, "push", "-q", remote, "agent/claude/x")
54+ require.NoError(t, err, out)
55+ assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x"))
56+
57+ h.mustGit(wt, "reset", "-q", "--hard", "main")
58+ commit("x2")
59+ out, err = h.git(wt, "push", "-q", "--force", remote, "agent/claude/x")
60+ require.NoError(t, err, out)
61+ assert.Equal(t, h.mustGit(wt, "rev-parse", "HEAD")[:40], h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/x"))
62+
63+ h.mustGit(wt, "branch", "agent/claude/deep/y")
64+ out, err = h.git(wt, "push", "-q", remote, "agent/claude/deep/y")
65+ require.NoError(t, err, out)
66+ out, err = h.git(wt, "push", "-q", remote, ":refs/heads/agent/claude/deep/y")
67+ require.NoError(t, err, out)
68+ _, err = h.git(h.repoDir("proj"), "rev-parse", "--verify", "-q", "refs/heads/agent/claude/deep/y")
69+ require.Error(t, err, "deleted")
70+
71+ // Every other ref refuses the push, with a message naming it.
72+ before := refs()
73+ for _, tt := range []struct{ name, refspec, ref string }{
74+ {"main", "HEAD:refs/heads/main", "refs/heads/main"},
75+ {"force-push to main", "+HEAD:refs/heads/main", "refs/heads/main"},
76+ {"delete main", ":refs/heads/main", "refs/heads/main"},
77+ {"a new branch outside", "HEAD:refs/heads/other", "refs/heads/other"},
78+ {"the prefix without a name", "HEAD:refs/heads/agent/claude", "refs/heads/agent/claude"},
79+ {"a sibling sharing the text", "HEAD:refs/heads/agent/claudex/y", "refs/heads/agent/claudex/y"},
80+ {"a tag", "HEAD:refs/tags/v1", "refs/tags/v1"},
81+ {"a tag named like the prefix", "HEAD:refs/tags/agent/claude/v1", "refs/tags/agent/claude/v1"},
82+ {"a notes ref", "HEAD:refs/notes/commits", "refs/notes/commits"},
83+ {"a custom ref", "HEAD:refs/agent/claude/z", "refs/agent/claude/z"},
84+ } {
85+ out, err = h.git(wt, "push", remote, tt.refspec)
86+ require.Error(t, err, tt.name)
87+ assert.Contains(t, out, tt.ref+" is outside the branches this token may push (agent/claude/)", tt.name)
88+ assert.Equal(t, before, refs(), "%s: nothing moved", tt.name)
89+ }
90+
91+ // A mixed push is refused as a whole: the allowed ref does not move either.
92+ commit("x3")
93+ out, err = h.git(wt, "push", "--atomic", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/heads/main")
94+ require.Error(t, err)
95+ assert.Contains(t, out, "refs/heads/main is outside")
96+ assert.Equal(t, before, refs())
97+ out, err = h.git(wt, "push", remote, "HEAD:refs/heads/agent/claude/ok", "HEAD:refs/tags/v2")
98+ require.Error(t, err)
99+ assert.Contains(t, out, "refs/tags/v2 is outside")
100+ assert.Equal(t, before, refs(), "git receives both updates in one pack: pre-receive refuses both")
101+ assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main"))
102+
103+ // It cannot create a repository: not by push-to-create, not by the API.
104+ h.mustGit(wt, "checkout", "-q", "agent/claude/x")
105+ out, err = h.git(wt, "push", "-q", h.urlAuth(secret, "/brand-new.git"), "agent/claude/x")
106+ require.Error(t, err, out)
107+ assert.NoDirExists(t, h.repoDir("brand-new"))
108+ anyRepo, _ := h.branchToken(scopes(domain.ScopeRepoWrite), []string{"agent/claude/"}) // not limited to a repository
109+ out, err = h.git(wt, "push", "-q", h.urlAuth(anyRepo, "/brand-new.git"), "agent/claude/x")
110+ require.Error(t, err, out)
111+ assert.NoDirExists(t, h.repoDir("brand-new"))
112+ assert.Equal(t, 404, h.statusAuth("GET", "/brand-new.git/info/refs?service=git-receive-pack", anyRepo))
113+ _, err = h.api(anyRepo).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"}))
114+ assert.Equal(t, "permission_denied", codeName(err))
115+ assert.NoDirExists(t, h.repoDir("via-api"))
116+ // The same token without prefixes does both.
117+ free := h.token(scopes(domain.ScopeRepoWrite))
118+ _, err = h.api(free).repo.CreateRepo(h.ctx, connect.NewRequest(&pb.CreateRepoRequest{Name: "via-api"}))
119+ require.NoError(t, err)
120+
121+ // It cannot delete a repository, even with repo:admin.
122+ admin, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeRepoAdmin), []string{"agent/claude/"}, "via-api")
123+ _, err = h.api(admin).repo.DeleteRepo(h.ctx, connect.NewRequest(&pb.DeleteRepoRequest{Repo: "via-api"}))
124+ assert.Equal(t, "permission_denied", codeName(err))
125+ assert.DirExists(t, h.repoDir("via-api"))
126+ // ... nor change its settings (default branch, visibility, ...).
127+ _, err = h.api(admin).repo.UpdateRepo(h.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
128+ Repo: "via-api", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
129+ }))
130+ assert.Equal(t, "permission_denied", codeName(err))
131+ got, err := h.api(adminToken).repo.GetRepo(h.ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: "via-api"}))
132+ require.NoError(t, err)
133+ assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, got.Msg.GetRepo().GetVisibility())
134+
135+ // It never has owner rights, whatever its scopes.
136+ all, _ := h.branchToken(domain.AllScopes(), []string{"agent/claude/"})
137+ _, err = h.api(all).session.SignOutEverywhere(h.ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{}))
138+ assert.Equal(t, "permission_denied", codeName(err))
139+ _, err = h.api(all).session.CreateLoginLink(h.ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{}))
140+ assert.Equal(t, "permission_denied", codeName(err))
141+}
142+
143+// AC5: merge requests write branches: a limited token merges only into its prefixes.
144+func TestAC5BranchLimitedMerge(t *testing.T) {
145+ h := newHarness(t, harnessOpts{})
146+ wt := h.workTree("main", "a.txt")
147+ out, err := h.git(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "main")
148+ require.NoError(t, err, out)
149+ h.mustGit(wt, "checkout", "-q", "-b", "agent/claude/feat")
150+ h.writeFile(wt, "f.txt", []byte("f\n"))
151+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/proj.git"), "agent/claude/feat")
152+ feat := h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat")
153+ mainID := h.bareGit("proj", "rev-parse", "refs/heads/main")
154+
155+ secret, _ := h.branchToken(scopes(domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge), []string{"agent/claude/"}, "proj")
156+ c := h.api(secret)
157+ created, err := c.mr.CreateMergeRequest(h.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{
158+ Repo: "proj", Title: "feat", SourceBranch: "agent/claude/feat",
159+ }))
160+ require.NoError(t, err, "opening a request writes no ref")
161+ n := created.Msg.GetMergeRequest().GetNumber()
162+
163+ for _, del := range []bool{false, true} {
164+ _, err = c.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{
165+ Repo: "proj", Number: n, HeadId: feat, DeleteSourceBranch: del,
166+ }))
167+ assert.Equal(t, "permission_denied", codeName(err), "into main, delete=%v", del)
168+ }
169+ assert.Equal(t, mainID, h.bareGit("proj", "rev-parse", "refs/heads/main"))
170+ assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/agent/claude/feat"))
171+
172+ // The merge goes through for a token without prefixes.
173+ m := h.api(h.token(scopes(domain.ScopeRepoRead, domain.ScopeMRMerge)))
174+ _, err = m.mr.MergeMergeRequest(h.ctx, connect.NewRequest(&pb.MergeMergeRequestRequest{Repo: "proj", Number: n, HeadId: feat}))
175+ require.NoError(t, err)
176+ assert.Equal(t, feat, h.bareGit("proj", "rev-parse", "refs/heads/main"))
177+}
178+
179+// AC5: a limited token only creates tokens within its prefixes, over the API.
180+func TestAC5BranchLimitedTokenCreation(t *testing.T) {
181+ h := newHarness(t, harnessOpts{})
182+ secret, _ := h.branchToken(scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), []string{"agent/claude/"})
183+ c := h.api(secret)
184+ create := func(prefixes ...string) (*connect.Response[pb.CreateTokenResponse], error) {
185+ return c.token.CreateToken(h.ctx, connect.NewRequest(&pb.CreateTokenRequest{
186+ Label: "child", Scopes: []pb.Scope{pb.Scope_SCOPE_REPO_WRITE}, BranchPrefixes: prefixes,
187+ }))
188+ }
189+ _, err := create()
190+ assert.Equal(t, "permission_denied", codeName(err), "no prefixes")
191+ _, err = create("agent/")
192+ assert.Equal(t, "permission_denied", codeName(err), "wider")
193+ _, err = create("agent/claudex/")
194+ assert.Equal(t, "permission_denied", codeName(err), "sibling")
195+ _, err = create("agent")
196+ assert.Equal(t, "invalid_argument", codeName(err), "malformed")
197+ res, err := create("agent/claude/sub/")
198+ require.NoError(t, err)
199+ assert.Equal(t, []string{"agent/claude/sub/"}, res.Msg.GetToken().GetBranchPrefixes())
200+
201+ // The listing shows the prefixes.
202+ list, err := h.api(adminToken).token.ListTokens(h.ctx, connect.NewRequest(&pb.ListTokensRequest{}))
203+ require.NoError(t, err)
204+ var seen int
205+ for _, tk := range list.Msg.GetTokens() {
206+ if tk.GetLabel() == "child" {
207+ assert.Equal(t, []string{"agent/claude/sub/"}, tk.GetBranchPrefixes())
208+ seen++
209+ }
210+ }
211+ assert.Equal(t, 1, seen)
212+}
M internal/app/e2e_helpers_test.go +11 -1
@@ -55,6 +55,12 @@ type harnessOpts struct {
5555 // newHarness starts a forge. The data directory is short: the hook socket
5656 // lives in it and Unix socket paths are limited to about 100 bytes.
5757 func newHarness(t *testing.T, o harnessOpts) *harness {
58+ t.Helper()
59+ return newHarnessWith(t, o, nil)
60+}
61+
62+// newHarnessWith is newHarness with a change of the wiring options.
63+func newHarnessWith(t *testing.T, o harnessOpts, tweak func(*options)) *harness {
5864 t.Helper()
5965 gitBin := requireGit(t)
6066 data, err := os.MkdirTemp("", "ermite")
@@ -90,7 +96,11 @@ func newHarness(t *testing.T, o harnessOpts) *harness {
9096 if hookExe == "" {
9197 hookExe = binary(t)
9298 }
93- f, err := newForge(ctx, cfg, options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery})
99+ opts := options{hookExe: hookExe, maintenanceDelay: o.maintenance, maxBodyBytes: o.maxBody, eventBuffer: o.eventBuffer, revalidateEvery: o.revalidateEvery}
100+ if tweak != nil {
101+ tweak(&opts)
102+ }
103+ f, err := newForge(ctx, cfg, opts)
94104 require.NoError(t, err)
95105
96106 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"
@@ -49,6 +50,9 @@ type options struct {
4950 eventBuffer int
5051 // revalidateEvery overrides usecase.DefaultRevalidateEvery when not zero.
5152 revalidateEvery time.Duration
53+ // ci overrides the settings of the CI engine (tests shorten its
54+ // durations).
55+ ci func(*usecase.CIConfig)
5256 }
5357
5458 // forge is the wired server: every adapter and use case, ready to serve.
@@ -77,8 +81,11 @@ type forge struct {
7781 // Passkeys is nil when passkeys are not available (passkeysAvailable).
7882 Passkeys *usecase.Passkeys
7983 EventLog *usecase.Events
80- API *rpc.API
81- Bus ports.EventBus
84+ // CI is the CI engine: workflow trigger, planner, runner protocol and the
85+ // owner side. It starts with the server and stops with it.
86+ CI *usecase.CI
87+ API *rpc.API
88+ Bus ports.EventBus
8289 }
8390
8491 // newSigningSeed makes the seed of a new ed25519 signing key.
@@ -185,6 +192,21 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
185192 }
186193 log.Info().Str("reader", cfg.GitReader).Msg("git reader selected")
187194 f.Browse = usecase.NewBrowse(store, reader)
195+
196+ // CI (ADR 0025): the engine reads workflows through the reader, and plans
197+ // them in the background; a push only queues its refs.
198+ ciCfg := usecase.CIConfig{
199+ Store: store.CI(), Logs: cilogs.New(cilogs.Dir(cfg.DataDir)), Meta: store, Repos: git, Reader: reader,
200+ Git: git, Diff: git, Bus: f.Bus, Clock: clock, Auth: f.Auth,
201+ Owner: cfg.Owner, PublicURL: cfg.PublicURL, Retention: cfg.CIRetention,
202+ }
203+ if opts.ci != nil {
204+ opts.ci(&ciCfg)
205+ }
206+ f.CI = usecase.NewCI(ctx, ciCfg)
207+ f.Pushes.WithWorkflowTrigger(f.CI)
208+ f.Repos.WithCIRunCleaner(f.CI)
209+ f.CI.Start()
188210 f.EventLog = usecase.NewEvents(ctx, f.Repos, f.Bus, clock, usecase.EventsConfig{
189211 Size: opts.eventBuffer, Auth: f.Auth, RevalidateEvery: opts.revalidateEvery,
190212 })
@@ -203,6 +225,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
203225 f.MergeRequests = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
204226 Meta: store, Store: store.MergeRequests(), Repos: git, Git: git, Signer: signer,
205227 Committer: committer, Bus: f.Bus, Clock: clock, Maint: f.maint,
228+ CI: f.CI, Checks: f.CI,
206229 })
207230 log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready")
208231
@@ -243,6 +266,8 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
243266 SigningKey: f.SigningKey,
244267 Sessions: f.Sessions,
245268 Passkeys: apiPasskeys,
269+ Runner: f.CI,
270+ Runs: f.CI,
246271 Version: serverVersion(),
247272 TrustedProxies: cfg.TrustedProxies,
248273 Limiter: limiter,
@@ -320,6 +345,9 @@ func (f *forge) Close(ctx context.Context) {
320345 if f.hooks != nil {
321346 f.hooks.Close(ctx)
322347 }
348+ if f.CI != nil {
349+ f.CI.Close(ctx)
350+ }
323351 if f.EventLog != nil {
324352 f.EventLog.Close(ctx)
325353 }
M internal/app/root.go +9 -3
@@ -18,15 +18,21 @@ func newRootCmdWith(env *cliEnv) *cobra.Command {
1818 Short: "A single-user Git forge",
1919 Long: "ermite is a single-user Git forge: `ermite serve` runs the server, the other commands\n" +
2020 "drive a running server over its API.\n\n" +
21- "Remote commands read ERMITE_URL (the server address, http:// or https://) and\n" +
22- "ERMITE_TOKEN (a bearer token, see `ermite token create`) from the environment.",
21+ "Remote commands need the server address (http:// or https://) and, except for public reads,\n" +
22+ "a bearer token (see `ermite token create`). Each comes from, in order:\n\n" +
23+ " ERMITE_URL / ERMITE_TOKEN the environment\n" +
24+ " url / token.cmd in ~/.config/ermite/config the config file (ERMITE_CONFIG selects another;\n `ermite config show` prints its path)\n\n" +
25+ "token.cmd is a command line that prints the token, run only when a command needs it, so the\n" +
26+ "token stays in your secret manager (pass, gopass, op, bw, secret-tool…), never in the file.\n" +
27+ "See `ermite config --help`.",
2328 SilenceUsage: true,
2429 SilenceErrors: true, // cmd/ermite prints the error once
2530 }
2631 root.AddCommand(
2732 newServeCmd(), newHookCmd(),
2833 newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env),
29- newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env),
34+ newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), newConfigCmd(env),
35+ newRunCmd(env), newRunnerCmd(env),
3036 )
3137 return root
3238 }
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
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </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-code.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </header>
@@ -380,6 +386,12 @@
380386 <span class="k">section</span>
381387 </a>
382388 </li>
389+<li data-k="section actions">
390+<a href="/files/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="/files/tree/feature/x" tabindex="-1">
385397 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-empty.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </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-image.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </header>
@@ -312,6 +318,12 @@
312318 <span class="k">section</span>
313319 </a>
314320 </li>
321+<li data-k="section actions">
322+<a href="/files/actions" tabindex="-1">
323+<span class="lbl">actions</span>
324+<span class="k">section</span>
325+</a>
326+</li>
315327 <li data-k="branch feature/x">
316328 <a href="/files/tree/feature/x" tabindex="-1">
317329 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-markdown.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -88,6 +90,10 @@
8890 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8991 </a>
9092 </li>
93+<li>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
95+</a>
96+</li>
9197 </ul>
9298 </nav>
9399 </header>
@@ -377,6 +383,12 @@
377383 <span class="k">section</span>
378384 </a>
379385 </li>
386+<li data-k="section actions">
387+<a href="/files/actions" tabindex="-1">
388+<span class="lbl">actions</span>
389+<span class="k">section</span>
390+</a>
391+</li>
380392 <li data-k="branch feature/x">
381393 <a href="/files/tree/feature/x" tabindex="-1">
382394 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-svg.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </header>
@@ -335,6 +341,12 @@
335341 <span class="k">section</span>
336342 </a>
337343 </li>
344+<li data-k="section actions">
345+<a href="/files/actions" tabindex="-1">
346+<span class="lbl">actions</span>
347+<span class="k">section</span>
348+</a>
349+</li>
338350 <li data-k="branch feature/x">
339351 <a href="/files/tree/feature/x" tabindex="-1">
340352 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-text.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </header>
@@ -330,6 +336,12 @@
330336 <span class="k">section</span>
331337 </a>
332338 </li>
339+<li data-k="section actions">
340+<a href="/files/actions" tabindex="-1">
341+<span class="lbl">actions</span>
342+<span class="k">section</span>
343+</a>
344+</li>
333345 <li data-k="branch feature/x">
334346 <a href="/files/tree/feature/x" tabindex="-1">
335347 <span class="lbl">feature/x</span>
M internal/app/testdata/web/blob-too-large.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </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-trojan.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </header>
@@ -377,6 +383,12 @@
377383 <span class="k">section</span>
378384 </a>
379385 </li>
386+<li data-k="section actions">
387+<a href="/files/actions" tabindex="-1">
388+<span class="lbl">actions</span>
389+<span class="k">section</span>
390+</a>
391+</li>
380392 <li data-k="branch feature/x">
381393 <a href="/files/tree/feature/x" tabindex="-1">
382394 <span class="lbl">feature/x</span>
M internal/app/testdata/web/commit-big.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -347,6 +353,12 @@
347353 <span class="k">section</span>
348354 </a>
349355 </li>
356+<li data-k="section actions">
357+<a href="/diffs/actions" tabindex="-1">
358+<span class="lbl">actions</span>
359+<span class="k">section</span>
360+</a>
361+</li>
350362 <li data-k="branch main">
351363 <a href="/diffs/tree/main" tabindex="-1">
352364 <span class="lbl">main</span>
M internal/app/testdata/web/commit-binary.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -342,6 +348,12 @@
342348 <span class="k">section</span>
343349 </a>
344350 </li>
351+<li data-k="section actions">
352+<a href="/diffs/actions" tabindex="-1">
353+<span class="lbl">actions</span>
354+<span class="k">section</span>
355+</a>
356+</li>
345357 <li data-k="branch main">
346358 <a href="/diffs/tree/main" tabindex="-1">
347359 <span class="lbl">main</span>
M internal/app/testdata/web/commit-merge.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -381,6 +387,12 @@
381387 <span class="k">section</span>
382388 </a>
383389 </li>
390+<li data-k="section actions">
391+<a href="/diffs/actions" tabindex="-1">
392+<span class="lbl">actions</span>
393+<span class="k">section</span>
394+</a>
395+</li>
384396 <li data-k="branch main">
385397 <a href="/diffs/tree/main" tabindex="-1">
386398 <span class="lbl">main</span>
M internal/app/testdata/web/commit-modify-split.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -502,6 +508,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
502508 <span class="k">section</span>
503509 </a>
504510 </li>
511+<li data-k="section actions">
512+<a href="/diffs/actions" tabindex="-1">
513+<span class="lbl">actions</span>
514+<span class="k">section</span>
515+</a>
516+</li>
505517 <li data-k="branch main">
506518 <a href="/diffs/tree/main" tabindex="-1">
507519 <span class="lbl">main</span>
M internal/app/testdata/web/commit-modify.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -502,6 +508,12 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
502508 <span class="k">section</span>
503509 </a>
504510 </li>
511+<li data-k="section actions">
512+<a href="/diffs/actions" tabindex="-1">
513+<span class="lbl">actions</span>
514+<span class="k">section</span>
515+</a>
516+</li>
505517 <li data-k="branch main">
506518 <a href="/diffs/tree/main" tabindex="-1">
507519 <span class="lbl">main</span>
M internal/app/testdata/web/commit-rename.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -407,6 +413,12 @@
407413 <span class="k">section</span>
408414 </a>
409415 </li>
416+<li data-k="section actions">
417+<a href="/diffs/actions" tabindex="-1">
418+<span class="lbl">actions</span>
419+<span class="k">section</span>
420+</a>
421+</li>
410422 <li data-k="branch main">
411423 <a href="/diffs/tree/main" tabindex="-1">
412424 <span class="lbl">main</span>
M internal/app/testdata/web/commit-root.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -723,6 +729,12 @@
723729 <span class="k">section</span>
724730 </a>
725731 </li>
732+<li data-k="section actions">
733+<a href="/diffs/actions" tabindex="-1">
734+<span class="lbl">actions</span>
735+<span class="k">section</span>
736+</a>
737+</li>
726738 <li data-k="branch main">
727739 <a href="/diffs/tree/main" tabindex="-1">
728740 <span class="lbl">main</span>
M internal/app/testdata/web/commit-trojan.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/SHA40">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -516,6 +522,12 @@
516522 <span class="k">section</span>
517523 </a>
518524 </li>
525+<li data-k="section actions">
526+<a href="/diffs/actions" tabindex="-1">
527+<span class="lbl">actions</span>
528+<span class="k">section</span>
529+</a>
530+</li>
519531 <li data-k="branch main">
520532 <a href="/diffs/tree/main" tabindex="-1">
521533 <span class="lbl">main</span>
M internal/app/testdata/web/commits-page2.html +16 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -105,6 +111,8 @@
105111 <td class="c-subject">
106112 <a href="/long/commit/SHA40">commit number 1</a>
107113 </td>
114+<td class="c-ck">
115+</td>
108116 <td class="c-who dim">T</td>
109117 <td class="c-when nowrap dim">
110118 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -117,6 +125,8 @@
117125 <td class="c-subject">
118126 <a href="/long/commit/SHA40">commit number 0</a>
119127 </td>
128+<td class="c-ck">
129+</td>
120130 <td class="c-who dim">T</td>
121131 <td class="c-when nowrap dim">
122132 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -329,6 +339,12 @@
329339 <span class="k">section</span>
330340 </a>
331341 </li>
342+<li data-k="section actions">
343+<a href="/long/actions" tabindex="-1">
344+<span class="lbl">actions</span>
345+<span class="k">section</span>
346+</a>
347+</li>
332348 <li data-k="branch main">
333349 <a href="/long/tree/main" tabindex="-1">
334350 <span class="lbl">main</span>
M internal/app/testdata/web/commits-path.html +16 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </header>
@@ -109,6 +115,8 @@
109115 <td class="c-subject">
110116 <a href="/long/commit/SHA40">commit number 30</a>
111117 </td>
118+<td class="c-ck">
119+</td>
112120 <td class="c-who dim">T</td>
113121 <td class="c-when nowrap dim">
114122 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -130,6 +138,8 @@
130138 <td class="c-subject">
131139 <a href="/long/commit/SHA40">commit number 3</a>
132140 </td>
141+<td class="c-ck">
142+</td>
133143 <td class="c-who dim">T</td>
134144 <td class="c-when nowrap dim">
135145 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -344,6 +354,12 @@
344354 <span class="k">section</span>
345355 </a>
346356 </li>
357+<li data-k="section actions">
358+<a href="/long/actions" tabindex="-1">
359+<span class="lbl">actions</span>
360+<span class="k">section</span>
361+</a>
362+</li>
347363 <li data-k="branch main">
348364 <a href="/long/tree/main" tabindex="-1">
349365 <span class="lbl">main</span>
M internal/app/testdata/web/commits-root.html +113 -1
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/long/paths/SHA40" data-blob="/long/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/long/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/long/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -108,6 +114,8 @@
108114 <a href="/long/commit/SHA40">commit number 51</a>
109115 <span class="pill">main</span>
110116 </td>
117+<td class="c-ck">
118+</td>
111119 <td class="c-who dim">T</td>
112120 <td class="c-when nowrap dim">
113121 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -120,6 +128,8 @@
120128 <td class="c-subject">
121129 <a href="/long/commit/SHA40">commit number 50</a>
122130 </td>
131+<td class="c-ck">
132+</td>
123133 <td class="c-who dim">T</td>
124134 <td class="c-when nowrap dim">
125135 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -141,6 +151,8 @@
141151 <td class="c-subject">
142152 <a href="/long/commit/SHA40">commit number 49</a>
143153 </td>
154+<td class="c-ck">
155+</td>
144156 <td class="c-who dim">T</td>
145157 <td class="c-when nowrap dim">
146158 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -153,6 +165,8 @@
153165 <td class="c-subject">
154166 <a href="/long/commit/SHA40">commit number 48</a>
155167 </td>
168+<td class="c-ck">
169+</td>
156170 <td class="c-who dim">T</td>
157171 <td class="c-when nowrap dim">
158172 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -174,6 +188,8 @@
174188 <td class="c-subject">
175189 <a href="/long/commit/SHA40">commit number 47</a>
176190 </td>
191+<td class="c-ck">
192+</td>
177193 <td class="c-who dim">T</td>
178194 <td class="c-when nowrap dim">
179195 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -186,6 +202,8 @@
186202 <td class="c-subject">
187203 <a href="/long/commit/SHA40">commit number 46</a>
188204 </td>
205+<td class="c-ck">
206+</td>
189207 <td class="c-who dim">T</td>
190208 <td class="c-when nowrap dim">
191209 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -207,6 +225,8 @@
207225 <td class="c-subject">
208226 <a href="/long/commit/SHA40">commit number 45</a>
209227 </td>
228+<td class="c-ck">
229+</td>
210230 <td class="c-who dim">T</td>
211231 <td class="c-when nowrap dim">
212232 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -219,6 +239,8 @@
219239 <td class="c-subject">
220240 <a href="/long/commit/SHA40">commit number 44</a>
221241 </td>
242+<td class="c-ck">
243+</td>
222244 <td class="c-who dim">T</td>
223245 <td class="c-when nowrap dim">
224246 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -240,6 +262,8 @@
240262 <td class="c-subject">
241263 <a href="/long/commit/SHA40">commit number 43</a>
242264 </td>
265+<td class="c-ck">
266+</td>
243267 <td class="c-who dim">T</td>
244268 <td class="c-when nowrap dim">
245269 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -252,6 +276,8 @@
252276 <td class="c-subject">
253277 <a href="/long/commit/SHA40">commit number 42</a>
254278 </td>
279+<td class="c-ck">
280+</td>
255281 <td class="c-who dim">T</td>
256282 <td class="c-when nowrap dim">
257283 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -273,6 +299,8 @@
273299 <td class="c-subject">
274300 <a href="/long/commit/SHA40">commit number 41</a>
275301 </td>
302+<td class="c-ck">
303+</td>
276304 <td class="c-who dim">T</td>
277305 <td class="c-when nowrap dim">
278306 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -285,6 +313,8 @@
285313 <td class="c-subject">
286314 <a href="/long/commit/SHA40">commit number 40</a>
287315 </td>
316+<td class="c-ck">
317+</td>
288318 <td class="c-who dim">T</td>
289319 <td class="c-when nowrap dim">
290320 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -306,6 +336,8 @@
306336 <td class="c-subject">
307337 <a href="/long/commit/SHA40">commit number 39</a>
308338 </td>
339+<td class="c-ck">
340+</td>
309341 <td class="c-who dim">T</td>
310342 <td class="c-when nowrap dim">
311343 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -318,6 +350,8 @@
318350 <td class="c-subject">
319351 <a href="/long/commit/SHA40">commit number 38</a>
320352 </td>
353+<td class="c-ck">
354+</td>
321355 <td class="c-who dim">T</td>
322356 <td class="c-when nowrap dim">
323357 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -339,6 +373,8 @@
339373 <td class="c-subject">
340374 <a href="/long/commit/SHA40">commit number 37</a>
341375 </td>
376+<td class="c-ck">
377+</td>
342378 <td class="c-who dim">T</td>
343379 <td class="c-when nowrap dim">
344380 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -351,6 +387,8 @@
351387 <td class="c-subject">
352388 <a href="/long/commit/SHA40">commit number 36</a>
353389 </td>
390+<td class="c-ck">
391+</td>
354392 <td class="c-who dim">T</td>
355393 <td class="c-when nowrap dim">
356394 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -372,6 +410,8 @@
372410 <td class="c-subject">
373411 <a href="/long/commit/SHA40">commit number 35</a>
374412 </td>
413+<td class="c-ck">
414+</td>
375415 <td class="c-who dim">T</td>
376416 <td class="c-when nowrap dim">
377417 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -384,6 +424,8 @@
384424 <td class="c-subject">
385425 <a href="/long/commit/SHA40">commit number 34</a>
386426 </td>
427+<td class="c-ck">
428+</td>
387429 <td class="c-who dim">T</td>
388430 <td class="c-when nowrap dim">
389431 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -405,6 +447,8 @@
405447 <td class="c-subject">
406448 <a href="/long/commit/SHA40">commit number 33</a>
407449 </td>
450+<td class="c-ck">
451+</td>
408452 <td class="c-who dim">T</td>
409453 <td class="c-when nowrap dim">
410454 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -417,6 +461,8 @@
417461 <td class="c-subject">
418462 <a href="/long/commit/SHA40">commit number 32</a>
419463 </td>
464+<td class="c-ck">
465+</td>
420466 <td class="c-who dim">T</td>
421467 <td class="c-when nowrap dim">
422468 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -438,6 +484,8 @@
438484 <td class="c-subject">
439485 <a href="/long/commit/SHA40">commit number 31</a>
440486 </td>
487+<td class="c-ck">
488+</td>
441489 <td class="c-who dim">T</td>
442490 <td class="c-when nowrap dim">
443491 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -450,6 +498,8 @@
450498 <td class="c-subject">
451499 <a href="/long/commit/SHA40">commit number 30</a>
452500 </td>
501+<td class="c-ck">
502+</td>
453503 <td class="c-who dim">T</td>
454504 <td class="c-when nowrap dim">
455505 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -471,6 +521,8 @@
471521 <td class="c-subject">
472522 <a href="/long/commit/SHA40">commit number 29</a>
473523 </td>
524+<td class="c-ck">
525+</td>
474526 <td class="c-who dim">T</td>
475527 <td class="c-when nowrap dim">
476528 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -483,6 +535,8 @@
483535 <td class="c-subject">
484536 <a href="/long/commit/SHA40">commit number 28</a>
485537 </td>
538+<td class="c-ck">
539+</td>
486540 <td class="c-who dim">T</td>
487541 <td class="c-when nowrap dim">
488542 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -504,6 +558,8 @@
504558 <td class="c-subject">
505559 <a href="/long/commit/SHA40">commit number 27</a>
506560 </td>
561+<td class="c-ck">
562+</td>
507563 <td class="c-who dim">T</td>
508564 <td class="c-when nowrap dim">
509565 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -516,6 +572,8 @@
516572 <td class="c-subject">
517573 <a href="/long/commit/SHA40">commit number 26</a>
518574 </td>
575+<td class="c-ck">
576+</td>
519577 <td class="c-who dim">T</td>
520578 <td class="c-when nowrap dim">
521579 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -537,6 +595,8 @@
537595 <td class="c-subject">
538596 <a href="/long/commit/SHA40">commit number 25</a>
539597 </td>
598+<td class="c-ck">
599+</td>
540600 <td class="c-who dim">T</td>
541601 <td class="c-when nowrap dim">
542602 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -549,6 +609,8 @@
549609 <td class="c-subject">
550610 <a href="/long/commit/SHA40">commit number 24</a>
551611 </td>
612+<td class="c-ck">
613+</td>
552614 <td class="c-who dim">T</td>
553615 <td class="c-when nowrap dim">
554616 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -570,6 +632,8 @@
570632 <td class="c-subject">
571633 <a href="/long/commit/SHA40">commit number 23</a>
572634 </td>
635+<td class="c-ck">
636+</td>
573637 <td class="c-who dim">T</td>
574638 <td class="c-when nowrap dim">
575639 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -582,6 +646,8 @@
582646 <td class="c-subject">
583647 <a href="/long/commit/SHA40">commit number 22</a>
584648 </td>
649+<td class="c-ck">
650+</td>
585651 <td class="c-who dim">T</td>
586652 <td class="c-when nowrap dim">
587653 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -603,6 +669,8 @@
603669 <td class="c-subject">
604670 <a href="/long/commit/SHA40">commit number 21</a>
605671 </td>
672+<td class="c-ck">
673+</td>
606674 <td class="c-who dim">T</td>
607675 <td class="c-when nowrap dim">
608676 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -615,6 +683,8 @@
615683 <td class="c-subject">
616684 <a href="/long/commit/SHA40">commit number 20</a>
617685 </td>
686+<td class="c-ck">
687+</td>
618688 <td class="c-who dim">T</td>
619689 <td class="c-when nowrap dim">
620690 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -636,6 +706,8 @@
636706 <td class="c-subject">
637707 <a href="/long/commit/SHA40">commit number 19</a>
638708 </td>
709+<td class="c-ck">
710+</td>
639711 <td class="c-who dim">T</td>
640712 <td class="c-when nowrap dim">
641713 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -648,6 +720,8 @@
648720 <td class="c-subject">
649721 <a href="/long/commit/SHA40">commit number 18</a>
650722 </td>
723+<td class="c-ck">
724+</td>
651725 <td class="c-who dim">T</td>
652726 <td class="c-when nowrap dim">
653727 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -669,6 +743,8 @@
669743 <td class="c-subject">
670744 <a href="/long/commit/SHA40">commit number 17</a>
671745 </td>
746+<td class="c-ck">
747+</td>
672748 <td class="c-who dim">T</td>
673749 <td class="c-when nowrap dim">
674750 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -681,6 +757,8 @@
681757 <td class="c-subject">
682758 <a href="/long/commit/SHA40">commit number 16</a>
683759 </td>
760+<td class="c-ck">
761+</td>
684762 <td class="c-who dim">T</td>
685763 <td class="c-when nowrap dim">
686764 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -702,6 +780,8 @@
702780 <td class="c-subject">
703781 <a href="/long/commit/SHA40">commit number 15</a>
704782 </td>
783+<td class="c-ck">
784+</td>
705785 <td class="c-who dim">T</td>
706786 <td class="c-when nowrap dim">
707787 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -714,6 +794,8 @@
714794 <td class="c-subject">
715795 <a href="/long/commit/SHA40">commit number 14</a>
716796 </td>
797+<td class="c-ck">
798+</td>
717799 <td class="c-who dim">T</td>
718800 <td class="c-when nowrap dim">
719801 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -735,6 +817,8 @@
735817 <td class="c-subject">
736818 <a href="/long/commit/SHA40">commit number 13</a>
737819 </td>
820+<td class="c-ck">
821+</td>
738822 <td class="c-who dim">T</td>
739823 <td class="c-when nowrap dim">
740824 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -747,6 +831,8 @@
747831 <td class="c-subject">
748832 <a href="/long/commit/SHA40">commit number 12</a>
749833 </td>
834+<td class="c-ck">
835+</td>
750836 <td class="c-who dim">T</td>
751837 <td class="c-when nowrap dim">
752838 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -768,6 +854,8 @@
768854 <td class="c-subject">
769855 <a href="/long/commit/SHA40">commit number 11</a>
770856 </td>
857+<td class="c-ck">
858+</td>
771859 <td class="c-who dim">T</td>
772860 <td class="c-when nowrap dim">
773861 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -780,6 +868,8 @@
780868 <td class="c-subject">
781869 <a href="/long/commit/SHA40">commit number 10</a>
782870 </td>
871+<td class="c-ck">
872+</td>
783873 <td class="c-who dim">T</td>
784874 <td class="c-when nowrap dim">
785875 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -801,6 +891,8 @@
801891 <td class="c-subject">
802892 <a href="/long/commit/SHA40">commit number 9</a>
803893 </td>
894+<td class="c-ck">
895+</td>
804896 <td class="c-who dim">T</td>
805897 <td class="c-when nowrap dim">
806898 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -813,6 +905,8 @@
813905 <td class="c-subject">
814906 <a href="/long/commit/SHA40">commit number 8</a>
815907 </td>
908+<td class="c-ck">
909+</td>
816910 <td class="c-who dim">T</td>
817911 <td class="c-when nowrap dim">
818912 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -834,6 +928,8 @@
834928 <td class="c-subject">
835929 <a href="/long/commit/SHA40">commit number 7</a>
836930 </td>
931+<td class="c-ck">
932+</td>
837933 <td class="c-who dim">T</td>
838934 <td class="c-when nowrap dim">
839935 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -846,6 +942,8 @@
846942 <td class="c-subject">
847943 <a href="/long/commit/SHA40">commit number 6</a>
848944 </td>
945+<td class="c-ck">
946+</td>
849947 <td class="c-who dim">T</td>
850948 <td class="c-when nowrap dim">
851949 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -867,6 +965,8 @@
867965 <td class="c-subject">
868966 <a href="/long/commit/SHA40">commit number 5</a>
869967 </td>
968+<td class="c-ck">
969+</td>
870970 <td class="c-who dim">T</td>
871971 <td class="c-when nowrap dim">
872972 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -879,6 +979,8 @@
879979 <td class="c-subject">
880980 <a href="/long/commit/SHA40">commit number 4</a>
881981 </td>
982+<td class="c-ck">
983+</td>
882984 <td class="c-who dim">T</td>
883985 <td class="c-when nowrap dim">
884986 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -900,6 +1002,8 @@
9001002 <td class="c-subject">
9011003 <a href="/long/commit/SHA40">commit number 3</a>
9021004 </td>
1005+<td class="c-ck">
1006+</td>
9031007 <td class="c-who dim">T</td>
9041008 <td class="c-when nowrap dim">
9051009 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -912,6 +1016,8 @@
9121016 <td class="c-subject">
9131017 <a href="/long/commit/SHA40">commit number 2</a>
9141018 </td>
1019+<td class="c-ck">
1020+</td>
9151021 <td class="c-who dim">T</td>
9161022 <td class="c-when nowrap dim">
9171023 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -961,7 +1067,7 @@
9611067 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
9621068 htmx:sse:error -> ermiteLive.error(event.detail.status);
9631069 htmx:sse:before:connection -> ermiteLive.gate(event);
964- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
1070+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
9651071 gap -> ermiteLive.reload()">
9661072 </span>
9671073 </div>
@@ -1133,6 +1239,12 @@
11331239 <span class="k">section</span>
11341240 </a>
11351241 </li>
1242+<li data-k="section actions">
1243+<a href="/long/actions" tabindex="-1">
1244+<span class="lbl">actions</span>
1245+<span class="k">section</span>
1246+</a>
1247+</li>
11361248 <li data-k="branch main">
11371249 <a href="/long/tree/main" tabindex="-1">
11381250 <span class="lbl">main</span>
M internal/app/testdata/web/commits-small.html +29 -1
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/diffs/paths/SHA40" data-blob="/diffs/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/diffs/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/diffs/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -108,6 +114,8 @@
108114 <a href="/diffs/commit/SHA40">add a very large file</a>
109115 <span class="pill">main</span>
110116 </td>
117+<td class="c-ck">
118+</td>
111119 <td class="c-who dim">T</td>
112120 <td class="c-when nowrap dim">
113121 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -129,6 +137,8 @@
129137 <td class="c-subject">
130138 <a href="/diffs/commit/SHA40">add evil.go</a>
131139 </td>
140+<td class="c-ck">
141+</td>
132142 <td class="c-who dim">T</td>
133143 <td class="c-when nowrap dim">
134144 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -151,6 +161,8 @@
151161 <a href="/diffs/commit/SHA40">merge the side branch</a>
152162 <span class="pill">merge</span>
153163 </td>
164+<td class="c-ck">
165+</td>
154166 <td class="c-who dim">T</td>
155167 <td class="c-when nowrap dim">
156168 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -173,6 +185,8 @@
173185 <a href="/diffs/commit/SHA40">work on the side</a>
174186 <span class="pill">side</span>
175187 </td>
188+<td class="c-ck">
189+</td>
176190 <td class="c-who dim">T</td>
177191 <td class="c-when nowrap dim">
178192 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -194,6 +208,8 @@
194208 <td class="c-subject">
195209 <a href="/diffs/commit/SHA40">change a binary file</a>
196210 </td>
211+<td class="c-ck">
212+</td>
197213 <td class="c-who dim">T</td>
198214 <td class="c-when nowrap dim">
199215 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -215,6 +231,8 @@
215231 <td class="c-subject">
216232 <a href="/diffs/commit/SHA40">rename a file and delete another</a>
217233 </td>
234+<td class="c-ck">
235+</td>
218236 <td class="c-who dim">T</td>
219237 <td class="c-when nowrap dim">
220238 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -238,6 +256,8 @@
238256 <span class="pill tag">
239257 <span class="sr">tag </span>v1.0.0</span>
240258 </td>
259+<td class="c-ck">
260+</td>
241261 <td class="c-who dim">T</td>
242262 <td class="c-when nowrap dim">
243263 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -259,6 +279,8 @@
259279 <td class="c-subject">
260280 <a href="/diffs/commit/SHA40">root: the first files</a>
261281 </td>
282+<td class="c-ck">
283+</td>
262284 <td class="c-who dim">T</td>
263285 <td class="c-when nowrap dim">
264286 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -306,7 +328,7 @@
306328 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
307329 htmx:sse:error -> ermiteLive.error(event.detail.status);
308330 htmx:sse:before:connection -> ermiteLive.gate(event);
309- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
331+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
310332 gap -> ermiteLive.reload()">
311333 </span>
312334 </div>
@@ -486,6 +508,12 @@
486508 <span class="k">section</span>
487509 </a>
488510 </li>
511+<li data-k="section actions">
512+<a href="/diffs/actions" tabindex="-1">
513+<span class="lbl">actions</span>
514+<span class="k">section</span>
515+</a>
516+</li>
489517 <li data-k="branch main">
490518 <a href="/diffs/tree/main" tabindex="-1">
491519 <span class="lbl">main</span>
M internal/app/testdata/web/empty.html +2 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="" data-sel="" data-nav="">
2628 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/home.html +3 -1
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="" data-sel="" data-nav="">
2628 <a class="skip" href="#main">skip to content</a>
@@ -115,7 +117,7 @@
115117 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
116118 htmx:sse:error -> ermiteLive.error(event.detail.status);
117119 htmx:sse:before:connection -> ermiteLive.gate(event);
118- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
120+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
119121 gap -> ermiteLive.reload()">
120122 </span>
121123 </div>
M internal/app/testdata/web/not-found-page.html +2 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="" data-sel="" data-nav="">
2628 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/not-found.html +2 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="" data-sha="" data-sel="" data-nav="">
2628 <a class="skip" href="#main">skip to content</a>
M internal/app/testdata/web/refs.html +13 -1
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -79,6 +81,10 @@
7981 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8082 </a>
8183 </li>
84+<li>
85+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
86+</a>
87+</li>
8288 </ul>
8389 </nav>
8490 </header>
@@ -198,7 +204,7 @@
198204 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
199205 htmx:sse:error -> ermiteLive.error(event.detail.status);
200206 htmx:sse:before:connection -> ermiteLive.gate(event);
201- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
207+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
202208 gap -> ermiteLive.reload()">
203209 </span>
204210 </div>
@@ -382,6 +388,12 @@
382388 <span class="k">section</span>
383389 </a>
384390 </li>
391+<li data-k="section actions">
392+<a href="/demo/actions" tabindex="-1">
393+<span class="lbl">actions</span>
394+<span class="k">section</span>
395+</a>
396+</li>
385397 <li data-k="branch feature/x">
386398 <a href="/demo/tree/feature/x" tabindex="-1">
387399 <span class="lbl">feature/x</span>
M internal/app/testdata/web/summary.html +17 -1
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/demo/paths/SHA40" data-blob="/demo/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -76,6 +78,10 @@
7678 <a href="/demo/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
7779 </a>
7880 </li>
81+<li>
82+<a href="/demo/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
83+</a>
84+</li>
7985 </ul>
8086 </nav>
8187 </header>
@@ -168,6 +174,8 @@
168174 <span class="pill tag">
169175 <span class="sr">tag </span>v0.2.0</span>
170176 </td>
177+<td class="c-ck">
178+</td>
171179 <td class="c-who dim">T</td>
172180 <td class="c-when nowrap dim">
173181 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -182,6 +190,8 @@
182190 <span class="pill tag">
183191 <span class="sr">tag </span>v0.1.0</span>
184192 </td>
193+<td class="c-ck">
194+</td>
185195 <td class="c-who dim">T</td>
186196 <td class="c-when nowrap dim">
187197 <time datetime="STAMP" title="STAMP">AGO</time>
@@ -217,7 +227,7 @@
217227 htmx:sse:close, htmx:sse:error -> data.state = 'offline';
218228 htmx:sse:error -> ermiteLive.error(event.detail.status);
219229 htmx:sse:before:connection -> ermiteLive.gate(event);
220- home-list, repo-last-push, refs-list, commits-head, mr-notice -> ermiteLive.swap(event.detail.data);
230+ home-list, repo-last-push, refs-list, commits-head, mr-notice, ci-run, ci-job -> ermiteLive.swap(event.detail.data);
221231 gap -> ermiteLive.reload()">
222232 </span>
223233 </div>
@@ -401,6 +411,12 @@
401411 <span class="k">section</span>
402412 </a>
403413 </li>
414+<li data-k="section actions">
415+<a href="/demo/actions" tabindex="-1">
416+<span class="lbl">actions</span>
417+<span class="k">section</span>
418+</a>
419+</li>
404420 <li data-k="branch feature/x">
405421 <a href="/demo/tree/feature/x" tabindex="-1">
406422 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-nested.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -88,6 +90,10 @@
8890 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8991 </a>
9092 </li>
93+<li>
94+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
95+</a>
96+</li>
9197 </ul>
9298 </nav>
9399 </header>
@@ -330,6 +336,12 @@
330336 <span class="k">section</span>
331337 </a>
332338 </li>
339+<li data-k="section actions">
340+<a href="/files/actions" tabindex="-1">
341+<span class="lbl">actions</span>
342+<span class="k">section</span>
343+</a>
344+</li>
333345 <li data-k="branch feature/x">
334346 <a href="/files/tree/feature/x" tabindex="-1">
335347 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-root.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -82,6 +84,10 @@
8284 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8385 </a>
8486 </li>
87+<li>
88+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
89+</a>
90+</li>
8591 </ul>
8692 </nav>
8793 </header>
@@ -557,6 +563,12 @@
557563 <span class="k">section</span>
558564 </a>
559565 </li>
566+<li data-k="section actions">
567+<a href="/files/actions" tabindex="-1">
568+<span class="lbl">actions</span>
569+<span class="k">section</span>
570+</a>
571+</li>
560572 <li data-k="branch feature/x">
561573 <a href="/files/tree/feature/x" tabindex="-1">
562574 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-slash-ref.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="feature/x" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/feature/x">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </header>
@@ -327,6 +333,12 @@
327333 <span class="k">section</span>
328334 </a>
329335 </li>
336+<li data-k="section actions">
337+<a href="/files/actions" tabindex="-1">
338+<span class="lbl">actions</span>
339+<span class="k">section</span>
340+</a>
341+</li>
330342 <li data-k="branch feature/x">
331343 <a href="/files/tree/feature/x" tabindex="-1">
332344 <span class="lbl">feature/x</span>
M internal/app/testdata/web/tree-subdir.html +12 -0
@@ -21,6 +21,8 @@
2121 </script>
2222 <script src="/-/static/js/passkey.HASH.js" defer>
2323 </script>
24+<script src="/-/static/js/log.HASH.js" defer>
25+</script>
2426 </head>
2527 <body data-ref="main" data-sha="SHA40" data-sel="" data-nav="" data-paths="/files/paths/SHA40" data-blob="/files/blob/main">
2628 <a class="skip" href="#main">skip to content</a>
@@ -85,6 +87,10 @@
8587 <a href="/files/merge-requests" data-key="m" aria-keyshortcuts="m">Merge requests<kbd aria-hidden="true">m</kbd>
8688 </a>
8789 </li>
90+<li>
91+<a href="/files/actions" data-key="a" aria-keyshortcuts="a">Actions<kbd aria-hidden="true">a</kbd>
92+</a>
93+</li>
8894 </ul>
8995 </nav>
9096 </header>
@@ -352,6 +358,12 @@
352358 <span class="k">section</span>
353359 </a>
354360 </li>
361+<li data-k="section actions">
362+<a href="/files/actions" tabindex="-1">
363+<span class="lbl">actions</span>
364+<span class="k">section</span>
365+</a>
366+</li>
355367 <li data-k="branch feature/x">
356368 <a href="/files/tree/feature/x" tabindex="-1">
357369 <span class="lbl">feature/x</span>
M internal/app/web.go +1 -0
@@ -39,6 +39,7 @@ func newWeb(cfg config.Config, f *forge, limiter *clientlimit.Limiter) (http.Han
3939 Repos: f.Repos,
4040 Browse: f.Browse,
4141 MergeRequests: f.MergeRequests,
42+ Runs: f.CI,
4243 Events: f.EventLog,
4344 PublicURL: cfg.PublicURL,
4445 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+}
M internal/domain/principal.go +67 -15
@@ -4,6 +4,7 @@ package domain
44
55 import (
66 "slices"
7+ "strings"
78 "time"
89 )
910
@@ -22,12 +23,14 @@ const (
2223 // token, the owner signed in on the web, or a stored token with its scopes
2324 // and repositories.
2425 type Principal struct {
25- kind principalKind
26- tokenID string
27- scopes []Scope
28- repos []RepoName
29- expiresAt time.Time
30- label string
26+ kind principalKind
27+ tokenID string
28+ scopes []Scope
29+ repos []RepoName
30+ // branchPrefixes limits the branches a token may write; empty = all.
31+ branchPrefixes []string
32+ expiresAt time.Time
33+ label string
3134 }
3235
3336 // Anonymous returns the principal of a caller without credentials.
@@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} }
4447 // TokenPrincipal returns the principal of the holder of a stored token.
4548 func TokenPrincipal(t Token) Principal {
4649 return Principal{
47- kind: kindToken,
48- tokenID: t.ID,
49- scopes: slices.Clone(t.Scopes),
50- repos: slices.Clone(t.Repos),
51- expiresAt: t.ExpiresAt,
52- label: t.Label,
50+ kind: kindToken,
51+ tokenID: t.ID,
52+ scopes: slices.Clone(t.Scopes),
53+ repos: slices.Clone(t.Repos),
54+ branchPrefixes: slices.Clone(t.BranchPrefixes),
55+ expiresAt: t.ExpiresAt,
56+ label: t.Label,
5357 }
5458 }
5559
@@ -72,13 +76,13 @@ func (p Principal) Label() string {
7276 func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner }
7377
7478 // HasOwnerRights reports whether p is the owner: root, the owner's web
75-// session, or a stored token that holds every scope and is not limited to a
76-// list of repositories. It decides who may manage browser sessions.
79+// session, or a stored token that holds every scope and is limited neither to
80+// a list of repositories nor to branch prefixes. It decides who may manage browser sessions.
7781 func (p Principal) HasOwnerRights() bool {
7882 if p.IsRoot() {
7983 return true
8084 }
81- if p.kind != kindToken || len(p.repos) > 0 {
85+ if p.kind != kindToken || len(p.repos) > 0 || p.IsBranchLimited() {
8286 return false
8387 }
8488 for _, s := range AllScopes() {
@@ -105,6 +109,54 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) }
105109 // repository.
106110 func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) }
107111
112+// BranchPrefixes returns the branch prefixes of a stored token; empty means
113+// every branch.
114+func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) }
115+
116+// IsBranchLimited reports whether p is limited to branch prefixes. A limited
117+// principal has no owner rights, creates no repository and administers none.
118+func (p Principal) IsBranchLimited() bool { return len(p.branchPrefixes) > 0 }
119+
120+// CoversBranchPrefix reports whether s lies under one of the branch prefixes of
121+// p: s starts with one of them. An empty prefix, which ParseBranchPrefix never
122+// lets in, covers nothing instead of everything, so that a corrupt record
123+// limits a token and never widens it. It is false without prefixes: callers
124+// test IsBranchLimited first.
125+func (p Principal) CoversBranchPrefix(s string) bool {
126+ for _, prefix := range p.branchPrefixes {
127+ if prefix != "" && strings.HasPrefix(s, prefix) {
128+ return true
129+ }
130+ }
131+ return false
132+}
133+
134+// AllowsRef reports whether p may write the ref (create, update or delete
135+// it). Without branch prefixes every ref is allowed. With them, only a branch
136+// "refs/heads/<name>" under one of the prefixes is: tags, every other ref and
137+// a branch outside the prefixes are not.
138+//
139+// It judges the name of the ref only, and rests on two assumptions about the
140+// repositories ermite serves. First, no symbolic ref lives under refs/heads
141+// (HEAD is the only symref): ermite never creates one and receive-pack cannot,
142+// so a name under a prefix never points at a branch outside it. Second, ref
143+// names are stored case-sensitively (Linux file systems): "refs/heads/Agent/x"
144+// is not "refs/heads/agent/x". On a case-insensitive file system the two could
145+// name the same ref and the prefixes would not hold.
146+func (p Principal) AllowsRef(ref string) bool {
147+ if !p.IsBranchLimited() {
148+ return true
149+ }
150+ name, ok := strings.CutPrefix(ref, "refs/heads/")
151+ if !ok {
152+ return false
153+ }
154+ if _, err := ParseBranchName(name); err != nil {
155+ return false
156+ }
157+ return p.CoversBranchPrefix(name)
158+}
159+
108160 // ExpiresAt returns the expiry of a stored token; zero means none.
109161 func (p Principal) ExpiresAt() time.Time { return p.expiresAt }
110162
M internal/domain/token.go +49 -0
@@ -7,6 +7,7 @@ import (
77 "encoding/hex"
88 "fmt"
99 "slices"
10+ "strings"
1011 "time"
1112 )
1213
@@ -80,6 +81,10 @@ type Token struct {
8081 Scopes []Scope
8182 // Repos limits the token to these repositories; empty means every one.
8283 Repos []RepoName
84+ // BranchPrefixes limits the branches the token may write to those under
85+ // one of these prefixes (each ends with "/"); empty means every branch.
86+ // A limited token cannot write tags or other refs, nor create repositories.
87+ BranchPrefixes []string
8388 // ExpiresAt is the zero time for a token that never expires.
8489 ExpiresAt time.Time
8590 CreatedAt time.Time
@@ -90,3 +95,47 @@ type Token struct {
9095 func (t Token) Expired(now time.Time) bool {
9196 return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt)
9297 }
98+
99+const (
100+ // MaxBranchPrefixLen is the longest branch prefix, in bytes.
101+ MaxBranchPrefixLen = 100
102+ // MaxBranchPrefixes is the most branch prefixes of a token.
103+ MaxBranchPrefixes = 8
104+)
105+
106+// ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends
107+// with "/", the rest is a valid branch name, and it has at most
108+// MaxBranchPrefixLen bytes. Errors match ErrInvalidToken.
109+func ParseBranchPrefix(s string) (string, error) {
110+ if len(s) > MaxBranchPrefixLen {
111+ return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen)
112+ }
113+ name, ok := strings.CutSuffix(s, "/")
114+ if !ok {
115+ return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s)
116+ }
117+ if _, err := ParseBranchName(name); err != nil {
118+ return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err)
119+ }
120+ return s, nil
121+}
122+
123+// ParseBranchPrefixes validates a list of branch prefixes: at most
124+// MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match
125+// ErrInvalidToken. The result is nil for an empty list.
126+func ParseBranchPrefixes(in []string) ([]string, error) {
127+ var out []string
128+ for _, s := range in {
129+ p, err := ParseBranchPrefix(s)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if !slices.Contains(out, p) {
134+ out = append(out, p)
135+ }
136+ }
137+ if len(out) > MaxBranchPrefixes {
138+ return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes)
139+ }
140+ return out, nil
141+}
M internal/domain/token_test.go +127 -0
@@ -3,6 +3,7 @@
33 package domain_test
44
55 import (
6+ "strings"
67 "testing"
78 "time"
89
@@ -111,3 +112,129 @@ func TestHasOwnerRights(t *testing.T) {
111112 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
112113 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
113114 }
115+
116+func TestParseBranchPrefix(t *testing.T) {
117+ t.Parallel()
118+ for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} {
119+ got, err := domain.ParseBranchPrefix(ok)
120+ require.NoError(t, err, ok)
121+ assert.Equal(t, ok, got)
122+ }
123+ for _, bad := range []string{
124+ "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/",
125+ "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/",
126+ "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/",
127+ } {
128+ _, err := domain.ParseBranchPrefix(bad)
129+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
130+ }
131+}
132+
133+func TestParseBranchPrefixes(t *testing.T) {
134+ t.Parallel()
135+ got, err := domain.ParseBranchPrefixes(nil)
136+ require.NoError(t, err)
137+ assert.Empty(t, got)
138+
139+ got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"})
140+ require.NoError(t, err)
141+ assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept")
142+
143+ eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"}
144+ got, err = domain.ParseBranchPrefixes(eight)
145+ require.NoError(t, err)
146+ assert.Len(t, got, 8)
147+ _, err = domain.ParseBranchPrefixes(append(eight, "i/"))
148+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
149+ got, err = domain.ParseBranchPrefixes(append(eight, "a/"))
150+ require.NoError(t, err, "duplicates do not count")
151+ assert.Len(t, got, 8)
152+
153+ _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"})
154+ assert.ErrorIs(t, err, domain.ErrInvalidToken)
155+}
156+
157+func TestAllowsRef(t *testing.T) {
158+ t.Parallel()
159+ limited := domain.TokenPrincipal(domain.Token{
160+ Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"},
161+ })
162+ for ref, want := range map[string]bool{
163+ "refs/heads/agent/claude/x": true,
164+ "refs/heads/agent/claude/a/b": true,
165+ "refs/heads/bot/y": true,
166+ "refs/heads/agent/claude": false,
167+ "refs/heads/agent/claude/": false,
168+ "refs/heads/agent/claudex/y": false,
169+ "refs/heads/main": false,
170+ "refs/heads/agent/other/x": false,
171+ "refs/heads/other/agent/claude/x": false,
172+ "refs/tags/v1": false,
173+ "refs/tags/agent/claude/x": false,
174+ "refs/notes/commits": false,
175+ "refs/remotes/agent/claude/x": false,
176+ "refs/changes/agent/claude/x": false,
177+ "agent/claude/x": false,
178+ "heads/agent/claude/x": false,
179+ "HEAD": false,
180+ "": false,
181+ "refs/heads/agent/claude/../main": false,
182+ "refs/heads/agent/claude/x.lock": false,
183+ "refs/heads/agent/claude/a b": false,
184+ "refs/heads//agent/claude/x": false,
185+ } {
186+ assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref)
187+ }
188+
189+ // A corrupt empty prefix still limits the token and allows nothing.
190+ corrupt := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{""}})
191+ assert.False(t, corrupt.AllowsRef("refs/heads/main"))
192+
193+ // Without prefixes every ref is allowed.
194+ unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}})
195+ assert.True(t, unlimited.AllowsRef("refs/tags/v1"))
196+ assert.True(t, unlimited.AllowsRef("refs/heads/main"))
197+ assert.True(t, domain.Root().AllowsRef("refs/tags/v1"))
198+ assert.True(t, domain.Owner().AllowsRef("refs/heads/main"))
199+}
200+
201+func TestBranchPrefixesOfPrincipal(t *testing.T) {
202+ t.Parallel()
203+ tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}}
204+ p := domain.TokenPrincipal(tok)
205+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
206+ assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner")
207+ assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
208+
209+ // The principal owns copies.
210+ tok.BranchPrefixes[0] = "b/"
211+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
212+ p.BranchPrefixes()[0] = "c/"
213+ assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
214+ assert.Empty(t, domain.Root().BranchPrefixes())
215+}
216+
217+func TestBranchLimitedAndCoversBranchPrefix(t *testing.T) {
218+ t.Parallel()
219+ free := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()})
220+ assert.False(t, free.IsBranchLimited())
221+ assert.False(t, free.CoversBranchPrefix("agent/x"), "no prefixes cover nothing: callers test IsBranchLimited first")
222+ assert.False(t, domain.Root().IsBranchLimited())
223+ assert.False(t, domain.Owner().IsBranchLimited())
224+ assert.False(t, domain.Anonymous().IsBranchLimited())
225+
226+ limited := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/claude/", "bot/"}})
227+ assert.True(t, limited.IsBranchLimited())
228+ for s, want := range map[string]bool{
229+ "agent/claude/x": true, "agent/claude/": true, "bot/y": true,
230+ "agent/claude": false, "agent/claudex/y": false, "main": false, "": false, "xbot/y": false,
231+ } {
232+ assert.Equal(t, want, limited.CoversBranchPrefix(s), "%q", s)
233+ }
234+
235+ // A corrupt empty prefix still limits, and covers nothing.
236+ corrupt := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{""}})
237+ assert.True(t, corrupt.IsBranchLimited())
238+ assert.False(t, corrupt.CoversBranchPrefix("main"))
239+ assert.False(t, corrupt.CoversBranchPrefix(""))
240+}
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 +5 -2
@@ -61,6 +61,9 @@ type CreateTokenParams struct {
6161 Scopes []domain.Scope
6262 // Repos limits the token to these repositories; empty means every one.
6363 Repos []domain.RepoName
64+ // BranchPrefixes limits the token to the branches under these prefixes
65+ // (see domain.ParseBranchPrefix); empty means every branch.
66+ BranchPrefixes []string
6467 // ExpiresAt is the zero time for a token that never expires.
6568 ExpiresAt time.Time
6669 }
@@ -137,8 +140,8 @@ type WatchEvent struct {
137140 // Event is nil then.
138141 Gap bool
139142 // Event is a domain.RepoCreated, RepoUpdated, RepoDeleted, PushEvent,
140- // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed or
141- // CommentAdded.
143+ // MergeRequestOpened, MergeRequestMerged, MergeRequestClosed,
144+ // CommentAdded, RunUpdated or JobUpdated.
142145 Event domain.Event
143146 // Repo is the state of the repository at delivery, for RepoCreated and
144147 // 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+}
M internal/ports/ports.go +3 -1
@@ -255,7 +255,9 @@ type MergeRequestStore interface {
255255 // ObjectID when there is none.
256256 GetRead(ctx context.Context, repo domain.RepoName, number int64) (domain.ObjectID, error)
257257 // SetRead records the head the reviewer has read. It returns
258- // domain.ErrNotFound when the repository does not exist.
258+ // domain.ErrNotFound when the repository does not exist, and
259+ // domain.ErrConflict when concurrent writes to the repository keep it
260+ // from landing.
259261 SetRead(ctx context.Context, repo domain.RepoName, number int64, head domain.ObjectID) error
260262 }
261263
M internal/usecase/access.go +18 -6
@@ -26,12 +26,18 @@ func canReadAs(p domain.Principal, name domain.RepoName, vis domain.Visibility)
2626 return p.Allows(domain.ScopeRepoRead, name)
2727 }
2828
29-// adminAccess is the access rule for changing or deleting a repository. A
30-// caller that may not administer it learns nothing it could not already read:
31-// not found when it cannot see the repository, unauthenticated when it is
32-// anonymous, forbidden otherwise.
29+// adminAccess is the access rule for changing or deleting a repository:
30+// repo:admin on it, and no branch prefixes. A token limited to some branches
31+// may not delete the repository (that deletes every branch, those outside its
32+// prefixes included), nor change its default branch, visibility, description,
33+// merge modes or failing-checks rule: these decide what happens to branches the
34+// token may not write. A caller that may not administer it learns nothing it
35+// could not already read: not found when it cannot see the repository,
36+// unauthenticated when it is anonymous, forbidden otherwise.
3337 func adminAccess(p domain.Principal, repo domain.Repo) error {
3438 switch {
39+ case canAdmin(p, repo.Name) && p.IsBranchLimited():
40+ return fmt.Errorf("%w: a token limited to branch prefixes may not administer a repository", domain.ErrForbidden)
3541 case canAdmin(p, repo.Name):
3642 return nil
3743 case !canRead(p, repo):
@@ -42,12 +48,18 @@ func adminAccess(p domain.Principal, repo domain.Repo) error {
4248 return domain.ErrForbidden
4349 }
4450
45-// canWrite is the access rule for pushing to a repository, or creating it on
46-// first push: repo:write on it.
51+// canWrite is the access rule for pushing to a repository: repo:write on it.
4752 func canWrite(p domain.Principal, name domain.RepoName) bool {
4853 return p.Allows(domain.ScopeRepoWrite, name)
4954 }
5055
56+// canCreateRepo is the access rule for creating a repository, by the API or on
57+// first push: repo:write on the name, and no branch prefixes. A token limited
58+// to some branches may push to a repository that exists; it never makes one.
59+func canCreateRepo(p domain.Principal, name domain.RepoName) bool {
60+ return canWrite(p, name) && !p.IsBranchLimited()
61+}
62+
5163 // canManageTokens is the access rule for creating, listing and revoking
5264 // stored tokens: token:admin, which root holds.
5365 func canManageTokens(p domain.Principal) bool {
A internal/usecase/ci.go +555 -0
@@ -0,0 +1,555 @@
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+// runWriteAccess is the branch rule of the write actions on a run, cancel and
466+// re-run, which come after the ci:write check. A run is tied to the ref that
467+// made it, and re-running it executes the workflow of that ref again while
468+// cancelling it stops the checks of that ref. So, like a push, a merge or a
469+// branch delete, these actions fall under the branch prefixes of a limited
470+// token (ADR 0027): it may act only on the runs of branches under its prefixes,
471+// and never on a run of a tag or of any other ref. Without prefixes, every run.
472+//
473+// Reading runs, jobs, logs and checks is not a write: it needs only the access
474+// to read the repository, as for the code, whatever the prefixes. The caller
475+// has seen the run by then, so the refusal is forbidden, not not found.
476+func runWriteAccess(p domain.Principal, run domain.CIRun) error {
477+ if !p.AllowsRef(run.Ref) {
478+ return fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, run.Ref)
479+ }
480+ return nil
481+}
482+
483+// canManageRunners is the access rule of runners: runner:register on a token
484+// that is not restricted to repositories (a runner serves every repository),
485+// or root. A token limited to branch prefixes never manages runners, whatever
486+// scopes it holds: a runner runs the workflows of every branch of every
487+// repository, with their code and secrets, so registering or removing one
488+// reaches far beyond the prefixes (a replaced runner could also be a way to
489+// take over the jobs of main). Like repository administration and owner
490+// rights, it is out of reach of a limited token.
491+func canManageRunners(p domain.Principal) error {
492+ switch {
493+ case p.IsAnonymous():
494+ return domain.ErrUnauthenticated
495+ case !p.HasScope(domain.ScopeRunnerRegister):
496+ return fmt.Errorf("%w: this needs the %s scope", domain.ErrForbidden, domain.ScopeRunnerRegister)
497+ case len(p.Repos()) > 0:
498+ return fmt.Errorf("%w: a runner serves every repository: the token must not be restricted to some", domain.ErrForbidden)
499+ case p.IsBranchLimited():
500+ return fmt.Errorf("%w: a token limited to branch prefixes may not manage runners", domain.ErrForbidden)
501+ }
502+ return nil
503+}
504+
505+// --- small helpers ---
506+
507+func truncateRunes(s string, n int) string {
508+ if len(s) <= n {
509+ return s
510+ }
511+ r := []rune(s)
512+ if len(r) <= n {
513+ return s
514+ }
515+ return string(r[:n])
516+}
517+
518+// cutText cuts s to n bytes at a character boundary.
519+func cutText(s string, n int) string {
520+ if len(s) <= n {
521+ return s
522+ }
523+ for n > 0 && n < len(s) && s[n]&0xC0 == 0x80 {
524+ n--
525+ }
526+ return s[:n]
527+}
528+
529+func sortedKeys[V any](m map[string]V) []string {
530+ keys := make([]string, 0, len(m))
531+ for k := range m {
532+ keys = append(keys, k)
533+ }
534+ slices.Sort(keys)
535+ return keys
536+}
537+
538+// ctxLog returns the logger of the context, addressable so that the level
539+// methods can be chained on it.
540+func ctxLog(ctx context.Context) *zerowrap.Logger {
541+ l := zerowrap.FromCtx(ctx)
542+ return &l
543+}
544+
545+// logRun returns the logger of the context with the fields of the run.
546+func logRun(ctx context.Context, run domain.CIRun) zerowrap.Logger {
547+ return zerowrap.FromCtx(ctx).WithFields(map[string]any{"repo": run.Repo.String(), "run_id": run.ID})
548+}
549+
550+func isHex40(s string) bool {
551+ if len(s) != 40 {
552+ return false
553+ }
554+ return strings.Trim(s, "0123456789abcdef") == ""
555+}
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 +592 -0
@@ -0,0 +1,592 @@
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+ run, err := c.runOf(ctx, name, runID)
322+ if err != nil {
323+ return domain.CIRun{}, err
324+ }
325+ if err := runWriteAccess(p, run); err != nil {
326+ return domain.CIRun{}, err
327+ }
328+ var out domain.CIRun
329+ err = c.withRun(ctx, runID, 0, func(rs *runState) error {
330+ if rs.run.Status == domain.CICompleted {
331+ return fmt.Errorf("%w: run %d has completed", domain.ErrPreconditionFailed, runID)
332+ }
333+ if !rs.run.CancelRequested {
334+ rs.run.CancelRequested, rs.run.CancelRequestedAt, rs.runDirty = true, c.now(), true
335+ rs.cancelAll("Canceled by " + authorOf(p))
336+ }
337+ if err := rs.flush(ctx); err != nil {
338+ return err
339+ }
340+ out = rs.run
341+ return nil
342+ })
343+ if err != nil {
344+ return domain.CIRun{}, err
345+ }
346+ ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", runID).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("ci run cancelled")
347+ return out, nil
348+}
349+
350+// RerunRun starts a new attempt of a completed run, from the stored workflow
351+// file: all the jobs, or with failedOnly the jobs that did not succeed and
352+// those that need them; the others are kept with their result, steps and log.
353+func (c *CI) RerunRun(ctx context.Context, p domain.Principal, name domain.RepoName, runID int64, failedOnly bool) (domain.CIRun, error) {
354+ repo, err := c.writableRepo(ctx, p, name)
355+ if err != nil {
356+ return domain.CIRun{}, err
357+ }
358+ run, err := c.runOf(ctx, name, runID)
359+ if err != nil {
360+ return domain.CIRun{}, err
361+ }
362+ if err := runWriteAccess(p, run); err != nil {
363+ return domain.CIRun{}, err
364+ }
365+ err = c.withRun(ctx, runID, 0, func(rs *runState) error {
366+ if rs.run.Status != domain.CICompleted {
367+ return fmt.Errorf("%w: run %d has not completed", domain.ErrPreconditionFailed, runID)
368+ }
369+ if len(rs.jobs) == 0 {
370+ return fmt.Errorf("%w: run %d has no job to run again", domain.ErrPreconditionFailed, runID)
371+ }
372+ redo, err := rerunSet(rs, failedOnly)
373+ if err != nil {
374+ return err
375+ }
376+ next := rs.run.Attempt + 1
377+ now := c.now()
378+ w := ports.CIWrite{}
379+ var fresh []domain.CIJob
380+ for _, j := range rs.jobs {
381+ if !redo[j.Key] {
382+ kept := j // kept with its own attempt, steps and log
383+ kept.RunAttempt, kept.Version = next, 0
384+ w.Jobs = append(w.Jobs, kept)
385+ fresh = append(fresh, kept)
386+ continue
387+ }
388+ if j.Combo > 0 {
389+ continue // the matrix is expanded again when the job is planned
390+ }
391+ n := domain.CIJob{
392+ ID: domain.NewJobID(runID, j.Index, 0), RunID: runID, Attempt: next, RunAttempt: next, Repo: rs.run.Repo,
393+ Key: j.Key, Name: j.Key, Phase: domain.CIPhaseWaiting, Needs: slices.Clone(j.Needs), Index: j.Index, CreatedAt: now,
394+ }
395+ w.Jobs = append(w.Jobs, n)
396+ fresh = append(fresh, n)
397+ }
398+ run := rs.run
399+ run.Attempt, run.Status, run.Conclusion = next, domain.CIQueued, domain.CINoConclusion
400+ run.StartedAt, run.CompletedAt, run.CreatedAt = time.Time{}, time.Time{}, now
401+ run.CancelRequested, run.CancelRequestedAt = false, time.Time{}
402+ w.Runs = []domain.CIRun{run}
403+ rs2 := &runState{c: c, run: run, attempt: next, jobs: fresh, src: rs.src, dirty: map[string]bool{}, checks: map[string]bool{}}
404+ for _, j := range fresh {
405+ w.Checks = append(w.Checks, rs2.checkOf(j))
406+ }
407+ v, err := c.cfg.Store.Apply(ctx, w)
408+ if err != nil {
409+ return err
410+ }
411+ run.Version = v
412+ c.flows.drop(runID)
413+ c.publishRun(ctx, repo, run)
414+ for _, j := range w.Jobs {
415+ j.Version = v
416+ c.cfg.Bus.Publish(ctx, domain.JobUpdated{Job: j, At: now, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt})
417+ }
418+ return nil
419+ })
420+ if err != nil {
421+ return domain.CIRun{}, err
422+ }
423+ // Plan the new attempt: the kept jobs count as the results of their needs.
424+ if err := c.advanceRun(ctx, runID); err != nil {
425+ return domain.CIRun{}, err
426+ }
427+ run, err = c.runOf(ctx, name, runID)
428+ if err != nil {
429+ return domain.CIRun{}, err
430+ }
431+ ctxLog(ctx).Info().Str("repo", name.String()).Int64("run_id", runID).Int32("attempt", run.Attempt).Bool("failed_only", failedOnly).
432+ Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("ci run started again")
433+ return run, nil
434+}
435+
436+// rerunSet returns the keys of the jobs to run again: all of them, or the ones
437+// that did not succeed and those that need them, directly or not.
438+func rerunSet(rs *runState, failedOnly bool) (map[string]bool, error) {
439+ redo := map[string]bool{}
440+ if !failedOnly {
441+ for _, j := range rs.jobs {
442+ redo[j.Key] = true
443+ }
444+ return redo, nil
445+ }
446+ for _, j := range rs.jobs {
447+ switch j.Conclusion {
448+ case domain.CIFailure, domain.CICancelled, domain.CITimedOut, domain.CIActionRequired:
449+ redo[j.Key] = true
450+ }
451+ }
452+ if len(redo) == 0 {
453+ return nil, fmt.Errorf("%w: no job of run %d failed", domain.ErrPreconditionFailed, rs.run.ID)
454+ }
455+ for changed := true; changed; {
456+ changed = false
457+ for _, j := range rs.jobs {
458+ if redo[j.Key] {
459+ continue
460+ }
461+ if slices.ContainsFunc(j.Needs, func(n string) bool { return redo[n] }) {
462+ redo[j.Key], changed = true, true
463+ }
464+ }
465+ }
466+ return redo, nil
467+}
468+
469+func (w *wfCache) drop(runID int64) {
470+ w.mu.Lock()
471+ delete(w.m, runID)
472+ w.mu.Unlock()
473+}
474+
475+// --- checks ---
476+
477+// CommitChecks returns the checks of a commit, without access control. A
478+// commit with more than MaxCIChecks checks is an error (domain.ErrResourceExhausted):
479+// the merge rule cannot answer from a part of them.
480+func (c *CI) CommitChecks(ctx context.Context, repo domain.RepoName, sha string) ([]domain.CICheck, error) {
481+ checks, more, err := c.cfg.Store.ListChecks(ctx, repo, sha, MaxCIChecks)
482+ if err != nil {
483+ return nil, err
484+ }
485+ if more {
486+ return nil, fmt.Errorf("%w: the commit has more than %d checks", domain.ErrResourceExhausted, MaxCIChecks)
487+ }
488+ return checks, nil
489+}
490+
491+// ListChecks returns the checks of a commit: the one of each job of the latest
492+// attempt of each run on it. A commit without a run has none.
493+func (c *CI) ListChecks(ctx context.Context, p domain.Principal, name domain.RepoName, sha string) (ports.ChecksResult, error) {
494+ if _, err := c.readableRepo(ctx, p, name); err != nil {
495+ return ports.ChecksResult{}, err
496+ }
497+ if !isHex40(sha) {
498+ return ports.ChecksResult{}, fmt.Errorf("%w: a commit is 40 lowercase hexadecimal characters", domain.ErrInvalidObjectID)
499+ }
500+ checks, more, err := c.cfg.Store.ListChecks(ctx, name, sha, MaxCIChecks)
501+ if err != nil {
502+ return ports.ChecksResult{}, err
503+ }
504+ res := ports.ChecksResult{Truncated: more}
505+ var runners []domain.CIRunner
506+ for _, ck := range checks {
507+ v := ports.CheckView{CICheck: ck}
508+ if ck.Status == domain.CIQueued {
509+ if runners == nil {
510+ runners, _ = c.cfg.Store.ListRunners(ctx)
511+ }
512+ v.WaitingForRunner = c.noRunnerFor(ctx, ck, runners)
513+ }
514+ res.Checks = append(res.Checks, v)
515+ }
516+ return res, nil
517+}
518+
519+// noRunnerFor reports whether no registered runner can take a queued check's
520+// job. A job that waits for its needs is not waiting for a runner.
521+func (c *CI) noRunnerFor(ctx context.Context, ck domain.CICheck, runners []domain.CIRunner) bool {
522+ if ck.JobID == "" {
523+ return false
524+ }
525+ j, err := c.cfg.Store.GetJob(ctx, ck.RunID, ck.Attempt, ck.JobID)
526+ if err != nil || j.Phase != domain.CIPhaseReady {
527+ return false
528+ }
529+ return !slices.ContainsFunc(runners, func(r domain.CIRunner) bool { return domain.LabelsMatch(j.Labels, r.Labels) })
530+}
531+
532+// --- runners ---
533+
534+// ListRunners returns a page of the runners ordered by name. The principal
535+// needs runner:register on a token that is not restricted to repositories.
536+func (c *CI) ListRunners(ctx context.Context, p domain.Principal, size int, token string) (ports.RunnerPage, error) {
537+ if err := canManageRunners(p); err != nil {
538+ return ports.RunnerPage{}, err
539+ }
540+ n, err := pageSize(size)
541+ if err != nil {
542+ return ports.RunnerPage{}, err
543+ }
544+ after := ""
545+ if token != "" {
546+ raw, ok := decodeToken(token, "rn1:")
547+ if !ok || raw == "" {
548+ return ports.RunnerPage{}, fmt.Errorf("%w: runners", domain.ErrInvalidPageToken)
549+ }
550+ after = raw
551+ }
552+ all, err := c.cfg.Store.ListRunners(ctx)
553+ if err != nil {
554+ return ports.RunnerPage{}, err
555+ }
556+ busy := map[string]bool{}
557+ if active, err := c.cfg.Store.ListActiveJobs(ctx); err == nil {
558+ for _, j := range active {
559+ if j.Phase == domain.CIPhaseRunning {
560+ busy[j.RunnerID] = true
561+ }
562+ }
563+ }
564+ var page ports.RunnerPage
565+ var rows []domain.CIRunner
566+ rows, page.NextPageToken = pageAfter(all, n,
567+ func(r domain.CIRunner) bool { return after == "" || strings.ToLower(r.Name) > after },
568+ func(r domain.CIRunner) string {
569+ return base64.RawURLEncoding.EncodeToString([]byte("rn1:" + strings.ToLower(r.Name)))
570+ })
571+ c.mu.Lock()
572+ defer c.mu.Unlock()
573+ for _, r := range rows {
574+ page.Runners = append(page.Runners, ports.RunnerView{CIRunner: r, LastSeen: c.seen[r.ID], Busy: busy[r.ID]})
575+ }
576+ return page, nil
577+}
578+
579+// RemoveRunner removes a runner: its token stops working at once and a poll of
580+// it in progress ends with unauthenticated. A job it runs is not affected.
581+func (c *CI) RemoveRunner(ctx context.Context, p domain.Principal, id string) error {
582+ if err := canManageRunners(p); err != nil {
583+ return err
584+ }
585+ r, err := c.cfg.Store.RemoveRunner(ctx, id)
586+ if err != nil {
587+ return err
588+ }
589+ c.forgetRunners([]domain.CIRunner{r})
590+ ctxLog(ctx).Info().Str("runner_id", r.ID).Str("runner", r.Name).Str("principal", p.Label()).Str("token_id", p.TokenID()).Msg("runner removed")
591+ return nil
592+}
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 +59 -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 }
@@ -710,6 +718,14 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
710718 if err != nil {
711719 return domain.MergeRequest{}, false, err
712720 }
721+ // A merge moves the target and may delete the source: a token limited to
722+ // branch prefixes may do so only within them.
723+ if !p.AllowsRef("refs/heads/" + mr.Target) {
724+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Target)
725+ }
726+ if params.DeleteSource && !p.AllowsRef("refs/heads/"+mr.Source) {
727+ return domain.MergeRequest{}, false, fmt.Errorf("%w: this token may not write %s", domain.ErrForbidden, mr.Source)
728+ }
713729 if !mr.IsOpen() {
714730 return domain.MergeRequest{}, false, fmt.Errorf("%w: !%d is %s", domain.ErrPreconditionFailed, number, mr.State)
715731 }
@@ -753,6 +769,11 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
753769 case !ok:
754770 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)
755771 }
772+ if settings.FailingChecksBlock {
773+ if err := m.checksAllow(ctx, name, head); err != nil {
774+ return domain.MergeRequest{}, false, err
775+ }
776+ }
756777 if params.DeleteSource {
757778 if err := m.checkDeletable(ctx, name, mr); err != nil {
758779 return domain.MergeRequest{}, false, err
@@ -792,9 +813,46 @@ func (m *MergeRequests) merge(ctx context.Context, p domain.Principal, name doma
792813 m.bus.Publish(ctx, domain.PushEvent{
793814 Repo: name, At: at, Visibility: repo.Visibility, CreatedAt: repo.CreatedAt, Updates: updates,
794815 })
816+ if m.ci != nil {
817+ // GitHub runs the "push" workflows of a merge, with the owner as pusher.
818+ m.ci.OnPush(ctx, name, updates[:1], domain.Owner())
819+ }
795820 return mr, deleted, nil
796821 }
797822
823+// checksAllow is the merge rule of FailingChecksBlock: every check of the
824+// head must have ended as success, skipped or neutral. A check still queued or
825+// running blocks too (the merge could land code that then fails), and a head
826+// without any check merges.
827+func (m *MergeRequests) checksAllow(ctx context.Context, name domain.RepoName, head domain.ObjectID) error {
828+ if m.checks == nil {
829+ return nil
830+ }
831+ checks, err := m.checks.CommitChecks(ctx, name, head.String())
832+ if errors.Is(err, domain.ErrResourceExhausted) {
833+ return fmt.Errorf("%w: too many checks to tell whether they passed: %v", domain.ErrPreconditionFailed, err)
834+ }
835+ if err != nil {
836+ return fmt.Errorf("read the checks of %s: %w", head, err)
837+ }
838+ var failing, pending []string
839+ for _, c := range checks {
840+ switch {
841+ case c.Status != domain.CICompleted:
842+ pending = append(pending, c.Name)
843+ case !c.Conclusion.PassesMerge():
844+ failing = append(failing, c.Name)
845+ }
846+ }
847+ switch {
848+ case len(failing) > 0:
849+ return fmt.Errorf("%w: failing checks block the merge: %s (turn off failing_checks_block to merge anyway)", domain.ErrPreconditionFailed, strings.Join(failing, ", "))
850+ case len(pending) > 0:
851+ return fmt.Errorf("%w: checks have not finished: %s", domain.ErrPreconditionFailed, strings.Join(pending, ", "))
852+ }
853+ return nil
854+}
855+
798856 // checkDeletable refuses to delete the source branch of mr when it is the
799857 // default branch or the source or target of another open request. It runs
800858 // under the merge lock, which Create also holds while it checks its branches
A internal/usecase/mergerequests_branches_test.go +113 -0
@@ -0,0 +1,113 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package usecase_test
4+
5+import (
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "bnema.dev/ermite/internal/domain"
12+ "bnema.dev/ermite/internal/ports"
13+)
14+
15+// branchMerger holds mr:merge but may only write the branches under
16+// agent/claude/.
17+var branchMerger = domain.TokenPrincipal(domain.Token{
18+ ID: "bm", Label: "claude", BranchPrefixes: []string{"agent/claude/"},
19+ Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeMRWrite, domain.ScopeMRMerge},
20+})
21+
22+// AC5: a merge writes the target, and the source when it is deleted; a token
23+// limited to branch prefixes may do neither outside them.
24+func TestMergeWithBranchLimitedToken(t *testing.T) {
25+ t.Parallel()
26+
27+ t.Run("into main is forbidden and nothing moves", func(t *testing.T) {
28+ t.Parallel()
29+ r := newMRRig(t)
30+ main := r.seed()
31+ head := r.feature("agent/claude/feat")
32+ mr := r.open("agent/claude/feat") // targets main
33+ r.drain()
34+
35+ for _, del := range []bool{false, true} {
36+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: del})
37+ require.ErrorIs(t, err, domain.ErrForbidden)
38+ assert.Contains(t, err.Error(), "this token may not write main")
39+ }
40+ assert.Equal(t, main, r.branchHead("main"), "main did not move")
41+ assert.Equal(t, head, r.branchHead("agent/claude/feat"), "the source stays")
42+ d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, false)
43+ require.NoError(t, err)
44+ assert.Equal(t, domain.MRStateOpen, d.MergeRequest.State)
45+ assert.Empty(t, r.drain(), "no event")
46+
47+ // The same merge by an unlimited token works.
48+ _, err = r.mrs.Merge(r.ctx, mrM, r.repo, mr.Number, ports.MergeParams{HeadID: head})
49+ require.NoError(t, err)
50+ assert.Equal(t, head, r.branchHead("main"))
51+ })
52+
53+ t.Run("into a branch under the prefixes is allowed, deleting the source too", func(t *testing.T) {
54+ t.Parallel()
55+ r := newMRRig(t)
56+ r.seed()
57+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
58+ r.push("agent/claude/base")
59+ r.sh("checkout", "-q", "-B", "agent/claude/feat", "agent/claude/base")
60+ head := r.commit(author, "f.txt", "f\n", "add f")
61+ r.push("agent/claude/feat")
62+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
63+ Title: "feat", Source: "agent/claude/feat", Target: "agent/claude/base",
64+ })
65+ require.NoError(t, err)
66+
67+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
68+ require.NoError(t, err)
69+ assert.True(t, res.SourceDeleted)
70+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
71+ _, err = r.git.BranchHead(r.ctx, r.repo, "agent/claude/feat")
72+ assert.ErrorIs(t, err, domain.ErrNotFound)
73+ })
74+
75+ t.Run("deleting a source outside the prefixes is forbidden, merging without deleting is not", func(t *testing.T) {
76+ t.Parallel()
77+ r := newMRRig(t)
78+ r.seed()
79+ r.sh("checkout", "-q", "-B", "agent/claude/base", "main")
80+ r.push("agent/claude/base")
81+ base := r.branchHead("agent/claude/base")
82+ r.sh("checkout", "-q", "-B", "other", "agent/claude/base")
83+ head := r.commit(author, "o.txt", "o\n", "add o")
84+ r.push("other")
85+ mr, err := r.mrs.Create(r.ctx, branchMerger, r.repo, ports.CreateMergeRequestParams{
86+ Title: "other", Source: "other", Target: "agent/claude/base",
87+ })
88+ require.NoError(t, err)
89+
90+ _, err = r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, DeleteSource: true})
91+ require.ErrorIs(t, err, domain.ErrForbidden)
92+ assert.Contains(t, err.Error(), "this token may not write other")
93+ assert.Equal(t, base, r.branchHead("agent/claude/base"), "not merged")
94+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
95+
96+ res, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head})
97+ require.NoError(t, err)
98+ assert.False(t, res.SourceDeleted)
99+ assert.Equal(t, head, r.branchHead("agent/claude/base"))
100+ assert.Equal(t, head, r.branchHead("other"), "the source stays")
101+ })
102+
103+ t.Run("a squash into main is forbidden too", func(t *testing.T) {
104+ t.Parallel()
105+ r := newMRRig(t)
106+ main := r.seed()
107+ head := r.feature("agent/claude/feat")
108+ mr := r.open("agent/claude/feat")
109+ _, err := r.mrs.Merge(r.ctx, branchMerger, r.repo, mr.Number, ports.MergeParams{HeadID: head, Mode: domain.MergeModeSquash})
110+ require.ErrorIs(t, err, domain.ErrForbidden)
111+ assert.Equal(t, main, r.branchHead("main"))
112+ })
113+}
M internal/usecase/pushes.go +32 -6
@@ -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.
@@ -69,7 +77,7 @@ func (u *Pushes) Open(ctx context.Context, p domain.Principal, name domain.RepoN
6977 return fmt.Errorf("get repository %q: %w", name, err)
7078 }
7179
72- if !create || !canWrite(p, name) {
80+ if !create || !canCreateRepo(p, name) {
7381 return domain.ErrNotFound
7482 }
7583 _, err = u.repos.Create(ctx, p, ports.CreateRepoParams{Name: name, Visibility: domain.VisibilityPrivate})
@@ -97,9 +105,17 @@ func openExisting(p domain.Principal, repo domain.Repo) error {
97105 return domain.ErrNotFound
98106 }
99107
100-// Check is the pre-receive decision. v1 accepts every push of a principal that
101-// may write the repository; protected branches and limits will refuse here by
102-// returning an error that matches domain.ErrRejected.
108+// Check is the pre-receive decision. It accepts a push of a principal that
109+// may write the repository. A token limited to branch prefixes may only update
110+// (create, move or delete) branches under them: the first ref outside them
111+// refuses the whole push, tags and every other ref included. Further rules
112+// (protected branches, limits) will refuse here too, by returning an error
113+// that matches domain.ErrRejected.
114+//
115+// The prefix rule is domain.Principal.AllowsRef and shares its assumptions: no
116+// symbolic ref under refs/heads besides HEAD (ermite never creates one and
117+// receive-pack cannot), and case-sensitive ref storage (Linux). The hook sees
118+// every ref of the push, so a tag or any other ref is judged like a branch.
103119 //
104120 // The token of the push was valid when the request began, but a push can run
105121 // for long: it is re-validated here, so a token revoked or expired meanwhile
@@ -114,6 +130,12 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
114130 if !canWrite(p, name) {
115131 return fmt.Errorf("%w: no write access to %s", domain.ErrRejected, name)
116132 }
133+ for _, up := range updates {
134+ if !p.AllowsRef(up.Ref) {
135+ return fmt.Errorf("%w: %s is outside the branches this token may push (%s)",
136+ domain.ErrRejected, up.Ref, strings.Join(p.BranchPrefixes(), ", "))
137+ }
138+ }
117139 log := zerowrap.FromCtx(ctx)
118140 log.Debug().Str("repo", name.String()).Int("refs", len(updates)).Msg("push checked")
119141 return nil
@@ -121,7 +143,8 @@ func (u *Pushes) Check(ctx context.Context, p domain.Principal, name domain.Repo
121143
122144 // Record is the post-receive step of an accepted push: it sets pushed_at,
123145 // repairs HEAD when it points to a missing branch, tells the reader that the
124-// repository changed, publishes a PushEvent and schedules maintenance. Every
146+// repository changed, queues the workflows the push triggers, publishes a
147+// PushEvent and schedules maintenance. Every
125148 // step runs even when an earlier one fails; the failures are returned together
126149 // and the push itself is not affected.
127150 func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.RepoName, updates []domain.RefUpdate) error {
@@ -139,6 +162,9 @@ func (u *Pushes) Record(ctx context.Context, p domain.Principal, name domain.Rep
139162 if u.fresh != nil {
140163 u.fresh.Refresh(ctx, name)
141164 }
165+ if u.ci != nil {
166+ u.ci.OnPush(ctx, name, updates, p) // queues only: never slows or fails the push
167+ }
142168 u.bus.Publish(ctx, domain.PushEvent{
143169 Repo: name, Updates: append([]domain.RefUpdate(nil), updates...), At: now,
144170 Visibility: repo.Visibility, CreatedAt: repo.CreatedAt,
M internal/usecase/pushes_test.go +83 -0
@@ -76,6 +76,9 @@ func TestPushesOpen(t *testing.T) {
7676 {"missing, no create", domain.Root(), nil, false, domain.ErrNotFound},
7777 {"missing, reader may not create", tokenPrincipal(domain.ScopeRepoRead), nil, true, domain.ErrNotFound},
7878 {"missing, writer limited elsewhere", tokenPrincipal(domain.ScopeRepoWrite, "other"), nil, true, domain.ErrNotFound},
79+ {"existing, writer limited to branches", branchLimited(domain.ScopeRepoWrite), &priv, false, nil},
80+ {"missing, writer limited to branches may not create", branchLimited(domain.ScopeRepoWrite), nil, true, domain.ErrNotFound},
81+ {"missing, writer limited to branches and to the repo may not create", branchLimited(domain.ScopeRepoWrite, "tools"), nil, true, domain.ErrNotFound},
7982 }
8083 for _, tt := range tests {
8184 t.Run(tt.name, func(t *testing.T) {
@@ -203,6 +206,86 @@ func TestPushesCheck(t *testing.T) {
203206 })
204207 }
205208
209+// AC5: a token limited to branch prefixes pushes only under them.
210+func TestPushesCheckBranchPrefixes(t *testing.T) {
211+ t.Parallel()
212+ ctx := context.Background()
213+ n := name(t, "tools")
214+ limited := domain.TokenPrincipal(domain.Token{
215+ ID: "b1", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"agent/claude/", "bot/"},
216+ })
217+ up := func(old, new, ref string) domain.RefUpdate { return domain.RefUpdate{Old: old, New: new, Ref: ref} }
218+
219+ tests := []struct {
220+ name string
221+ updates []domain.RefUpdate
222+ refused string // the ref named by the error; "" = accepted
223+ }{
224+ {"create under the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/x")}, ""},
225+ {"force-push under the prefix", []domain.RefUpdate{up(oidA, oidB, "refs/heads/agent/claude/x")}, ""},
226+ {"delete under the prefix", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/agent/claude/x")}, ""},
227+ {"second prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/bot/y")}, ""},
228+ {"several under the prefixes", []domain.RefUpdate{
229+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/a"), up(domain.ZeroOID, oidA, "refs/heads/bot/b"),
230+ }, ""},
231+ {"main", []domain.RefUpdate{up(oidA, oidB, "refs/heads/main")}, "refs/heads/main"},
232+ {"delete main", []domain.RefUpdate{up(oidA, domain.ZeroOID, "refs/heads/main")}, "refs/heads/main"},
233+ {"the prefix itself without a name", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude")}, "refs/heads/agent/claude"},
234+ {"a sibling that shares the text", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claudex/y")}, "refs/heads/agent/claudex/y"},
235+ {"a tag", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}, "refs/tags/v1"},
236+ {"a tag named like the prefix", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/agent/claude/v1")}, "refs/tags/agent/claude/v1"},
237+ {"a notes ref", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/notes/commits")}, "refs/notes/commits"},
238+ {"a ref outside refs/heads", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/agent/claude/x")}, "refs/agent/claude/x"},
239+ {"a path trick", []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/agent/claude/../main")}, "refs/heads/agent/claude/../main"},
240+ {"mixed push: one allowed, one forbidden", []domain.RefUpdate{
241+ up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"), up(oidA, oidB, "refs/heads/main"),
242+ }, "refs/heads/main"},
243+ {"mixed push, forbidden first", []domain.RefUpdate{
244+ up(oidA, oidB, "refs/heads/main"), up(domain.ZeroOID, oidA, "refs/heads/agent/claude/ok"),
245+ }, "refs/heads/main"},
246+ }
247+ for _, tt := range tests {
248+ t.Run(tt.name, func(t *testing.T) {
249+ t.Parallel()
250+ f := newPushFixture(t)
251+ f.tokens.EXPECT().Get(ctx, "b1").Return(domain.Token{ID: "b1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
252+ err := f.pushes.Check(ctx, limited, n, tt.updates)
253+ if tt.refused == "" {
254+ require.NoError(t, err)
255+ return
256+ }
257+ require.ErrorIs(t, err, domain.ErrRejected)
258+ assert.Contains(t, err.Error(), tt.refused+" is outside the branches this token may push (agent/claude/, bot/)")
259+ })
260+ }
261+
262+ t.Run("a token without limit pushes anywhere", func(t *testing.T) {
263+ t.Parallel()
264+ f := newPushFixture(t)
265+ f.tokens.EXPECT().Get(ctx, "w1").Return(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)}, nil)
266+ free := domain.TokenPrincipal(domain.Token{ID: "w1", Scopes: scopes(domain.ScopeRepoWrite)})
267+ require.NoError(t, f.pushes.Check(ctx, free, n, []domain.RefUpdate{
268+ up(domain.ZeroOID, oidA, "refs/tags/v1"), up(oidA, oidB, "refs/heads/main"),
269+ }))
270+ })
271+
272+ t.Run("root pushes anywhere", func(t *testing.T) {
273+ t.Parallel()
274+ f := newPushFixture(t)
275+ require.NoError(t, f.pushes.Check(ctx, domain.Root(), n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/tags/v1")}))
276+ })
277+
278+ t.Run("a limited token without repo:write is rejected for the repository first", func(t *testing.T) {
279+ t.Parallel()
280+ f := newPushFixture(t)
281+ f.tokens.EXPECT().Get(ctx, "b2").Return(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead)}, nil)
282+ p := domain.TokenPrincipal(domain.Token{ID: "b2", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"a/"}})
283+ err := f.pushes.Check(ctx, p, n, []domain.RefUpdate{up(domain.ZeroOID, oidA, "refs/heads/a/x")})
284+ require.ErrorIs(t, err, domain.ErrRejected)
285+ assert.Contains(t, err.Error(), "no write access")
286+ })
287+}
288+
206289 func TestPushesRecord(t *testing.T) {
207290 t.Parallel()
208291 ctx := context.Background()
M internal/usecase/repos.go +12 -3
@@ -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,14 +32,22 @@ 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
3745 // after the metadata is written removes it again and discards the staged
3846 // repository.
3947 //
40-// The principal needs repo:write on the name: anonymous callers get
41-// domain.ErrUnauthenticated, other callers without it domain.ErrForbidden.
48+// The principal needs repo:write on the name and no branch prefixes: anonymous
49+// callers get domain.ErrUnauthenticated, other callers without it (a token
50+// limited to some branches included) domain.ErrForbidden.
4251 func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.CreateRepoParams) (domain.Repo, error) {
4352 if params.Name.IsZero() {
4453 return domain.Repo{}, fmt.Errorf("%w: empty name", domain.ErrInvalidName)
@@ -46,7 +55,7 @@ func (r *Repos) Create(ctx context.Context, p domain.Principal, params ports.Cre
4655 if p.IsAnonymous() {
4756 return domain.Repo{}, domain.ErrUnauthenticated
4857 }
49- if !canWrite(p, params.Name) {
58+ if !canCreateRepo(p, params.Name) {
5059 return domain.Repo{}, domain.ErrForbidden
5160 }
5261 if err := validateDescription(params.Description); err != nil {
M internal/usecase/repos_test.go +26 -2
@@ -86,6 +86,18 @@ func TestCreate(t *testing.T) {
8686 }
8787 })
8888
89+ t.Run("a token limited to branches may not create a repository", func(t *testing.T) {
90+ t.Parallel()
91+ f := newFixture(t) // the mocks expect no call: nothing is staged
92+ for _, p := range []domain.Principal{
93+ branchLimited(domain.ScopeRepoWrite),
94+ branchLimited(domain.ScopeRepoWrite, "tools"),
95+ } {
96+ _, err := f.repos.Create(ctx, p, ports.CreateRepoParams{Name: name(t, "tools")})
97+ assert.ErrorIs(t, err, domain.ErrForbidden)
98+ }
99+ })
100+
89101 t.Run("token with repo:write creates", func(t *testing.T) {
90102 t.Parallel()
91103 f := newFixture(t)
@@ -179,8 +191,10 @@ func TestCreate(t *testing.T) {
179191 })
180192 }
181193
182-func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
183- tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}}
194+// scopedToken is the principal of a token with one scope, limited to the
195+// repositories and to the branch prefixes given (none means no limit).
196+func scopedToken(s domain.Scope, prefixes []string, repos ...string) domain.Principal {
197+ tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}, BranchPrefixes: prefixes}
184198 for _, r := range repos {
185199 n, err := domain.ParseRepoName(r)
186200 if err != nil {
@@ -191,6 +205,16 @@ func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
191205 return domain.TokenPrincipal(tok)
192206 }
193207
208+func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
209+ return scopedToken(s, nil, repos...)
210+}
211+
212+// branchLimited is a token with one scope, optionally limited to repositories,
213+// and limited to the branches under "agent/".
214+func branchLimited(s domain.Scope, repos ...string) domain.Principal {
215+ return scopedToken(s, []string{"agent/"}, repos...)
216+}
217+
194218 func TestGetAndListAccess(t *testing.T) {
195219 t.Parallel()
196220 ctx := context.Background()
M internal/usecase/repos_write.go +16 -2
@@ -46,7 +46,7 @@ func (r *Repos) View(ctx context.Context, repo domain.Repo) (ports.RepoView, err
4646
4747 // Update changes the description, the visibility, the default branch and the
4848 // merge settings of a repository, and publishes RepoUpdated. The principal needs repo:admin on it
49-// (see adminAccess for what the others learn). At least one field must be set,
49+// (see adminAccess for what the others learn) and no branch prefixes. At least one field must be set,
5050 // else domain.ErrInvalid. A default branch that does not exist gives
5151 // domain.ErrPreconditionFailed; nothing is written before every check passed.
5252 // The metadata is written first, then HEAD, and RepoUpdated is published after
@@ -174,7 +174,7 @@ func (r *Repos) updateMeta(ctx context.Context, name domain.RepoName, params por
174174
175175 // Delete moves the repository directory to the trash, removes its metadata
176176 // and publishes RepoDeleted. The principal needs repo:admin on it (see
177-// adminAccess). A repository whose directory is already gone, left by a
177+// adminAccess) and no branch prefixes. A repository whose directory is already gone, left by a
178178 // delete that failed half way, loses its metadata.
179179 func (r *Repos) Delete(ctx context.Context, p domain.Principal, name domain.RepoName) error {
180180 repo, err := lookupRepo(ctx, r.meta, name)
@@ -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
M internal/usecase/repos_write_test.go +26 -0
@@ -165,6 +165,32 @@ func TestUpdate(t *testing.T) {
165165 })
166166 }
167167
168+ t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
169+ t.Parallel()
170+ modes := []domain.MergeMode{domain.MergeModeSquash}
171+ block := true
172+ for name, params := range map[string]ports.UpdateRepoParams{
173+ "default branch": {DefaultBranch: ptr("dev")},
174+ "visibility": {Visibility: ptr(domain.VisibilityPublic)},
175+ "description": {Description: ptr("x")},
176+ "merge modes": {AllowedMergeModes: &modes},
177+ "checks rule": {FailingChecksBlock: &block},
178+ } {
179+ f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
180+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
181+ _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params)
182+ assert.ErrorIs(t, err, domain.ErrForbidden, name)
183+ }
184+ })
185+
186+ t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
187+ t.Parallel()
188+ f := newFixture(t) // no Trash, no Delete
189+ f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
190+ err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n)
191+ assert.ErrorIs(t, err, domain.ErrForbidden)
192+ })
193+
168194 t.Run("a missing repository is not found", func(t *testing.T) {
169195 t.Parallel()
170196 f := newFixture(t)
M internal/usecase/sessions.go +2 -2
@@ -74,13 +74,13 @@ func NewSessions(ctx context.Context, store ports.SessionStore, clock ports.Cloc
7474 }
7575
7676 // requireOwner is the access rule of the session API: root, or a stored token
77-// with every scope and no repository list.
77+// with every scope, no repository list and no branch prefixes.
7878 func requireOwner(p domain.Principal) error {
7979 switch {
8080 case p.IsAnonymous():
8181 return domain.ErrUnauthenticated
8282 case !p.HasOwnerRights():
83- return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope and no repository list", domain.ErrForbidden)
83+ return fmt.Errorf("%w: managing web sessions needs the admin token or a token with every scope, no repository list and no branch prefixes", domain.ErrForbidden)
8484 }
8585 return nil
8686 }
M internal/usecase/tokens.go +26 -9
@@ -54,7 +54,8 @@ func NewTokens(store ports.TokenStore, clock ports.Clock, sessions ports.Session
5454 // domain.ErrUnauthenticated, others without it domain.ErrForbidden. A token
5555 // cannot create a token more powerful than itself: every scope must be one it
5656 // holds, the repositories must lie within its list (a limited token cannot
57-// create an unlimited one) and the expiry must not outlast its own. Violations
57+// create an unlimited one), the same for branch prefixes (each must start
58+// with one of its own) and the expiry must not outlast its own. Violations
5859 // give domain.ErrForbidden. Root has no such limits. Invalid settings give
5960 // domain.ErrInvalidToken (this includes the labels "owner" and "root", which
6061 // are reserved, in any case and around spaces), an unknown scope
@@ -88,7 +89,7 @@ func (t *Tokens) Create(ctx context.Context, p domain.Principal, params ports.Cr
8889 log := zerowrap.FromCtx(ctx)
8990 log.Info().
9091 Str("token_id", tok.ID).Str("label", tok.Label).
91- Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).
92+ Int("scopes", len(tok.Scopes)).Int("repos", len(tok.Repos)).Int("branch_prefixes", len(tok.BranchPrefixes)).
9293 Str("created_by", p.Label()).Str("created_by_token", p.TokenID()).
9394 Msg("token created")
9495 return ports.CreatedToken{Token: tok, Secret: secret}, nil
@@ -122,10 +123,15 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er
122123 if !params.ExpiresAt.IsZero() && !params.ExpiresAt.After(now) {
123124 return domain.Token{}, fmt.Errorf("%w: expiry is not in the future", domain.ErrInvalidToken)
124125 }
126+ prefixes, err := domain.ParseBranchPrefixes(params.BranchPrefixes)
127+ if err != nil {
128+ return domain.Token{}, err
129+ }
125130 tok := domain.Token{
126- Label: params.Label,
127- CreatedAt: now,
128- ExpiresAt: params.ExpiresAt.UTC(),
131+ Label: params.Label,
132+ BranchPrefixes: prefixes,
133+ CreatedAt: now,
134+ ExpiresAt: params.ExpiresAt.UTC(),
129135 }
130136 // Canonical order, no duplicates.
131137 for _, s := range domain.AllScopes() {
@@ -143,8 +149,9 @@ func buildToken(params ports.CreateTokenParams, now time.Time) (domain.Token, er
143149 }
144150
145151 // checkNoEscalation enforces that tok is within the rights of p: every scope
146-// held by p, the repositories within its list and the expiry not beyond its
147-// own. Root has no limits.
152+// held by p, the repositories within its list, the branch prefixes within its
153+// own (a limited p only makes tokens that are limited too, each prefix under
154+// one of its own) and the expiry not beyond its own. Root has no limits.
148155 func checkNoEscalation(p domain.Principal, tok domain.Token) error {
149156 if p.IsRoot() {
150157 return nil
@@ -164,6 +171,16 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
164171 }
165172 }
166173 }
174+ if p.IsBranchLimited() {
175+ if len(tok.BranchPrefixes) == 0 {
176+ return fmt.Errorf("%w: the creating token is limited to branch prefixes, so the new token must be too", domain.ErrForbidden)
177+ }
178+ for _, bp := range tok.BranchPrefixes {
179+ if !p.CoversBranchPrefix(bp) {
180+ return fmt.Errorf("%w: branch prefix %s is outside the creating token", domain.ErrForbidden, bp)
181+ }
182+ }
183+ }
167184 if exp := p.ExpiresAt(); !exp.IsZero() {
168185 if tok.ExpiresAt.IsZero() || tok.ExpiresAt.After(exp) {
169186 return fmt.Errorf("%w: expiry outlasts the creating token", domain.ErrForbidden)
@@ -173,7 +190,7 @@ func checkNoEscalation(p domain.Principal, tok domain.Token) error {
173190 }
174191
175192 // List returns the stored tokens the principal could have created, oldest
176-// first: those whose scopes, repositories and expiry lie within its own (the
193+// first: those whose scopes, repositories, branch prefixes and expiry lie within its own (the
177194 // no-escalation rule of Create). Root sees every token. It needs token:admin.
178195 func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token, error) {
179196 if err := requireTokenAdmin(p); err != nil {
@@ -195,7 +212,7 @@ func (t *Tokens) List(ctx context.Context, p domain.Principal) ([]domain.Token,
195212 // Create), so that it does not show itself. Root may revoke any token.
196213 //
197214 // A token that has owner rights (HasOwnerRights: every scope, no repository
198-// list) could open web sessions, so revoking it also signs every browser out,
215+// list and no branch prefixes) could open web sessions, so revoking it also signs every browser out,
199216 // as `ermite web logout --all` does: web sessions are not tied to the token
200217 // that opened them. The revocation holds even if signing out fails, which is
201218 // then reported as an error.
M internal/usecase/tokens_test.go +117 -0
@@ -177,6 +177,14 @@ func TestTokensCreateNoEscalation(t *testing.T) {
177177 })
178178 }
179179
180+ adminOn := func(prefixes []string) domain.Principal {
181+ return domain.TokenPrincipal(domain.Token{
182+ ID: "creator",
183+ Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead, domain.ScopeRepoWrite),
184+ BranchPrefixes: prefixes,
185+ })
186+ }
187+
180188 tests := []struct {
181189 name string
182190 principal domain.Principal
@@ -211,6 +219,22 @@ func TestTokensCreateNoEscalation(t *testing.T) {
211219 ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), ExpiresAt: exp}, nil},
212220 {"root: everything", domain.Root(),
213221 ports.CreateTokenParams{Label: "x", Scopes: domain.AllScopes()}, nil},
222+ {"branch-limited creator, child within its prefix", adminOn([]string{"agent/claude/"}),
223+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/sub/"}}, nil},
224+ {"branch-limited creator, child with the same prefix", adminOn([]string{"agent/claude/"}),
225+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"}}, nil},
226+ {"branch-limited creator, child with one of two prefixes", adminOn([]string{"a/", "b/"}),
227+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"b/x/", "a/"}}, nil},
228+ {"branch-limited creator, child without prefixes", adminOn([]string{"agent/claude/"}),
229+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead)}, domain.ErrForbidden},
230+ {"branch-limited creator, child with a wider prefix", adminOn([]string{"agent/claude/"}),
231+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}, domain.ErrForbidden},
232+ {"branch-limited creator, child with a sibling prefix", adminOn([]string{"agent/claude/"}),
233+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}, domain.ErrForbidden},
234+ {"branch-limited creator, one prefix of two outside", adminOn([]string{"agent/claude/"}),
235+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/a/", "main/"}}, domain.ErrForbidden},
236+ {"unlimited creator may limit branches", admin(nil, time.Time{}),
237+ ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"z/"}}, nil},
214238 }
215239 for _, tt := range tests {
216240 t.Run(tt.name, func(t *testing.T) {
@@ -283,6 +307,7 @@ func TestTokensRevokeOwnerRightsEndsWebSessions(t *testing.T) {
283307 "every scope, by an owner key": {ownerTok, domain.TokenPrincipal(domain.Token{ID: "adm", Scopes: domain.AllScopes()}), true},
284308 "one scope short": {domain.Token{ID: "p", Scopes: domain.AllScopes()[1:]}, domain.Root(), false},
285309 "repo list": {domain.Token{ID: "l", Scopes: domain.AllScopes(), Repos: names(t, "a")}, domain.Root(), false},
310+ "branch prefixes": {domain.Token{ID: "b", Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/"}}, domain.Root(), false},
286311 } {
287312 t.Run(name, func(t *testing.T) {
288313 t.Parallel()
@@ -551,3 +576,95 @@ func TestAuthenticate(t *testing.T) {
551576 assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
552577 })
553578 }
579+
580+func TestTokensCreateBranchPrefixes(t *testing.T) {
581+ t.Parallel()
582+ ctx := context.Background()
583+
584+ t.Run("stored in canonical form: duplicates dropped, order kept", func(t *testing.T) {
585+ t.Parallel()
586+ f := newTokenFixture(t)
587+ f.store.EXPECT().Create(ctx, mock.MatchedBy(func(tok domain.Token) bool {
588+ return assert.ObjectsAreEqual([]string{"b/", "a/c/"}, tok.BranchPrefixes)
589+ })).Return(nil)
590+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
591+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"b/", "a/c/", "b/"},
592+ })
593+ require.NoError(t, err)
594+ assert.Equal(t, []string{"b/", "a/c/"}, got.Token.BranchPrefixes)
595+ })
596+
597+ t.Run("no prefixes stays unlimited", func(t *testing.T) {
598+ t.Parallel()
599+ f := newTokenFixture(t)
600+ f.store.EXPECT().Create(ctx, mock.Anything).Return(nil)
601+ got, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{Label: "x", Scopes: scopes(domain.ScopeRepoWrite)})
602+ require.NoError(t, err)
603+ assert.Empty(t, got.Token.BranchPrefixes)
604+ })
605+
606+ t.Run("invalid prefixes are refused with ErrInvalidToken", func(t *testing.T) {
607+ t.Parallel()
608+ nine := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/", "i/"}
609+ for _, bad := range [][]string{{"agent"}, {"/"}, {""}, {"a b/"}, {"../"}, {"refs/heads/a/"}, {strings.Repeat("a", 100) + "/"}, {"a/", "bad"}, nine} {
610+ f := newTokenFixture(t) // the store expects no Create
611+ _, err := f.tokens.Create(ctx, domain.Root(), ports.CreateTokenParams{
612+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: bad,
613+ })
614+ assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
615+ }
616+ })
617+
618+ t.Run("a corrupt empty creator prefix does not cover every child prefix", func(t *testing.T) {
619+ t.Parallel()
620+ f := newTokenFixture(t) // the store expects no Create
621+ corrupt := domain.TokenPrincipal(domain.Token{
622+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoWrite), BranchPrefixes: []string{""},
623+ })
624+ _, err := f.tokens.Create(ctx, corrupt, ports.CreateTokenParams{
625+ Label: "x", Scopes: scopes(domain.ScopeRepoWrite), BranchPrefixes: []string{"main/"},
626+ })
627+ assert.ErrorIs(t, err, domain.ErrForbidden)
628+ })
629+
630+ t.Run("revoking is bounded by the prefixes of a limited token", func(t *testing.T) {
631+ t.Parallel()
632+ ctx := context.Background()
633+ creator := domain.TokenPrincipal(domain.Token{
634+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
635+ })
636+ inside := domain.Token{ID: "in", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
637+ wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
638+ sibling := domain.Token{ID: "sib", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claudex/"}}
639+ free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
640+
641+ f := newTokenFixture(t)
642+ for _, tok := range []domain.Token{wide, sibling, free} {
643+ f.store.EXPECT().Get(ctx, tok.ID).Return(tok, nil).Once()
644+ }
645+ // Out of reach: not found, like an unknown id, and nothing is revoked.
646+ for _, id := range []string{"wide", "sib", "free"} {
647+ assert.ErrorIs(t, f.tokens.Revoke(ctx, creator, id), domain.ErrNotFound, id)
648+ }
649+ // Within its prefixes it revokes, and no web session is signed out.
650+ f.store.EXPECT().Get(ctx, "in").Return(inside, nil).Once()
651+ f.store.EXPECT().Revoke(ctx, "in").Return(nil).Once()
652+ require.NoError(t, f.tokens.Revoke(ctx, creator, "in"))
653+ })
654+
655+ t.Run("listing hides tokens a limited creator could not have made", func(t *testing.T) {
656+ t.Parallel()
657+ f := newTokenFixture(t)
658+ mine := domain.Token{ID: "mine", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/x/"}}
659+ wide := domain.Token{ID: "wide", Scopes: scopes(domain.ScopeRepoRead), BranchPrefixes: []string{"agent/"}}
660+ free := domain.Token{ID: "free", Scopes: scopes(domain.ScopeRepoRead)}
661+ f.store.EXPECT().List(ctx).Return([]domain.Token{mine, wide, free}, nil)
662+ p := domain.TokenPrincipal(domain.Token{
663+ ID: "c", Scopes: scopes(domain.ScopeTokenAdmin, domain.ScopeRepoRead), BranchPrefixes: []string{"agent/claude/"},
664+ })
665+ got, err := f.tokens.List(ctx, p)
666+ require.NoError(t, err)
667+ require.Len(t, got, 1)
668+ assert.Equal(t, "mine", got[0].ID)
669+ })
670+}
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+}
M proto/ermite/storage/repo.proto +3 -0
@@ -53,4 +53,7 @@ message Token {
5353 google.protobuf.Timestamp created_at = 7;
5454 // Unset until the token is first used.
5555 google.protobuf.Timestamp last_used_at = 8;
56+ // Branch prefixes, each ending with "/", that the token may write; empty
57+ // means every branch. Added in storage schema 2.
58+ repeated string branch_prefixes = 9;
5659 }
M skills/ermite/CLI.md +8 -5
@@ -1,6 +1,6 @@
11 # ermite CLI reference
22
3-Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads). Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.
3+Remote commands read `ERMITE_URL` (`http://` or `https://`) and `ERMITE_TOKEN` (optional for public reads); they fall back to the `url` and `token.cmd` of the config file (`~/.config/ermite/config`, the platform config dir on macOS and Windows, or the file named by `ERMITE_CONFIG`; `ermite config show` prints the path), the environment first (`token.cmd` is only run for the server of the file: with a different `ERMITE_URL` set `ERMITE_TOKEN` too). Agents: use `ERMITE_TOKEN`, or your own `ERMITE_CONFIG` file; do not edit the owner's `~/.config/ermite/config` and never run `token.cmd` yourself or print what it prints. Run `ermite <command> --help` for the authoritative text of a command and its JSON fields.
44
55 Errors are one line on standard error (`ermite: ...`) with exit status 1. `--json` prints stable field names. `ermite completion bash|zsh|fish` completes repository names from the server.
66
@@ -20,7 +20,7 @@ Repository JSON: `name`, `description`, `visibility`, `default_branch`, `created
2020
2121 | Command | Scope | Notes |
2222 |---|---|---|
23-| `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope |
23+| `token create <label> [--preset cli\|agent\|ci] [--scope S] [--repo R] [--branch-prefix P] [--expires 30d\|2w\|12h\|2027-01-31] [--json]` | `token:admin` | secret printed once on standard output; there is no default scope |
2424 | `token list [--json]` | `token:admin` | secrets are never shown; the admin token is not listed |
2525 | `token revoke <id>` | `token:admin` | |
2626
@@ -44,7 +44,9 @@ Scopes:
4444 | `token:admin` | create, list and revoke tokens |
4545 | `ci:write`, `secrets:write`, `runner:register` | reserved for CI |
4646
47-A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `expires_at`, `created_at`, `last_used_at`.
47+A token can never grant more than the token that creates it. `--repo` (repeatable) limits it to those repositories; the default is all. Labels are free text up to 100 bytes; `owner` and `root` are reserved. Token JSON: `id`, `label`, `scopes`, `repos`, `branch_prefixes`, `expires_at`, `created_at`, `last_used_at`.
48+
49+`--branch-prefix` (repeatable, up to 8, each ending with `/`, for example `agent/claude/`) limits the branches the token may write; the default is all. A limited token pushes, force-pushes and deletes only `refs/heads/<prefix>…`: any other ref (`main`, tags, notes) refuses the whole push, naming the ref. It cannot create or delete a repository or change its settings (`repo edit`: default branch, visibility, description, merge modes), merges only into branches under its prefixes (and deletes only a source branch under them), never has owner rights, and creates only tokens within its own prefixes. A token for an agent: `ermite token create claude --preset agent --scope repo:read --repo <repo> --branch-prefix agent/claude/`. If you hold such a token, work on a branch under your prefix and open a merge request; do not try `main` or tags.
4850
4951 ## Merge requests
5052
@@ -77,9 +79,10 @@ Merge request JSON: `repo`, `number`, `title`, `description`, `source_branch`, `
7779 | Command | Notes |
7880 |---|---|
7981 | `serve` | configured by `ERMITE_*` environment variables only (see the project README) |
80-| `version [--json]` | client version; with `ERMITE_URL`, the server version and features |
82+| `version [--json]` | client version; with a server URL (`ERMITE_URL` or the config file), the server version and features |
83+| `config show [--json]` / `config set <url\|token.cmd> <value>` / `config unset <key>` | the CLI config file; `token.cmd` is a command that prints the token (any secret manager), never the token itself; `show` never runs it |
8184 | `signing-key show` | public key and `allowed_signers` line to check squash commits with `git verify-commit` |
82-| `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope and no repository list; the link is a secret |
85+| `web login [--json]` | one-time sign-in link (about 60 s); needs the admin token or a token with every scope, no repository list and no branch prefix; the link is a secret |
8386 | `web logout --all` | signs every browser out |
8487 | `web passkey list [--json]` | the owner's passkeys (`id`, `name`, `created_at`, `last_used_at`, `synced`); same rights as `web login`; fails with "passkeys are not available on this server" when the public URL is not https with a host name |
8588 | `web passkey remove <id>` | removes a passkey and signs every browser out; the id comes from `list` |
M skills/ermite/SKILL.md +2 -0
@@ -16,6 +16,8 @@ ermite version # client and server versions, warns about
1616 ermite repo list
1717 ```
1818
19+Instead of exporting them, the owner can keep `url` and a `token.cmd` (a command that prints the token, such as `pass show ermite/token`) in `~/.config/ermite/config`; as an agent, set `ERMITE_TOKEN` or point `ERMITE_CONFIG` at your own file.
20+
1921 Never print a token, commit it, log it or paste it into a chat. Prefer a Git credential helper to a token in a URL. Use `https://`: a token sent over plain `http://` to a remote host triggers a warning.
2022
2123 ## Git

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 42

↑↓ moveenter openesc close