From b019c437f3423741571b25db2f559b1c5f862a3a Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Wed, 28 Oct 2026 21:10:09 +0000 Subject: [PATCH 1/9] cli: include CI provider in run watch events --- avrea_cli/commands/run.py | 19 ++++++++++---- docs/REFERENCE.md | 4 +++ docs/reference.json | 2 +- docs/starlight/reference/run.md | 4 +++ man/man1/avr-run-watch.1 | 2 ++ tests/test_run_view.py | 44 +++++++++++++++++++++++++++++++++ 6 files changed, 69 insertions(+), 6 deletions(-) diff --git a/avrea_cli/commands/run.py b/avrea_cli/commands/run.py index 187c6bf..8b80bdd 100644 --- a/avrea_cli/commands/run.py +++ b/avrea_cli/commands/run.py @@ -883,11 +883,14 @@ def _emit_ndjson_event(event: dict[str, Any]) -> None: raise SystemExit(0) from None -def _job_event_payload(event: str, run_id: str, job: dict[str, Any], timestamp: str) -> dict[str, Any]: +def _job_event_payload( + event: str, run_id: str, job: dict[str, Any], timestamp: str, ci_provider: str +) -> dict[str, Any]: return { "event": event, "timestamp": timestamp, "run_id": run_id, + "ci_provider": ci_provider, "avrea_job_id": job.get("job_id"), "platform_job_id": job.get("platform_job_id"), "job_name": job.get("job_name"), @@ -903,12 +906,12 @@ def watch_run_loop_ndjson(client: ApiClient, org_id: str, run_id: str, *, interv event kinds, with intentionally different payloads: - ``job_started`` / ``job_completed`` — emitted via ``_job_event_payload`` - and share the keys ``event``, ``timestamp``, ``run_id``, + and share the keys ``event``, ``timestamp``, ``run_id``, ``ci_provider``, ``avrea_job_id``, ``platform_job_id``, ``job_name``, ``state``, ``conclusion``. ``conclusion`` is ``null`` until ``job_completed``. - ``run_completed`` — top-level run summary; carries ``event``, - ``timestamp``, ``run_id``, ``status``, ``conclusion``, + ``timestamp``, ``run_id``, ``ci_provider``, ``status``, ``conclusion``, ``duration_seconds``. It deliberately omits the per-job fields — consumers correlate via ``run_id`` to the preceding ``job_*`` events. @@ -965,6 +968,7 @@ def record_failure(reason: str, msg: str) -> None: continue now = datetime.now(UTC).isoformat().replace("+00:00", "Z") + ci_provider = run_data.get("ci_provider", "github_actions") for j in run_data.get("jobs") or []: j_id = j.get("job_id") if not j_id: @@ -974,9 +978,9 @@ def record_failure(reason: str, msg: str) -> None: if prev != j_state: last_state[j_id] = j_state if j_state == "in_progress": - _emit_ndjson_event(_job_event_payload("job_started", run_id, j, now)) + _emit_ndjson_event(_job_event_payload("job_started", run_id, j, now, ci_provider)) elif j_state == "completed": - _emit_ndjson_event(_job_event_payload("job_completed", run_id, j, now)) + _emit_ndjson_event(_job_event_payload("job_completed", run_id, j, now, ci_provider)) status = run_data.get("status", "unknown") if status == "completed": @@ -985,6 +989,7 @@ def record_failure(reason: str, msg: str) -> None: "event": "run_completed", "timestamp": now, "run_id": run_id, + "ci_provider": ci_provider, "status": status, "conclusion": run_data.get("conclusion"), "duration_seconds": run_data.get("duration_seconds"), @@ -1188,6 +1193,10 @@ def run_watch( `avr run view`. Without RUN, auto-selects the latest in-progress run. Pass --repo (repeatable) to scope the auto-select to specific repositories. + NDJSON job and run completion events include ci_provider + (github_actions, gitlab_ci, or avrea_ci). Native jobs have a null + platform_job_id; correlate events using run_id and avrea_job_id. + \b Examples: avr run watch diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 9d12a68..be24e50 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -402,6 +402,10 @@ RUN accepts the same Avrea IDs, GitHub run IDs, and run URLs as Pass --repo (repeatable) to scope the auto-select to specific repositories. ``` +NDJSON job and run completion events include ci_provider +(github_actions, gitlab_ci, or avrea_ci). Native jobs have a null +platform_job_id; correlate events using run_id and avrea_job_id. + ```sh Examples: avr run watch diff --git a/docs/reference.json b/docs/reference.json index 482622b..fbe3d19 100644 --- a/docs/reference.json +++ b/docs/reference.json @@ -1623,7 +1623,7 @@ "full": "avr run watch", "is_group": false, "short_help": "Watch a workflow run until it completes.", - "help": "Watch a workflow run until it completes.\n\n\b\nRUN accepts the same Avrea IDs, GitHub run IDs, and run URLs as\n`avr run view`. Without RUN, auto-selects the latest in-progress run.\nPass --repo (repeatable) to scope the auto-select to specific repositories.\n\n\b\nExamples:\n avr run watch\n avr run watch --repo acme/web\n avr run watch --repo a/x --repo b/y\n avr run watch run-019e5a2e474e71c297e452279d4bcdea --exit-status\n avr run watch run-019e5a2e474e71c297e452279d4bcdea --ndjson | jq -c .\n", + "help": "Watch a workflow run until it completes.\n\n\b\nRUN accepts the same Avrea IDs, GitHub run IDs, and run URLs as\n`avr run view`. Without RUN, auto-selects the latest in-progress run.\nPass --repo (repeatable) to scope the auto-select to specific repositories.\n\nNDJSON job and run completion events include ci_provider\n(github_actions, gitlab_ci, or avrea_ci). Native jobs have a null\nplatform_job_id; correlate events using run_id and avrea_job_id.\n\n\b\nExamples:\n avr run watch\n avr run watch --repo acme/web\n avr run watch --repo a/x --repo b/y\n avr run watch run-019e5a2e474e71c297e452279d4bcdea --exit-status\n avr run watch run-019e5a2e474e71c297e452279d4bcdea --ndjson | jq -c .\n", "epilog": null, "deprecated": false, "hidden": false, diff --git a/docs/starlight/reference/run.md b/docs/starlight/reference/run.md index f4c2cc2..2d8a802 100644 --- a/docs/starlight/reference/run.md +++ b/docs/starlight/reference/run.md @@ -239,6 +239,10 @@ RUN accepts the same Avrea IDs, GitHub run IDs, and run URLs as Pass --repo (repeatable) to scope the auto-select to specific repositories. ``` +NDJSON job and run completion events include ci_provider +(github_actions, gitlab_ci, or avrea_ci). Native jobs have a null +platform_job_id; correlate events using run_id and avrea_job_id. + ```sh Examples: avr run watch diff --git a/man/man1/avr-run-watch.1 b/man/man1/avr-run-watch.1 index d36c0dc..cafd2fa 100644 --- a/man/man1/avr-run-watch.1 +++ b/man/man1/avr-run-watch.1 @@ -15,6 +15,8 @@ Pass --repo (repeatable) to scope the auto-select to specific repositories. .fi .RE .PP +NDJSON job and run completion events include ci_provider (github_actions, gitlab_ci, or avrea_ci). Native jobs have a null platform_job_id; correlate events using run_id and avrea_job_id. +.PP .RS 4 .nf Examples: diff --git a/tests/test_run_view.py b/tests/test_run_view.py index 9fd9932..a5ad88a 100644 --- a/tests/test_run_view.py +++ b/tests/test_run_view.py @@ -5,6 +5,7 @@ from avrea_cli.commands.run import _watch_title from avrea_cli.commands.run import watch_run_loop from avrea_cli.main import cli +from click.testing import CliRunner from io import StringIO from unittest.mock import MagicMock import click @@ -356,6 +357,49 @@ def boom(client, config, org_id, names): class TestRunWatchJson: """NDJSON event stream: one JSON object per line, transitions only.""" + @pytest.mark.parametrize("provider", ["github_actions", "gitlab_ci", "avrea_ci", None]) + @pytest.mark.parametrize("conclusion", ["success", "failure"]) + def test_provider_survives_watch_transitions( + self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch, provider: str | None, conclusion: str + ) -> None: + native = provider == "avrea_ci" + run_id = f"{'awr' if native else 'run'}-019e5a2e474e72d711642b726b044016" + job_id = f"{'awj' if native else 'job'}-019e5a2e474e72d711642b726b044017" + platform_job_id = None if native else 100 + snapshots = [] + for state in ["queued", "in_progress", "in_progress", "completed"]: + run = { + "run_id": run_id, + "status": "completed" if state == "completed" else "in_progress", + "conclusion": conclusion if state == "completed" else None, + "duration_seconds": 30 if state == "completed" else None, + "jobs": [ + { + "job_id": job_id, + "platform_job_id": platform_job_id, + "job_name": "Build", + "state": state, + "conclusion": conclusion if state == "completed" else None, + } + ], + } + if provider is not None: + run["ci_provider"] = provider + snapshots.append({"data": run}) + monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_get", self._stateful_get(snapshots)) + monkeypatch.setattr("time.sleep", lambda _: None) + + result = runner.invoke(cli, ["run", "watch", run_id, "--ndjson", "--exit-status"]) + + assert result.exit_code == (0 if conclusion == "success" else 1), result.output + events = [json.loads(line) for line in result.output.splitlines() if line.startswith("{")] + assert [event["event"] for event in events] == ["job_started", "job_completed", "run_completed"] + assert [event["ci_provider"] for event in events] == [provider or "github_actions"] * 3 + assert [event["run_id"] for event in events] == [run_id] * 3 + assert [event["avrea_job_id"] for event in events[:2]] == [job_id] * 2 + assert [event["platform_job_id"] for event in events[:2]] == [platform_job_id] * 2 + assert events[-1]["conclusion"] == conclusion + def _stateful_get(self, snapshots): """Return a public_get fake that returns each snapshot in turn.""" idx = {"i": 0} From 93cf1601a3b1cbfe8ece1d3bc5c4e500f2ce2e07 Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Wed, 28 Oct 2026 21:12:36 +0000 Subject: [PATCH 2/9] cli: make run watch display native CI identity and availability --- avrea_cli/commands/run.py | 54 ++++++++++++++++++++++----------------- tests/test_run_view.py | 38 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 23 deletions(-) diff --git a/avrea_cli/commands/run.py b/avrea_cli/commands/run.py index 8b80bdd..6e64a0d 100644 --- a/avrea_cli/commands/run.py +++ b/avrea_cli/commands/run.py @@ -1100,10 +1100,12 @@ def watch_run_loop( now_str = datetime.now(UTC).strftime("%H:%M:%S UTC") title = run_data.get("display_title", "") run_number = run_data.get("run_number", "?") + run_label = f"#{run_number}" if run_number is not None else run_id + ci_provider = run_data.get("ci_provider", "github_actions") status = run_data.get("status", "unknown") conclusion = run_data.get("conclusion") - header = f"Watching run #{run_number}: {title} -- {now_str}" + header = f"Watching run {run_label}: {title} [{ci_provider}] -- {now_str}" if console_url and slug: header = hyperlink(header, run_url(console_url, slug, run_id)) click.echo(header + "\n") @@ -1111,27 +1113,33 @@ def watch_run_loop( run_jobs = run_data.get("jobs") or [] if run_jobs: _print_run_jobs(run_jobs, console_url=console_url, slug=slug) - # Hints for drilling into a job. Static lines (job ids - # vary across rows), but the verbs are the demo flow. click.echo() - click.echo( - click.style( - " → avr job logs --follow # tail logs of a running job", - fg=DIM_FG, - ) - ) - click.echo( - click.style( - " → avr job metrics --watch # live CPU/mem/IO gauges", - fg=DIM_FG, - ) - ) - click.echo( - click.style( - " → avr job ssh # ssh into the runner VM", - fg=DIM_FG, - ) - ) + match ci_provider: + case "avrea_ci": + click.echo( + click.style(" Native job logs, metrics, and SSH are not available yet.", fg=DIM_FG) + ) + case "github_actions" | "gitlab_ci": + click.echo( + click.style( + " → avr job logs --follow # tail logs of a running job", + fg=DIM_FG, + ) + ) + click.echo( + click.style( + " → avr job metrics --watch # live CPU/mem/IO gauges", + fg=DIM_FG, + ) + ) + click.echo( + click.style( + " → avr job ssh # ssh into the runner VM", + fg=DIM_FG, + ) + ) + case _: + raise ValueError(f"Unsupported CI provider: {ci_provider}") else: click.echo(" No jobs yet.") @@ -1142,13 +1150,13 @@ def watch_run_loop( if status == "completed": dur = format_duration(run_data.get("duration_seconds")) conclusion_str = format_conclusion_colored(status, conclusion) - click.echo(f"\nRun #{run_number} completed: {conclusion_str} ({dur})") + click.echo(f"\nRun {run_label} completed: {conclusion_str} ({dur})") # Reset title to bare ``avr ▸ `` before notify # so the conclusion isn't echoed twice (once via Ghostty's # source line, once in the notification body). wf_name = ((run_data.get("workflow") or {}).get("name") or "run").strip() term_title.set(f"avr ▸ {wf_name}") - notify(f"Run #{run_number} {title}: {conclusion or 'completed'}") + notify(f"Run {run_label} {title}: {conclusion or 'completed'}") if exit_status and conclusion != "success": sys.exit(1) return diff --git a/tests/test_run_view.py b/tests/test_run_view.py index a5ad88a..719ba1a 100644 --- a/tests/test_run_view.py +++ b/tests/test_run_view.py @@ -316,6 +316,44 @@ def test_job_filter_no_match_warns(self, runner, monkeypatch): class TestRunWatch: + @pytest.mark.parametrize("provider", ["github_actions", "gitlab_ci", "avrea_ci"]) + def test_watch_labels_and_hints_match_provider( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, provider: str + ) -> None: + native = provider == "avrea_ci" + run_id = f"{'awr' if native else 'run'}-019e5a2e474e72d711642b726b044016" + run_label = run_id if native else "#42" + client = MagicMock() + client.public_get.return_value = { + "data": { + "run_id": run_id, + "ci_provider": provider, + "run_number": None if native else 42, + "display_title": "Build", + "status": "completed", + "conclusion": "success", + "jobs": [ + { + "job_id": f"{'awj' if native else 'job'}-{'1' * 32}", + "job_name": "Build", + "state": "completed", + } + ], + } + } + notify = MagicMock() + monkeypatch.setattr("avrea_cli.commands.run.notify", notify) + + watch_run_loop(client, "org-default", run_id, interval=1, exit_status=False) + + output = capsys.readouterr().out + assert f"Watching run {run_label}: Build" in output + assert provider in output + assert f"Run {run_label} completed:" in output + notify.assert_called_once_with(f"Run {run_label} Build: success") + for command in ("avr job logs", "avr job metrics", "avr job ssh"): + assert (command in output) is (not native) + def test_stopped_by_ctrl_c_exits_130(self, capsys): """Exit 0 would read as a successful run under --exit-status.""" client = MagicMock() From a618ea9ff413a3456859d7cd27a8ad4f572021cc Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Wed, 28 Oct 2026 21:53:27 +0000 Subject: [PATCH 3/9] cli: report unavailable native CI operations explicitly --- avrea_cli/commands/job.py | 6 ++++- avrea_cli/commands/run.py | 7 +++++- avrea_cli/helpers.py | 11 ++++++++++ docs/REFERENCE.md | 2 +- docs/reference.json | 4 ++-- docs/starlight/reference/run.md | 2 +- man/man1/avr-run-logs.1 | 4 ++-- man/man1/avr-run.1 | 2 +- tests/test_native_ci.py | 39 +++++++++++++++++++++++++++++++++ 9 files changed, 68 insertions(+), 9 deletions(-) diff --git a/avrea_cli/commands/job.py b/avrea_cli/commands/job.py index 3eaee38..2de1202 100644 --- a/avrea_cli/commands/job.py +++ b/avrea_cli/commands/job.py @@ -29,6 +29,7 @@ from avrea_cli.helpers import get_org_slug from avrea_cli.helpers import handle_http_error from avrea_cli.helpers import parse_since +from avrea_cli.helpers import require_external_ci from avrea_cli.helpers import validate_cursor from avrea_cli.json_output import emit_json from avrea_cli.json_output import emit_json_record @@ -627,6 +628,7 @@ def job_view( click.echo(f" {s_indicator} {step.get('name', '?')}{s_dur}") if show_log or log_failed: + require_external_ci(job_data, "Logs") # OSC 8 link target for per-line timestamps. log_links_enabled = ctx.obj.get("links_enabled", False) log_url: str | None = None @@ -642,7 +644,7 @@ def job_view( entries = fetch_all_logs(client, job_id) print_logs_grouped(entries, emit=log_buf.append, link_url=log_url, steps=steps) page_output("\n".join(log_buf), bypass=no_pager) - else: + elif _job_ci_provider(job_data) != "avrea_ci": click.echo(f"\nTo see the full job log, try: avr job logs {job_id}", err=True) slug = get_org_slug(client, org_id) @@ -706,6 +708,7 @@ def job_logs( except httpx.HTTPStatusError as exc: handle_http_error(exc, "get job") + require_external_ci(job_data, "Logs") steps = job_data.get("steps") or [] started_at = job_data.get("started_at") @@ -871,6 +874,7 @@ def job_metrics(ctx, job_id, org_id, sources, start, end, live, json_output): except httpx.HTTPStatusError as exc: handle_http_error(exc, "get job") + require_external_ci(job_data, "Metrics") vm_cpus, vm_ram_bytes, vm_label = _resolve_vm_specs(job_data) job_finished = job_data.get("state") == "completed" or bool(job_data.get("conclusion")) diff --git a/avrea_cli/commands/run.py b/avrea_cli/commands/run.py index 6e64a0d..eea8c43 100644 --- a/avrea_cli/commands/run.py +++ b/avrea_cli/commands/run.py @@ -32,6 +32,7 @@ from avrea_cli.helpers import handle_http_error from avrea_cli.helpers import is_scoped_token from avrea_cli.helpers import parse_since +from avrea_cli.helpers import require_external_ci from avrea_cli.helpers import validate_cursor from avrea_cli.json_output import emit_json from avrea_cli.json_output import emit_json_record @@ -750,6 +751,7 @@ def run_view( _print_run_jobs(run_jobs, verbose=show_steps, console_url=link_console_url, slug=link_slug) if show_log or log_failed: + require_external_ci(run_data, "Logs") log_links_enabled = ctx.obj.get("links_enabled", False) log_console_url = get_console_url(config.public_api_url) if log_links_enabled else "" log_slug = get_org_slug(client, org_id) if log_links_enabled else "" @@ -820,6 +822,7 @@ def run_diagnose(ctx, run: str, org_id: str | None, json_output: bool) -> None: ) try: + require_external_ci(resolved, "Diagnostics") report = build_run_diagnostics(client, org_id, resolved) except httpx.HTTPStatusError as exc: handle_http_error(exc, "diagnose workflow run") @@ -1380,6 +1383,7 @@ def run_rerun(ctx, run: str, org_id, failed: bool, yes: bool): action="look up workflow run before rerun", ) + require_external_ci(existing_data, "Reruns") if not yes: scope = "failed jobs only" if failed else "all jobs" ensure_prompts_allowed("run rerun needs confirmation") @@ -1457,7 +1461,7 @@ def run_logs( show_all_levels: bool, no_pager: bool, ): - """Fetch logs for a workflow run's GitHub jobs. + """Fetch logs for a workflow run's jobs. RUN accepts the same Avrea IDs, GitHub run IDs, and run URLs as `avr run view`. @@ -1488,6 +1492,7 @@ def run_logs( include=["jobs"], ) + require_external_ci(run_data, "Logs") jobs_list: list[dict[str, Any]] = run_data.get("jobs") or [] if job_name_filter: needle = job_name_filter.lower() diff --git a/avrea_cli/helpers.py b/avrea_cli/helpers.py index 1ad6659..ea75d57 100644 --- a/avrea_cli/helpers.py +++ b/avrea_cli/helpers.py @@ -41,6 +41,17 @@ ) +def require_external_ci(record: Mapping[str, Any], feature: str) -> None: + """Gate operations whose public native CI implementation has not landed.""" + match record.get("ci_provider"): + case "avrea_ci": + raise click.ClickException(f"{feature} are not available for Avrea CI yet.") + case None | "github_actions" | "gitlab_ci": + return + case provider: + raise click.ClickException(f"Unsupported CI provider: {provider}") + + def exit_with_auth_hint() -> NoReturn: """Print the auth suggestion and exit with code 4. Used for both no-token (boundary check) and 401 (server rejected our token). diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index be24e50..6471d5e 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -275,7 +275,7 @@ JSON FIELDS #### `avr run logs` -Fetch logs for a workflow run's GitHub jobs. +Fetch logs for a workflow run's jobs. ```sh avr run logs [OPTIONS] RUN diff --git a/docs/reference.json b/docs/reference.json index fbe3d19..262501e 100644 --- a/docs/reference.json +++ b/docs/reference.json @@ -1184,8 +1184,8 @@ ], "full": "avr run logs", "is_group": false, - "short_help": "Fetch logs for a workflow run's GitHub jobs.", - "help": "Fetch logs for a workflow run's GitHub jobs.\n\nRUN accepts the same Avrea IDs, GitHub run IDs, and run URLs as\n`avr run view`.\n\nLong-form alternative to `avr run view --log[-failed]`. Use --follow to\ntail logs in real time for an in-progress job; pass --job to scope to a\nspecific job when a run has many.\n\n\b\nExamples:\n avr run logs run-019e5a2e474e71c297e452279d4bcdea\n avr run logs run-019e5a2e474e71c297e452279d4bcdea --failed\n avr run logs run-019e5a2e474e71c297e452279d4bcdea --job test\n avr run logs run-019e5a2e474e71c297e452279d4bcdea --follow\n", + "short_help": "Fetch logs for a workflow run's jobs.", + "help": "Fetch logs for a workflow run's jobs.\n\nRUN accepts the same Avrea IDs, GitHub run IDs, and run URLs as\n`avr run view`.\n\nLong-form alternative to `avr run view --log[-failed]`. Use --follow to\ntail logs in real time for an in-progress job; pass --job to scope to a\nspecific job when a run has many.\n\n\b\nExamples:\n avr run logs run-019e5a2e474e71c297e452279d4bcdea\n avr run logs run-019e5a2e474e71c297e452279d4bcdea --failed\n avr run logs run-019e5a2e474e71c297e452279d4bcdea --job test\n avr run logs run-019e5a2e474e71c297e452279d4bcdea --follow\n", "epilog": null, "deprecated": false, "hidden": false, diff --git a/docs/starlight/reference/run.md b/docs/starlight/reference/run.md index 2d8a802..7dc5ddc 100644 --- a/docs/starlight/reference/run.md +++ b/docs/starlight/reference/run.md @@ -112,7 +112,7 @@ JSON FIELDS ### `avr run logs` -Fetch logs for a workflow run's GitHub jobs. +Fetch logs for a workflow run's jobs. ```sh avr run logs [OPTIONS] RUN diff --git a/man/man1/avr-run-logs.1 b/man/man1/avr-run-logs.1 index c922879..4b02647 100644 --- a/man/man1/avr-run-logs.1 +++ b/man/man1/avr-run-logs.1 @@ -1,11 +1,11 @@ .TH AVR\-RUN\-LOGS 1 "" "avr 0.5.0" "Avrea CLI" .SH NAME -avr-run-logs \- Fetch logs for a workflow run's GitHub jobs. +avr-run-logs \- Fetch logs for a workflow run's jobs. .SH SYNOPSIS .B avr run logs [OPTIONS] RUN .SH DESCRIPTION -Fetch logs for a workflow run's GitHub jobs. +Fetch logs for a workflow run's jobs. .PP RUN accepts the same Avrea IDs, GitHub run IDs, and run URLs as `avr run view`. .PP diff --git a/man/man1/avr-run.1 b/man/man1/avr-run.1 index 7d8570a..494d20b 100644 --- a/man/man1/avr-run.1 +++ b/man/man1/avr-run.1 @@ -19,7 +19,7 @@ list List workflow runs for an organization. .TP logs -Fetch logs for a workflow run's GitHub jobs. +Fetch logs for a workflow run's jobs. .TP rerun Re-run a completed workflow run. diff --git a/tests/test_native_ci.py b/tests/test_native_ci.py index fcfe380..ffba68c 100644 --- a/tests/test_native_ci.py +++ b/tests/test_native_ci.py @@ -6,6 +6,7 @@ from avrea_cli.run_refs import parse_run_reference from click.testing import CliRunner from pytest import MonkeyPatch +from unittest.mock import MagicMock import json import pytest @@ -77,6 +78,44 @@ def test_native_list_json_provider(runner: CliRunner, monkeypatch: MonkeyPatch, assert json.loads(result.output) == [{"ci_provider": "avrea_ci"}] +@pytest.mark.parametrize( + "args,data,feature", + [ + (["job", "logs", _JOB], _JOB_DATA, "Logs"), + (["job", "logs", _JOB, "--failed"], _JOB_DATA, "Logs"), + (["job", "logs", _JOB, "--follow"], _JOB_DATA, "Logs"), + (["job", "view", _JOB, "--log"], _JOB_DATA, "Logs"), + (["job", "view", _JOB, "--log-failed"], _JOB_DATA, "Logs"), + (["job", "metrics", _JOB], _JOB_DATA, "Metrics"), + (["run", "logs", _RUN], _RUN_DATA, "Logs"), + (["run", "logs", _RUN, "--failed"], _RUN_DATA, "Logs"), + (["run", "view", _RUN, "--log"], _RUN_DATA, "Logs"), + (["run", "view", _RUN, "--log-failed"], _RUN_DATA, "Logs"), + (["run", "diagnose", _RUN], _RUN_DATA, "Diagnostics"), + (["run", "rerun", _RUN, "--yes"], _RUN_DATA, "Reruns"), + ], +) +def test_native_unsupported_operations_are_explicit( + runner: CliRunner, monkeypatch: MonkeyPatch, args: list[str], data: dict, feature: str +) -> None: + get = MagicMock(return_value={"data": data}) + post = MagicMock(return_value={"data": []}) + monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_get", get) + monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_post", post) + result = runner.invoke(cli, args) + assert result.exit_code == 1, result.output + assert f"{feature} are not available for Avrea CI yet" in result.output + assert get.call_count == 1 + post.assert_not_called() + + +def test_native_job_view_does_not_offer_unavailable_logs(runner: CliRunner, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_get", lambda *args, **kwargs: {"data": _JOB_DATA}) + result = runner.invoke(cli, ["job", "view", _JOB]) + assert result.exit_code == 0, result.output + assert "avr job logs" not in result.output + + @pytest.mark.parametrize("platform,provider", [("github", "github_actions"), ("gitlab", "gitlab_ci")]) def test_job_list_identifies_provider_from_old_control( platform: str, provider: str, capsys: pytest.CaptureFixture[str] From 9eecc0cf20f1f822908826e6e60786f41bf8e598 Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Wed, 28 Oct 2026 22:19:21 +0000 Subject: [PATCH 4/9] cli: verify host keys for native and external job SSH --- avrea_cli/commands/job.py | 21 ++++++++++++++-- avrea_cli/commands/run.py | 4 +--- tests/test_native_ci.py | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/avrea_cli/commands/job.py b/avrea_cli/commands/job.py index 2de1202..003f413 100644 --- a/avrea_cli/commands/job.py +++ b/avrea_cli/commands/job.py @@ -50,6 +50,7 @@ from avrea_cli.output import format_timestamp from avrea_cli.output import short_id from avrea_cli.repo_context import resolve_repos_or_detect +from avrea_cli.vm import _write_known_hosts from collections import deque from datetime import UTC from datetime import datetime @@ -446,7 +447,7 @@ def job_ssh(ctx, job_id: str, print_command: bool, show_password: bool): password = result.get("password") ssh_keys_installed = result.get("ssh_keys_installed", False) - ssh_opts = ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"] + ssh_opts = ["-o", "StrictHostKeyChecking=accept-new"] ssh_exec = ["ssh", *ssh_opts, "-p", str(port), f"{username}@{host}"] ssh_cmd = shlex.join(ssh_exec) @@ -471,7 +472,23 @@ def job_ssh(ctx, job_id: str, print_command: bool, show_password: bool): else: click.echo("Password: ******** (hidden; re-run with --show-password to reveal)", err=True) - sys.exit(subprocess.run(ssh_exec).returncode) + known_hosts = _write_known_hosts(result.get("host_key"), host, port) + if known_hosts is None: + raise click.ClickException("The VM did not return an SSH host key; refusing an unverified connection.") + try: + ssh_exec = [ + "ssh", + "-o", + "StrictHostKeyChecking=yes", + "-o", + f"UserKnownHostsFile={known_hosts}", + "-p", + str(port), + f"{username}@{host}", + ] + sys.exit(subprocess.run(ssh_exec).returncode) + finally: + known_hosts.unlink(missing_ok=True) @job.command("view") diff --git a/avrea_cli/commands/run.py b/avrea_cli/commands/run.py index eea8c43..ba3c313 100644 --- a/avrea_cli/commands/run.py +++ b/avrea_cli/commands/run.py @@ -1119,9 +1119,7 @@ def watch_run_loop( click.echo() match ci_provider: case "avrea_ci": - click.echo( - click.style(" Native job logs, metrics, and SSH are not available yet.", fg=DIM_FG) - ) + click.echo(click.style(" Native job logs and metrics are not available yet.", fg=DIM_FG)) case "github_actions" | "gitlab_ci": click.echo( click.style( diff --git a/tests/test_native_ci.py b/tests/test_native_ci.py index ffba68c..a38abef 100644 --- a/tests/test_native_ci.py +++ b/tests/test_native_ci.py @@ -5,7 +5,9 @@ from avrea_cli.run_refs import RunReferenceKind from avrea_cli.run_refs import parse_run_reference from click.testing import CliRunner +from pathlib import Path from pytest import MonkeyPatch +from types import SimpleNamespace from unittest.mock import MagicMock import json import pytest @@ -43,6 +45,54 @@ } +@pytest.mark.parametrize("job_id", [_JOB, "job-019e5a2e474e71c297e452279d4bcdea"]) +@pytest.mark.parametrize("exit_code", [0, 23]) +def test_job_ssh_pins_the_returned_host_key( + runner: CliRunner, monkeypatch: MonkeyPatch, job_id: str, exit_code: int +) -> None: + response = { + "host": "127.0.0.1", + "port": 2222, + "username": "runner", + "host_key": "ssh-ed25519 AAAA", + "ssh_keys_installed": True, + } + post = MagicMock(return_value=response) + monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_post", post) + paths: list[Path] = [] + + def connect(argv: list[str]) -> SimpleNamespace: + assert "StrictHostKeyChecking=yes" in argv + path = Path(next(arg.split("=", 1)[1] for arg in argv if arg.startswith("UserKnownHostsFile="))) + assert path.read_text() == "[127.0.0.1]:2222 ssh-ed25519 AAAA\n" + paths.append(path) + return SimpleNamespace(returncode=exit_code) + + monkeypatch.setattr("avrea_cli.commands.job.subprocess.run", connect) + result = runner.invoke(cli, ["job", "ssh", job_id]) + assert result.exit_code == exit_code, result.exception + post.assert_called_once_with("/vms/ssh-forwarding", json={"job_id": job_id}, timeout=60.0) + assert paths and not paths[0].exists() + + +def test_native_job_ssh_refuses_a_missing_host_key(runner: CliRunner, monkeypatch: MonkeyPatch) -> None: + monkeypatch.setattr( + "avrea_cli.api_client.ApiClient.public_post", + lambda *args, **kwargs: { + "host": "127.0.0.1", + "port": 2222, + "username": "runner", + "ssh_keys_installed": True, + }, + ) + connect = MagicMock() + monkeypatch.setattr("avrea_cli.commands.job.subprocess.run", connect) + result = runner.invoke(cli, ["job", "ssh", _JOB]) + assert result.exit_code == 1 + assert "refusing an unverified connection" in result.output + connect.assert_not_called() + + @pytest.mark.parametrize("reference", [_RUN, f"https://console.avrea.com/org/acme/runs/{_RUN}"]) def test_native_run_reference(reference: str) -> None: parsed = parse_run_reference(reference, api_url="https://api.avrea.com") From 58d07b8d1e1ff525ac0c4975fbf2666a1b027874 Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Wed, 28 Oct 2026 22:26:38 +0000 Subject: [PATCH 5/9] tests: cover native and GitHub run cancellation output --- tests/test_native_ci.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_native_ci.py b/tests/test_native_ci.py index a38abef..dbe9ccf 100644 --- a/tests/test_native_ci.py +++ b/tests/test_native_ci.py @@ -45,6 +45,21 @@ } +@pytest.mark.parametrize("run_id", [_RUN, "run-019e5a2e474e71c297e452279d4bcdea"]) +@pytest.mark.parametrize("status", ["cancel_requested", "already_terminal"]) +def test_run_cancel_preserves_public_route_and_provider_neutral_output( + runner: CliRunner, monkeypatch: MonkeyPatch, run_id: str, status: str +) -> None: + post = MagicMock(return_value={"data": {"run_id": run_id, "status": status}}) + monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_post", post) + monkeypatch.setattr("avrea_cli.commands.run.get_org_slug", lambda *args: "acme") + result = runner.invoke(cli, ["run", "cancel", run_id, "--yes"]) + assert result.exit_code == 0, result.output + post.assert_called_once_with(f"/orgs/org-default/workflow-runs/{run_id}/cancel") + assert ("Cancel requested" if status == "cancel_requested" else "already finished") in result.output + assert (f"avr run watch {run_id}" in result.output) is (status == "cancel_requested") + + @pytest.mark.parametrize("job_id", [_JOB, "job-019e5a2e474e71c297e452279d4bcdea"]) @pytest.mark.parametrize("exit_code", [0, 23]) def test_job_ssh_pins_the_returned_host_key( From dac5322178de0fc9f5f40938c613ab811f06347a Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Wed, 28 Oct 2026 23:18:34 +0000 Subject: [PATCH 6/9] cli: read and follow native CI logs and failed steps --- avrea_cli/commands/job.py | 4 +-- avrea_cli/commands/run.py | 9 ++++--- avrea_cli/log_display.py | 27 ++++++++++++++----- tests/test_log_display.py | 23 ++++++++++++++++ tests/test_native_ci.py | 55 +++++++++++++++++++++++++++++++-------- tests/test_run_view.py | 3 ++- 6 files changed, 97 insertions(+), 24 deletions(-) diff --git a/avrea_cli/commands/job.py b/avrea_cli/commands/job.py index 003f413..140e8cf 100644 --- a/avrea_cli/commands/job.py +++ b/avrea_cli/commands/job.py @@ -645,7 +645,6 @@ def job_view( click.echo(f" {s_indicator} {step.get('name', '?')}{s_dur}") if show_log or log_failed: - require_external_ci(job_data, "Logs") # OSC 8 link target for per-line timestamps. log_links_enabled = ctx.obj.get("links_enabled", False) log_url: str | None = None @@ -661,7 +660,7 @@ def job_view( entries = fetch_all_logs(client, job_id) print_logs_grouped(entries, emit=log_buf.append, link_url=log_url, steps=steps) page_output("\n".join(log_buf), bypass=no_pager) - elif _job_ci_provider(job_data) != "avrea_ci": + else: click.echo(f"\nTo see the full job log, try: avr job logs {job_id}", err=True) slug = get_org_slug(client, org_id) @@ -725,7 +724,6 @@ def job_logs( except httpx.HTTPStatusError as exc: handle_http_error(exc, "get job") - require_external_ci(job_data, "Logs") steps = job_data.get("steps") or [] started_at = job_data.get("started_at") diff --git a/avrea_cli/commands/run.py b/avrea_cli/commands/run.py index ba3c313..931e33f 100644 --- a/avrea_cli/commands/run.py +++ b/avrea_cli/commands/run.py @@ -751,7 +751,6 @@ def run_view( _print_run_jobs(run_jobs, verbose=show_steps, console_url=link_console_url, slug=link_slug) if show_log or log_failed: - require_external_ci(run_data, "Logs") log_links_enabled = ctx.obj.get("links_enabled", False) log_console_url = get_console_url(config.public_api_url) if log_links_enabled else "" log_slug = get_org_slug(client, org_id) if log_links_enabled else "" @@ -1119,7 +1118,12 @@ def watch_run_loop( click.echo() match ci_provider: case "avrea_ci": - click.echo(click.style(" Native job logs and metrics are not available yet.", fg=DIM_FG)) + click.echo( + click.style( + " → avr job logs --follow # tail logs of a running job", fg=DIM_FG + ) + ) + click.echo(click.style(" Native job metrics are not available yet.", fg=DIM_FG)) case "github_actions" | "gitlab_ci": click.echo( click.style( @@ -1490,7 +1494,6 @@ def run_logs( include=["jobs"], ) - require_external_ci(run_data, "Logs") jobs_list: list[dict[str, Any]] = run_data.get("jobs") or [] if job_name_filter: needle = job_name_filter.lower() diff --git a/avrea_cli/log_display.py b/avrea_cli/log_display.py index bf4b97d..647fe6c 100644 --- a/avrea_cli/log_display.py +++ b/avrea_cli/log_display.py @@ -264,19 +264,29 @@ def print_failed_step_logs( OSC 133 boundaries so terminals can navigate between failed sections.""" failed_steps = [s for s in steps if s.get("conclusion") and s["conclusion"] not in ("success", "skipped")] if not failed_steps: - emit("No failed steps.") + if job_id.startswith("awj-") and (not steps or any(not step.get("conclusion") for step in steps)): + emit(f"Step outcomes are unavailable; use `avr job logs {job_id}` for all output.") + else: + emit("No failed steps.") return all_logs = fetch_all_logs(client, job_id, show_all_levels=show_all_levels) - failed_conclusion = {s["name"]: s.get("conclusion", "failure") for s in failed_steps} prev_conclusion: str | None = None for step in failed_steps: name = step["name"] - step_logs = [e for e in all_logs if e.get("step_name") == name] + step_logs = [ + entry + for entry in all_logs + if ( + entry.get("step_record_id") == step["log_record_id"] + if step.get("log_record_id") + else entry.get("step_name") == name + ) + ] if not step_logs: continue - conclusion = failed_conclusion.get(name, "failure") + conclusion = step.get("conclusion", "failure") indicator = status_indicator("completed", conclusion) emit("") header = click.style(f"--- {indicator} {name} ({conclusion}) ---", bold=True) @@ -381,11 +391,16 @@ def _title(stage: str | None) -> str: # Soft-retry on 404/429/5xx; other 4xx is terminal and # routes through handle_http_error so 401 honors the # auth-hint exit-4 contract. - if code == 404 or code == 429 or code >= 500: + if code == 404: + # Queued jobs may finish without ever acquiring a VM. + # Let the regular idle check observe that completion. + idle_polls += 1 + elif code == 429 or code >= 500: idle_polls += 1 time.sleep(interval) continue - handle_http_error(exc, "fetch logs") + else: + handle_http_error(exc, "fetch logs") except httpx.ConnectError, httpx.TimeoutException: # Network blip — `--follow` is interactive; we'd rather # hold the session open and retry than crash the user. diff --git a/tests/test_log_display.py b/tests/test_log_display.py index eaf87a1..1093890 100644 --- a/tests/test_log_display.py +++ b/tests/test_log_display.py @@ -6,6 +6,7 @@ from avrea_cli.log_display import print_failed_step_logs from avrea_cli.log_display import print_logs_grouped from unittest.mock import MagicMock +import httpx import pytest @@ -223,3 +224,25 @@ def test_stopped_by_ctrl_c_exits_130(self, capsys): follow_logs(client, "org-1", "job-1") assert exit_info.value.code == 130 assert "Stopped following." in capsys.readouterr().out + + +@pytest.mark.parametrize("conclusion", ["cancelled", "skipped"]) +def test_follow_native_job_without_a_vm_stops_when_terminal(monkeypatch, capsys, conclusion): + client = MagicMock() + error = httpx.HTTPStatusError( + "No execution", request=httpx.Request("POST", "https://example.test/logs/search"), response=httpx.Response(404) + ) + client.public_post.side_effect = [error] * 5 + [AssertionError("follow did not stop")] + client.public_get.return_value = {"data": {"state": "completed", "conclusion": conclusion}} + monkeypatch.setattr("avrea_cli.log_display.time.sleep", lambda _: None) + monkeypatch.setattr("avrea_cli.log_display.notify", lambda _: None) + follow_logs(client, "org-019e5a2e474e72d711642b726b044016", "awj-019e5a2e474e72d711642b726b044016") + assert f"Job completed: {conclusion}" in capsys.readouterr().out + + +@pytest.mark.parametrize("steps", [[], [{"name": "Build", "conclusion": None}]]) +def test_native_missing_step_outcomes_are_not_reported_as_success(capsys, steps): + client = MagicMock() + print_failed_step_logs(client, "awj-019e5a2e474e72d711642b726b044016", steps) + assert "Step outcomes are unavailable" in capsys.readouterr().out + client.public_post.assert_not_called() diff --git a/tests/test_native_ci.py b/tests/test_native_ci.py index dbe9ccf..31f474b 100644 --- a/tests/test_native_ci.py +++ b/tests/test_native_ci.py @@ -146,16 +146,7 @@ def test_native_list_json_provider(runner: CliRunner, monkeypatch: MonkeyPatch, @pytest.mark.parametrize( "args,data,feature", [ - (["job", "logs", _JOB], _JOB_DATA, "Logs"), - (["job", "logs", _JOB, "--failed"], _JOB_DATA, "Logs"), - (["job", "logs", _JOB, "--follow"], _JOB_DATA, "Logs"), - (["job", "view", _JOB, "--log"], _JOB_DATA, "Logs"), - (["job", "view", _JOB, "--log-failed"], _JOB_DATA, "Logs"), (["job", "metrics", _JOB], _JOB_DATA, "Metrics"), - (["run", "logs", _RUN], _RUN_DATA, "Logs"), - (["run", "logs", _RUN, "--failed"], _RUN_DATA, "Logs"), - (["run", "view", _RUN, "--log"], _RUN_DATA, "Logs"), - (["run", "view", _RUN, "--log-failed"], _RUN_DATA, "Logs"), (["run", "diagnose", _RUN], _RUN_DATA, "Diagnostics"), (["run", "rerun", _RUN, "--yes"], _RUN_DATA, "Reruns"), ], @@ -174,11 +165,53 @@ def test_native_unsupported_operations_are_explicit( post.assert_not_called() -def test_native_job_view_does_not_offer_unavailable_logs(runner: CliRunner, monkeypatch: MonkeyPatch) -> None: +def test_native_job_view_offers_logs(runner: CliRunner, monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_get", lambda *args, **kwargs: {"data": _JOB_DATA}) result = runner.invoke(cli, ["job", "view", _JOB]) assert result.exit_code == 0, result.output - assert "avr job logs" not in result.output + assert "avr job logs" in result.output + + +@pytest.mark.parametrize( + "args", + [ + ["job", "logs", _JOB], + ["job", "view", _JOB, "--log"], + ["run", "logs", _RUN], + ["run", "view", _RUN, "--log"], + ["job", "logs", _JOB, "--failed"], + ["job", "view", _JOB, "--log-failed"], + ["run", "logs", _RUN, "--failed"], + ["run", "view", _RUN, "--log-failed"], + ], +) +def test_native_log_commands_use_public_logs_and_stable_failed_step_identity( + runner: CliRunner, monkeypatch: MonkeyPatch, args: list[str] +) -> None: + step = {"number": 1, "name": "Build", "status": "completed", "conclusion": "failure", "log_record_id": "first"} + job = {**_JOB_DATA, "steps": [step], "conclusion": "failure"} + run = {**_RUN_DATA, "jobs": [job], "conclusion": "failure"} + monkeypatch.setattr( + "avrea_cli.api_client.ApiClient.public_get", + lambda self, path, **kwargs: {"data": run if "/workflow-runs/" in path else job}, + ) + monkeypatch.setattr("avrea_cli.commands.run.get_org_slug", lambda *args: "acme") + monkeypatch.setattr("avrea_cli.commands.job.get_org_slug", lambda *args: "acme") + post = MagicMock( + return_value={ + "results": [ + {"line_number": 1, "step_name": "Build", "step_record_id": "first", "content": "native failed output"}, + {"line_number": 2, "step_name": "Build", "step_record_id": "second", "content": "other step output"}, + ], + "has_more": False, + } + ) + monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_post", post) + result = runner.invoke(cli, args) + assert result.exit_code == 0, result.exception + assert "native failed output" in result.output + assert ("other step output" in result.output) is not any("failed" in arg for arg in args) + assert all(call.args[0] == "/logs/search" and call.kwargs["json"]["job_id"] == _JOB for call in post.call_args_list) @pytest.mark.parametrize("platform,provider", [("github", "github_actions"), ("gitlab", "gitlab_ci")]) diff --git a/tests/test_run_view.py b/tests/test_run_view.py index 719ba1a..2606cd8 100644 --- a/tests/test_run_view.py +++ b/tests/test_run_view.py @@ -351,7 +351,8 @@ def test_watch_labels_and_hints_match_provider( assert provider in output assert f"Run {run_label} completed:" in output notify.assert_called_once_with(f"Run {run_label} Build: success") - for command in ("avr job logs", "avr job metrics", "avr job ssh"): + assert "avr job logs" in output + for command in ("avr job metrics", "avr job ssh"): assert (command in output) is (not native) def test_stopped_by_ctrl_c_exits_130(self, capsys): From 20ce7beb5eed4c06929ef01285e5c160c53a2bc9 Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Wed, 28 Oct 2026 23:44:39 +0000 Subject: [PATCH 7/9] cli: enable native job metrics and pending VM watches --- avrea_cli/commands/job.py | 16 ++++++++----- avrea_cli/commands/run.py | 9 +------- tests/test_job_metrics_cmd.py | 42 +++++++++++++++++++++++++++++++++++ tests/test_native_ci.py | 10 ++++++++- tests/test_run_view.py | 2 +- 5 files changed, 63 insertions(+), 16 deletions(-) diff --git a/avrea_cli/commands/job.py b/avrea_cli/commands/job.py index 140e8cf..2f80ad9 100644 --- a/avrea_cli/commands/job.py +++ b/avrea_cli/commands/job.py @@ -25,11 +25,11 @@ from avrea_cli.display import truncate as _truncate from avrea_cli.helpers import ensure_authenticated from avrea_cli.helpers import ensure_ctx +from avrea_cli.helpers import extract_detail from avrea_cli.helpers import get_org_id from avrea_cli.helpers import get_org_slug from avrea_cli.helpers import handle_http_error from avrea_cli.helpers import parse_since -from avrea_cli.helpers import require_external_ci from avrea_cli.helpers import validate_cursor from avrea_cli.json_output import emit_json from avrea_cli.json_output import emit_json_record @@ -803,6 +803,10 @@ def _format_job_status(job_data: dict[str, Any]) -> str: return f"{indicator} {label} ({duration})" +def _runtime_pending(response: httpx.Response) -> bool: + return response.status_code == 404 and extract_detail(response) == "No execution found for job" + + def _fetch_metrics_sources( client: ApiClient, org_id: str, @@ -814,7 +818,7 @@ def _fetch_metrics_sources( ) -> dict[str, dict[str, Any]]: """Fetch metrics for each source. - 404 is treated as "no execution yet" (queued job) so the user still sees + A missing execution is treated as pending (queued job) so the user still sees a partial pane. Other failures propagate — the caller decides whether to abort (static path) or render the error in the footer (live path).""" params: dict[str, Any] = {} @@ -828,7 +832,7 @@ def _fetch_metrics_sources( try: response = client.public_get(f"/orgs/{org_id}/jobs/{job_id}/metrics/{src}", params=params or None) except httpx.HTTPStatusError as exc: - if exc.response.status_code == 404: + if _runtime_pending(exc.response): # Job has no execution yet (queued) — keep going so partial sources still render. out[src] = {"series": [], "_error": "no execution"} continue @@ -889,7 +893,6 @@ def job_metrics(ctx, job_id, org_id, sources, start, end, live, json_output): except httpx.HTTPStatusError as exc: handle_http_error(exc, "get job") - require_external_ci(job_data, "Metrics") vm_cpus, vm_ram_bytes, vm_label = _resolve_vm_specs(job_data) job_finished = job_data.get("state") == "completed" or bool(job_data.get("conclusion")) @@ -975,10 +978,11 @@ def _build_frame(fetched: dict[str, dict[str, Any]], log_tail: list[dict[str, An new_entries, new_cursor = fetch_logs_after(client, job_id, after_line=last_log_line) except httpx.HTTPStatusError as exc: code = exc.response.status_code - if 400 <= code < 500 and code != 429: + pending = _runtime_pending(exc.response) + if 400 <= code < 500 and code != 429 and not pending: handle_http_error(exc, "fetch logs") new_entries, new_cursor = [], last_log_line - log_error = f"log fetch failed: HTTP {code}" + log_error = None if pending else f"log fetch failed: HTTP {code}" except (httpx.ConnectError, httpx.TimeoutException) as exc: # Live mode polls on a tight loop — a transient API blip # shouldn't kill the watcher. Surface it in the LOGS pane so diff --git a/avrea_cli/commands/run.py b/avrea_cli/commands/run.py index 931e33f..dc8eab9 100644 --- a/avrea_cli/commands/run.py +++ b/avrea_cli/commands/run.py @@ -1117,14 +1117,7 @@ def watch_run_loop( _print_run_jobs(run_jobs, console_url=console_url, slug=slug) click.echo() match ci_provider: - case "avrea_ci": - click.echo( - click.style( - " → avr job logs --follow # tail logs of a running job", fg=DIM_FG - ) - ) - click.echo(click.style(" Native job metrics are not available yet.", fg=DIM_FG)) - case "github_actions" | "gitlab_ci": + case "avrea_ci" | "github_actions" | "gitlab_ci": click.echo( click.style( " → avr job logs --follow # tail logs of a running job", diff --git a/tests/test_job_metrics_cmd.py b/tests/test_job_metrics_cmd.py index 1ac3b5b..6209ba4 100644 --- a/tests/test_job_metrics_cmd.py +++ b/tests/test_job_metrics_cmd.py @@ -258,3 +258,45 @@ def fake_get(self, path, params=None, **kw): result = runner.invoke(cli, ["job", "metrics", "job-1", "--watch"]) assert result.exit_code != 0, result.output assert "metrics fetch failed: HTTP" not in result.output # never reached the soft-fail branch + + +@pytest.mark.parametrize("detail,pending", [("No execution found for job", True), ("Job not found", False)]) +def test_native_metrics_watch_waits_for_vm_but_preserves_denial(runner, monkeypatch, detail, pending): + job_id = "awj-019d9533fc2f7b59948243a10b312d2e" + monkeypatch.setattr( + "avrea_cli.api_client.ApiClient.public_get", + _make_get( + { + f"/orgs/org-default/jobs/{job_id}/metrics/cpu": CPU_RESPONSE, + f"/orgs/org-default/jobs/{job_id}/metrics/memory": MEMORY_RESPONSE, + f"/orgs/org-default/jobs/{job_id}": _job_response(state="queued"), + } + ), + ) + request = httpx.Request("GET", "https://api.example/logs") + response = httpx.Response(404, json={"detail": detail}, request=request) + calls = [] + + def fetch(*args, **kwargs): + calls.append(kwargs["after_line"]) + if len(calls) == 1: + raise httpx.HTTPStatusError("404", request=request, response=response) + return ([{"content": "VM started", "line_number": 1}], 1) + + def sleep(_): + if len(calls) == 2: + raise KeyboardInterrupt() + assert pending, "authorization failure must exit before polling" + + monkeypatch.setattr("avrea_cli.commands.job.fetch_logs_after", fetch) + monkeypatch.setattr("avrea_cli.commands.job.time.sleep", sleep) + result = runner.invoke(cli, ["job", "metrics", job_id, "--watch"]) + if pending: + assert result.exit_code == 130, result.output + assert calls == [None, None] + assert "VM started" in result.output + assert "log fetch failed" not in result.output + else: + assert result.exit_code != 0 + assert calls == [None] + assert "Job not found" in result.output diff --git a/tests/test_native_ci.py b/tests/test_native_ci.py index 31f474b..22a18ad 100644 --- a/tests/test_native_ci.py +++ b/tests/test_native_ci.py @@ -146,7 +146,6 @@ def test_native_list_json_provider(runner: CliRunner, monkeypatch: MonkeyPatch, @pytest.mark.parametrize( "args,data,feature", [ - (["job", "metrics", _JOB], _JOB_DATA, "Metrics"), (["run", "diagnose", _RUN], _RUN_DATA, "Diagnostics"), (["run", "rerun", _RUN, "--yes"], _RUN_DATA, "Reruns"), ], @@ -238,3 +237,12 @@ def test_native_runs_do_not_guess_github_source_links( assert result.exit_code == 0, result.output assert "https://github.com/" not in result.output assert "https://console.avrea.com/" in result.output + + +def test_native_job_metrics_use_the_public_vm_metrics_route(runner: CliRunner, monkeypatch: MonkeyPatch) -> None: + get = MagicMock(side_effect=[{"data": _JOB_DATA}, {"unit": "ratio", "series": []}]) + monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_get", get) + result = runner.invoke(cli, ["job", "metrics", _JOB, "--source", "cpu", "--json"]) + assert result.exit_code == 0, result.output + assert '"cpu"' in result.output + assert get.call_args.args[0].endswith(f"/jobs/{_JOB}/metrics/cpu") diff --git a/tests/test_run_view.py b/tests/test_run_view.py index 2606cd8..af977c5 100644 --- a/tests/test_run_view.py +++ b/tests/test_run_view.py @@ -353,7 +353,7 @@ def test_watch_labels_and_hints_match_provider( notify.assert_called_once_with(f"Run {run_label} Build: success") assert "avr job logs" in output for command in ("avr job metrics", "avr job ssh"): - assert (command in output) is (not native) + assert command in output def test_stopped_by_ctrl_c_exits_130(self, capsys): """Exit 0 would read as a successful run under --exit-status.""" From 7b862855eae7821165d75ec0577900e59ee2bd33 Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Thu, 29 Oct 2026 00:33:03 +0000 Subject: [PATCH 8/9] cli: resolve native workflow identities and provider filters --- avrea_cli/commands/job.py | 10 +++++++++- avrea_cli/commands/run.py | 10 +++++++++- avrea_cli/commands/workflow.py | 15 +++++++++++---- tests/test_native_ci.py | 13 +++++++++++++ tests/test_workflow_run.py | 28 ++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 6 deletions(-) diff --git a/avrea_cli/commands/job.py b/avrea_cli/commands/job.py index 2f80ad9..3162d2d 100644 --- a/avrea_cli/commands/job.py +++ b/avrea_cli/commands/job.py @@ -216,7 +216,12 @@ def job(ctx): help="Filter by state (queued, in_progress, completed) or conclusion (success, failure, ...). Repeatable.", ) @click.option("--on-avrea/--shadowing", "running_on_avrea", default=None, help="Filter by Avrea-run vs shadowing jobs.") -@click.option("-w", "--workflow", "workflow_ids", multiple=True, help="Filter by workflow ID (wfl-xxx, repeatable).") +@click.option( + "--ci-provider", type=click.Choice(["github_actions", "gitlab_ci", "avrea_ci"]), help="Filter by CI engine." +) +@click.option( + "-w", "--workflow", "workflow_ids", multiple=True, help="Filter by workflow ID (wfl- or awf-, repeatable)." +) @click.option("--since", default=None, help="Relative time window: '7d', '24h', etc.") @click.option("-L", "--limit", type=click.IntRange(1, 1000), default=20, show_default=True, help="Max jobs to return.") @click.option("--cursor", default=None, help="Pagination cursor from a previous response.") @@ -243,6 +248,7 @@ def job_list( status_values, running_on_avrea, workflow_ids, + ci_provider, since, limit, cursor, @@ -291,6 +297,8 @@ def job_list( params["conclusions"] = platform_conclusions if running_on_avrea is not None: params["running_on_avrea"] = running_on_avrea + if ci_provider: + params["ci_provider"] = ci_provider if workflow_ids: params["workflow_ids"] = list(workflow_ids) if since: diff --git a/avrea_cli/commands/run.py b/avrea_cli/commands/run.py index dc8eab9..ce577c1 100644 --- a/avrea_cli/commands/run.py +++ b/avrea_cli/commands/run.py @@ -163,7 +163,12 @@ def run(ctx): help="Filter by state (queued, in_progress, completed) or conclusion (success, failure, ...). Repeatable.", ) @click.option("--branch", "head_branches", multiple=True, help="Filter by head branch (repeatable).") -@click.option("-w", "--workflow", "workflow_ids", multiple=True, help="Filter by workflow ID (wfl-xxx, repeatable).") +@click.option( + "--ci-provider", type=click.Choice(["github_actions", "gitlab_ci", "avrea_ci"]), help="Filter by CI engine." +) +@click.option( + "-w", "--workflow", "workflow_ids", multiple=True, help="Filter by workflow ID (wfl- or awf-, repeatable)." +) @click.option("--since", default=None, help="Relative time window: '7d', '24h', etc. Sugar for --created-after.") @click.option( "--from", @@ -204,6 +209,7 @@ def run_list( status_values, head_branches, workflow_ids, + ci_provider, since, created_after, created_before, @@ -283,6 +289,8 @@ def run_list( params["conclusions"] = platform_conclusions if head_branches: params["head_branches"] = list(head_branches) + if ci_provider: + params["ci_provider"] = ci_provider if workflow_ids: params["workflow_ids"] = list(workflow_ids) if created_after: diff --git a/avrea_cli/commands/workflow.py b/avrea_cli/commands/workflow.py index 93667ac..5767eae 100644 --- a/avrea_cli/commands/workflow.py +++ b/avrea_cli/commands/workflow.py @@ -19,6 +19,7 @@ from avrea_cli.helpers import get_org_slug from avrea_cli.helpers import handle_http_error from avrea_cli.helpers import parse_since +from avrea_cli.helpers import require_external_ci from avrea_cli.json_output import emit_json from avrea_cli.json_output import emit_json_record from avrea_cli.json_output import handle_json_meta @@ -58,6 +59,7 @@ def workflow(ctx): "failure_count", "flaked_count", "median_duration_seconds", + ci_provider="workflow.ci_provider", workflow_id="workflow.workflow_id", platform_workflow_id="workflow.platform_id", name="workflow.name", @@ -256,7 +258,7 @@ def workflow_view(ctx, workflow_identifier: str, org_id, repo_flag, since, json_ org_id = get_org_id(config, org_id, client=client) - if workflow_identifier.startswith("wfl-"): + if workflow_identifier.startswith(("wfl-", "awf-")): workflow_id = workflow_identifier else: repo_id = resolve_repo_or_detect(client, config, org_id, repo_flag, required=True) @@ -530,7 +532,7 @@ def _lookup_workflow(client: ApiClient, org_id: str, repo_id: str, identifier: s ) handle_http_error(exc, "list workflows for resolution", hint=hint) - if identifier.startswith("wfl-"): + if identifier.startswith(("wfl-", "awf-")): for wf in workflows: if wf.get("workflow_id") == identifier: return wf @@ -545,7 +547,9 @@ def _lookup_workflow(client: ApiClient, org_id: str, repo_id: str, identifier: s raise click.ClickException(f"Workflow with platform ID {identifier!r} not found in this repository") ident_lower = identifier.lower() - matches = [wf for wf in workflows if (wf.get("name") or "").lower() == ident_lower] + matches = [wf for wf in workflows if (wf.get("path") or "").lower() == ident_lower] + if not matches: + matches = [wf for wf in workflows if (wf.get("name") or "").lower() == ident_lower] if not matches: # Filename match — full ("ci.yml") or stem ("ci"). matches = [ @@ -559,7 +563,9 @@ def _lookup_workflow(client: ApiClient, org_id: str, repo_id: str, identifier: s ) if len(matches) > 1: names = ", ".join(wf.get("name", "") for wf in matches) - raise click.ClickException(f"Ambiguous workflow {identifier!r}. Matches: {names}. Pass --wfl-id instead.") + raise click.ClickException( + f"Ambiguous workflow {identifier!r}. Matches: {names}. Pass the full workflow ID or file path instead." + ) return matches[0] @@ -574,6 +580,7 @@ def _resolve_workflow_filename(client: ApiClient, org_id: str, repo_id: str, ide return ResolvedWorkflow(identifier, None, None) wf = _lookup_workflow(client, org_id, repo_id, identifier) + require_external_ci(wf, "Manual workflow dispatch") path = wf.get("path") # GitHub serves workflows that lack a ``.yml`` path while disabled or # mid-rename. Surface the cause instead of dispatching ``workflow=""``, diff --git a/tests/test_native_ci.py b/tests/test_native_ci.py index 22a18ad..c8f350e 100644 --- a/tests/test_native_ci.py +++ b/tests/test_native_ci.py @@ -246,3 +246,16 @@ def test_native_job_metrics_use_the_public_vm_metrics_route(runner: CliRunner, m assert result.exit_code == 0, result.output assert '"cpu"' in result.output assert get.call_args.args[0].endswith(f"/jobs/{_JOB}/metrics/cpu") + + +@pytest.mark.parametrize("command", ["run", "job"]) +@pytest.mark.parametrize("provider", ["github_actions", "gitlab_ci", "avrea_ci"]) +def test_history_provider_filter_reaches_public_api( + runner: CliRunner, monkeypatch: MonkeyPatch, command: str, provider: str +) -> None: + get = MagicMock(return_value={"data": []}) + monkeypatch.setattr("avrea_cli.api_client.ApiClient.public_get", get) + monkeypatch.setattr(f"avrea_cli.commands.{command}.resolve_repos_or_detect", lambda *args, **kwargs: []) + result = runner.invoke(cli, [command, "list", "--ci-provider", provider, "--json", "*"]) + assert result.exit_code == 0, result.output + assert get.call_args.kwargs["params"]["ci_provider"] == provider diff --git a/tests/test_workflow_run.py b/tests/test_workflow_run.py index 3100711..7d6dcf2 100644 --- a/tests/test_workflow_run.py +++ b/tests/test_workflow_run.py @@ -8,6 +8,7 @@ from avrea_cli.main import cli from click.testing import CliRunner from unittest.mock import MagicMock +import click import httpx import pytest @@ -293,3 +294,30 @@ def fake_post(self, path, **kw): result = runner.invoke(cli, ["workflow", "run", "build.yml", "--repo", "rep-foo"]) assert result.exit_code != 0 + + +@pytest.mark.parametrize("reference", ["awf-019d9533fc2f7b59948243a10b312d2e", ".avrea/workflows/ci.yml"]) +def test_native_workflow_identity_does_not_dispatch_a_same_named_github_workflow(reference): + client = MagicMock() + client.public_get.return_value = { + "data": [ + { + "workflow_id": "wfl-019d9533fc2f7b59948243a10b312d2e", + "name": "CI", + "path": ".github/workflows/ci.yml", + "platform_id": 123, + }, + { + "workflow_id": "awf-019d9533fc2f7b59948243a10b312d2e", + "name": "CI", + "path": ".avrea/workflows/ci.yml", + "platform_id": None, + "ci_provider": "avrea_ci", + }, + ] + } + with pytest.raises(click.ClickException, match="not available for Avrea CI"): + _resolve_workflow_filename( + client, "org-019d9533fc2f7b59948243a10b312d2e", "rep-019d9533fc2f7b59948243a10b312d2e", reference + ) + client.public_post.assert_not_called() From d29a62928644d0754f7b830cd8978f183c82f922 Mon Sep 17 00:00:00 2001 From: Hannu Valtonen Date: Thu, 29 Oct 2026 06:34:00 +0000 Subject: [PATCH 9/9] docs: regenerate native CI command references --- docs/REFERENCE.md | 6 ++-- docs/reference.json | 50 +++++++++++++++++++++++++++++++-- docs/starlight/reference/job.md | 3 +- docs/starlight/reference/run.md | 3 +- man/man1/avr-job-list.1 | 5 +++- man/man1/avr-run-list.1 | 5 +++- 6 files changed, 64 insertions(+), 8 deletions(-) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index 6471d5e..4b0c611 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -262,7 +262,8 @@ JSON FIELDS - `--repo ` — Filter by repository (org/repo or rep-xxx). Pass --repo more than once to filter multiple repositories. Auto-detected from git remote if omitted. _(repeatable)_ - `--status ` — Filter by state (queued, in_progress, completed) or conclusion (success, failure, ...). Repeatable. _(choices: `action_required`, `cancelled`, `completed`, `failure`, `in_progress`, `neutral`, `queued`, `skipped`, `stale`, `startup_failure`, `success`, `timed_out` · repeatable)_ - `--branch ` — Filter by head branch (repeatable). _(repeatable)_ -- `-w, --workflow ` — Filter by workflow ID (wfl-xxx, repeatable). _(repeatable)_ +- `--ci-provider ` — Filter by CI engine. _(choices: `github_actions`, `gitlab_ci`, `avrea_ci`)_ +- `-w, --workflow ` — Filter by workflow ID (wfl- or awf-, repeatable). _(repeatable)_ - `--since ` — Relative time window: '7d', '24h', etc. Sugar for --created-after. - `--from, --created-after ` — Only runs created after this ISO timestamp. - `--to, --created-before ` — Only runs created before this ISO timestamp. @@ -511,7 +512,8 @@ JSON FIELDS - `--name ` — Filter by job name (repeatable). _(repeatable)_ - `--status ` — Filter by state (queued, in_progress, completed) or conclusion (success, failure, ...). Repeatable. _(choices: `action_required`, `cancelled`, `completed`, `failure`, `in_progress`, `neutral`, `queued`, `skipped`, `stale`, `startup_failure`, `success`, `timed_out` · repeatable)_ - `--on-avrea / --shadowing` — Filter by Avrea-run vs shadowing jobs. -- `-w, --workflow ` — Filter by workflow ID (wfl-xxx, repeatable). _(repeatable)_ +- `--ci-provider ` — Filter by CI engine. _(choices: `github_actions`, `gitlab_ci`, `avrea_ci`)_ +- `-w, --workflow ` — Filter by workflow ID (wfl- or awf-, repeatable). _(repeatable)_ - `--since ` — Relative time window: '7d', '24h', etc. - `-L, --limit ` — Max jobs to return. _(default: `20`)_ - `--cursor ` — Pagination cursor from a previous response. diff --git a/docs/reference.json b/docs/reference.json index 262501e..3014899 100644 --- a/docs/reference.json +++ b/docs/reference.json @@ -983,6 +983,29 @@ "show_default": false, "default": null }, + { + "name": "ci_provider", + "kind": "option", + "opts": [ + "--ci-provider" + ], + "secondary_opts": [], + "type": "choice", + "required": false, + "multiple": false, + "nargs": 1, + "is_flag": false, + "envvar": null, + "help": "Filter by CI engine.", + "hidden": false, + "show_default": false, + "default": null, + "choices": [ + "github_actions", + "gitlab_ci", + "avrea_ci" + ] + }, { "name": "workflow_ids", "kind": "option", @@ -997,7 +1020,7 @@ "nargs": 1, "is_flag": false, "envvar": null, - "help": "Filter by workflow ID (wfl-xxx, repeatable).", + "help": "Filter by workflow ID (wfl- or awf-, repeatable).", "hidden": false, "show_default": false, "default": null @@ -2074,6 +2097,29 @@ "show_default": false, "default": null }, + { + "name": "ci_provider", + "kind": "option", + "opts": [ + "--ci-provider" + ], + "secondary_opts": [], + "type": "choice", + "required": false, + "multiple": false, + "nargs": 1, + "is_flag": false, + "envvar": null, + "help": "Filter by CI engine.", + "hidden": false, + "show_default": false, + "default": null, + "choices": [ + "github_actions", + "gitlab_ci", + "avrea_ci" + ] + }, { "name": "workflow_ids", "kind": "option", @@ -2088,7 +2134,7 @@ "nargs": 1, "is_flag": false, "envvar": null, - "help": "Filter by workflow ID (wfl-xxx, repeatable).", + "help": "Filter by workflow ID (wfl- or awf-, repeatable).", "hidden": false, "show_default": false, "default": null diff --git a/docs/starlight/reference/job.md b/docs/starlight/reference/job.md index 33fd8b3..84742af 100644 --- a/docs/starlight/reference/job.md +++ b/docs/starlight/reference/job.md @@ -43,7 +43,8 @@ JSON FIELDS - --name <TEXT> — Filter by job name (repeatable). _(repeatable)_ - --status <CHOICE> — Filter by state (queued, in_progress, completed) or conclusion (success, failure, ...). Repeatable. _(choices: `action_required`, `cancelled`, `completed`, `failure`, `in_progress`, `neutral`, `queued`, `skipped`, `stale`, `startup_failure`, `success`, `timed_out` · repeatable)_ - --on-avrea / --shadowing — Filter by Avrea-run vs shadowing jobs. -- -w, --workflow <TEXT> — Filter by workflow ID (wfl-xxx, repeatable). _(repeatable)_ +- --ci-provider <CHOICE> — Filter by CI engine. _(choices: `github_actions`, `gitlab_ci`, `avrea_ci`)_ +- -w, --workflow <TEXT> — Filter by workflow ID (wfl- or awf-, repeatable). _(repeatable)_ - --since <TEXT> — Relative time window: '7d', '24h', etc. - -L, --limit <INTEGER RANGE> — Max jobs to return. _(default: `20`)_ - --cursor <TEXT> — Pagination cursor from a previous response. diff --git a/docs/starlight/reference/run.md b/docs/starlight/reference/run.md index 7dc5ddc..027c708 100644 --- a/docs/starlight/reference/run.md +++ b/docs/starlight/reference/run.md @@ -99,7 +99,8 @@ JSON FIELDS - --repo <TEXT> — Filter by repository (org/repo or rep-xxx). Pass --repo more than once to filter multiple repositories. Auto-detected from git remote if omitted. _(repeatable)_ - --status <CHOICE> — Filter by state (queued, in_progress, completed) or conclusion (success, failure, ...). Repeatable. _(choices: `action_required`, `cancelled`, `completed`, `failure`, `in_progress`, `neutral`, `queued`, `skipped`, `stale`, `startup_failure`, `success`, `timed_out` · repeatable)_ - --branch <TEXT> — Filter by head branch (repeatable). _(repeatable)_ -- -w, --workflow <TEXT> — Filter by workflow ID (wfl-xxx, repeatable). _(repeatable)_ +- --ci-provider <CHOICE> — Filter by CI engine. _(choices: `github_actions`, `gitlab_ci`, `avrea_ci`)_ +- -w, --workflow <TEXT> — Filter by workflow ID (wfl- or awf-, repeatable). _(repeatable)_ - --since <TEXT> — Relative time window: '7d', '24h', etc. Sugar for --created-after. - --from, --created-after <TEXT> — Only runs created after this ISO timestamp. - --to, --created-before <TEXT> — Only runs created before this ISO timestamp. diff --git a/man/man1/avr-job-list.1 b/man/man1/avr-job-list.1 index 4b7be64..5d080f4 100644 --- a/man/man1/avr-job-list.1 +++ b/man/man1/avr-job-list.1 @@ -44,8 +44,11 @@ Filter by state (queued, in_progress, completed) or conclusion (success, failure \fB\-\-on\-avrea\fR / \fB\-\-shadowing\fR Filter by Avrea-run vs shadowing jobs. .TP +\fB\-\-ci\-provider\fR \fICHOICE\fR +Filter by CI engine. (choices: github_actions, gitlab_ci, avrea_ci) +.TP \fB\-w\fR, \fB\-\-workflow\fR \fITEXT\fR -Filter by workflow ID (wfl-xxx, repeatable). (repeatable) +Filter by workflow ID (wfl- or awf-, repeatable). (repeatable) .TP \fB\-\-since\fR \fITEXT\fR Relative time window: '7d', '24h', etc. diff --git a/man/man1/avr-run-list.1 b/man/man1/avr-run-list.1 index 0ac0d81..42097d0 100644 --- a/man/man1/avr-run-list.1 +++ b/man/man1/avr-run-list.1 @@ -43,8 +43,11 @@ Filter by state (queued, in_progress, completed) or conclusion (success, failure \fB\-\-branch\fR \fITEXT\fR Filter by head branch (repeatable). (repeatable) .TP +\fB\-\-ci\-provider\fR \fICHOICE\fR +Filter by CI engine. (choices: github_actions, gitlab_ci, avrea_ci) +.TP \fB\-w\fR, \fB\-\-workflow\fR \fITEXT\fR -Filter by workflow ID (wfl-xxx, repeatable). (repeatable) +Filter by workflow ID (wfl- or awf-, repeatable). (repeatable) .TP \fB\-\-since\fR \fITEXT\fR Relative time window: '7d', '24h', etc. Sugar for --created-after.