Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,26 @@ avr run logs <run-id> # full log dump, paginated
avr job logs <job-id> --follow # tail one job in real time
```

**Read a job's recorded tests**

```sh
avr tests <job-id> # names, outcomes, timings and failure messages
avr tests <job-id> --json '*' # results with reporting state and pagination
avr tests <job-id> --limit 500 # follow short pages up to the requested count
avr tests <job-id> --cursor '<cursor>' # continue with pagination.next_cursor
```

Use the Avrea job ID (`job-` or `awj-`) from `avr job list`; the repository is
resolved from that job. `avr tests list <job-id>` is equivalent. `--json FIELDS` and `--jq` also
work. Each invocation reads at most 1,000 occurrences and 20 result pages,
preserving repeated tests and returning a cursor when more results remain.
Missing or expired reports are distinct from a retained report containing zero
tests. A report marked complete does not establish that every expected test ran.

This requires an API deployment supporting job test results and access to the
repository's test evidence. Only Avrea job IDs are supported. The command does
not rerun tests or change collection.

**Trigger a workflow_dispatch**

```sh
Expand Down
236 changes: 236 additions & 0 deletions avrea_cli/commands/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
"""Read the retained test results of a job."""

from avrea_cli.api_client import ApiClient
from avrea_cli.click_ext import GhGroup
from avrea_cli.config import CliConfig
from avrea_cli.display import escape_control_characters
from avrea_cli.helpers import ensure_authenticated
from avrea_cli.helpers import ensure_ctx
from avrea_cli.helpers import get_org_id
from avrea_cli.helpers import handle_http_error
from avrea_cli.helpers import validate_cursor
from avrea_cli.json_output import emit_json_record
from avrea_cli.json_output import handle_json_meta
from avrea_cli.json_output import json_options
from avrea_cli.json_output import make_schema
from avrea_cli.json_output import split_fields
from avrea_cli.output import output_list
from avrea_cli.repo_context import resolve_repo
from typing import Any
import click
import httpx
import re
import shlex

_SNAPSHOT_FIELDS = (
"job_id",
"execution_id",
"status",
"reporting_state",
"reason",
"commit_sha",
"observed_at",
"total_count",
)
_FIELDS = make_schema("organization_id", "repository_id", *_SNAPSHOT_FIELDS, "data", "pagination")
_MAX_PAGES = 20
_JOB_ID = re.compile(r"(job|awj)-[0-9a-f]{32}")
_INVALID_NEXT_CURSOR = "The API returned an invalid next cursor."


class JobTestsGroup(GhGroup):
"""Accept the documented job shorthand without swallowing unknown verbs."""

def resolve_command(
self, ctx: click.Context, args: list[str]
) -> tuple[str | None, click.Command | None, list[str]]:
if args and args[0].startswith(("job-", "awj-")):
args = ["list", *args]
return super().resolve_command(ctx, args)


def _job_id(_ctx: click.Context, param: click.Parameter, value: str) -> str:
if not _JOB_ID.fullmatch(value):
raise click.BadParameter(
"use the Avrea job ID from 'avr job list' (job- or awj- followed by 32 lowercase hex characters)",
param=param,
)
return value


def _get(
client: ApiClient, path: str, action: str, not_found_hint: str, params: dict[str, Any] | None = None
) -> dict[str, Any]:
try:
response = client.public_get(path, params=params)
except httpx.HTTPStatusError as exc:
handle_http_error(exc, action, hints={404: not_found_hint})
return response


def _job_repository(client: ApiClient, organization: str, job: str) -> str:
response = _get(
client,
f"/orgs/{organization}/jobs/{job}",
"read the job",
"Check the job ID, or pass --org with the job's organization.",
)
data = response.get("data", response)
repository = data.get("repository_id") if isinstance(data, dict) else None
if not isinstance(repository, str) or not re.fullmatch(r"rep-[0-9a-f]{32}", repository):
raise click.ClickException("The job has no available repository. Check the job and selected organization.")
if data.get("job_id") != job:
raise click.ClickException("The API returned a different job. Retry with the exact Avrea job ID.")
return repository


def _results(client: ApiClient, path: str, job: str, limit: int, cursor: str | None) -> dict[str, Any]:
"""Follow short pages within a row and request budget, retaining the resume cursor."""
rows: list[dict[str, Any]] = []
snapshot: dict[str, Any] | None = None
seen = {cursor} if cursor else set()
for _ in range(_MAX_PAGES):
page_limit = limit - len(rows)
params: dict[str, Any] = {"limit": page_limit}
if cursor is not None:
params["cursor"] = cursor
page = _get(
client,
path,
"read job test results",
# A new runtime invalidates old cursors, and the route also answers 404
# when Repository Tests are off or the job is outside the repository.
"If you passed --cursor, restart without it. Otherwise check that Repository"
" Tests are enabled and that the job belongs to the repository.",
params,
)
data = page.get("data")
pagination = page.get("pagination")
if page.get("job_id") != job or not isinstance(data, list) or not isinstance(pagination, dict):
raise click.ClickException("The API returned invalid job test results.")
if len(data) > page_limit or not all(isinstance(row, dict) for row in data):
raise click.ClickException("The API returned an invalid test result page.")
if snapshot is None:
snapshot = page
elif any(page.get(key) != snapshot.get(key) for key in _SNAPSHOT_FIELDS):
raise click.ClickException("Job results changed while reading pages. Retry without --cursor.")
cursor = pagination.get("next_cursor")
if cursor is not None:
if not isinstance(cursor, str):
raise click.ClickException(_INVALID_NEXT_CURSOR)
try:
validate_cursor(cursor, max_length=1024)
except click.BadParameter:
# The server's cursor is not user input, so it gets no usage error.
raise click.ClickException(_INVALID_NEXT_CURSOR) from None
if cursor in seen:
raise click.ClickException("The API repeated a test-results cursor; stopping pagination.")
seen.add(cursor)
rows.extend(data)
if cursor is None or len(rows) == limit:
break
assert snapshot is not None
return {**snapshot, "data": rows, "pagination": {"next_cursor": cursor}}


def _text(value: Any) -> str:
return "unknown" if value is None else escape_control_characters(str(value))


def _show(response: dict[str, Any]) -> None:
click.echo(f"Job: {_text(response['job_id'])}")
click.echo(f"Organization: {_text(response['organization_id'])} Repository: {_text(response['repository_id'])}")
click.echo(f"Execution: {_text(response.get('execution_id'))} Commit: {_text(response.get('commit_sha'))}")
click.echo(f"Reporting: {_text(response.get('reporting_state'))} Results: {_text(response.get('status'))}")
if response.get("reason"):
click.echo(f"Reason: {_text(response['reason'])}")
click.echo(f"Retained occurrences: {_text(response.get('total_count'))}; shown: {len(response['data'])}")
rows = response["data"]
if rows:
columns = ("suite", "class_name", "name", "outcome", "duration_ms")
output_list(
[{key: _text(row.get(key)) for key in columns} for row in rows],
columns=columns,
column_labels=["Suite", "Class", "Test", "Outcome", "Time (ms)"],
)
for row in rows:
if row.get("failure_summary"):
# The class keeps same-named tests in different classes apart.
label = ".".join(_text(row.get(key)) for key in ("class_name", "name") if row.get(key))
click.echo(f"\n{label or 'unknown'}: {_text(row['failure_summary'])}")
elif response.get("status") == "available" and response.get("total_count") == 0:
click.echo("The retained report contains zero test occurrences. This does not establish that no tests ran.")
elif response.get("status") == "available":
click.echo("No more results.")
else:
click.echo("No test results returned. Missing or expired reports do not mean that zero tests ran.")
Comment thread
varjoranta marked this conversation as resolved.
Resolved
if response.get("reporting_state") == "complete":
click.echo("Complete describes the collected reports, not whether every expected test ran.")


@click.group(cls=JobTestsGroup)
@click.pass_context
def tests(ctx: click.Context) -> None:
"""Read a job's tests, timings and recorded failure messages.

Use avr tests JOB (or avr tests list JOB). JOB is an Avrea job ID
(job- or awj-) from avr job list.
"""
ensure_ctx(ctx)


@tests.command("list")
@click.argument("job", metavar="JOB", callback=_job_id)
@click.option("--org", "org_id", help="Organization ID or slug. Defaults to the configured organization.")
@click.option("--repo", "repo_id", help="Repository (owner/name or rep-ID). Defaults to the job's repository.")
@click.option(
"-L",
"--limit",
type=click.IntRange(1, 1000),
default=100,
show_default=True,
help=f"Maximum occurrences to return; short pages are followed, up to {_MAX_PAGES} result pages.",
)
@click.option("--cursor", help="Continue from a previous response's next_cursor.")
@json_options
@click.pass_context
def tests_list(
ctx: click.Context,
job: str,
org_id: str | None,
repo_id: str | None,
limit: int,
cursor: str | None,
json_fields: str | None,
jq_expr: str | None,
) -> None:
"""Read the latest confirmed execution of JOB, including repeated tests.

Names, outcomes, recorded durations and available failure messages are
shown together. A failed test is data, not a command error. Missing,
pending, partial and expired evidence are reported explicitly; an older
execution is never substituted. Complete describes collected reports.

Use --json '*' for the complete response or --json FIELDS to select
fields. Continue with --cursor when pagination.next_cursor is present.
API refusal or a changed report fails the command without partial output.
"""
if handle_json_meta(json_fields, jq_expr, _FIELDS):
return
cursor = validate_cursor(cursor, max_length=1024)
client: ApiClient = ctx.obj["client"]
config: CliConfig = ctx.obj["config"]
ensure_authenticated(config)
organization = get_org_id(config, org_id, client=client)
repository = (
resolve_repo(client, config, organization, repo_id) if repo_id else _job_repository(client, organization, job)
)
response = _results(client, f"/orgs/{organization}/repos/{repository}/jobs/{job}/tests", job, limit, cursor)
response.update(organization_id=organization, repository_id=repository)
if json_fields is not None:
emit_json_record(response, split_fields(json_fields, _FIELDS), _FIELDS, jq_expr)
else:
_show(response)
next_cursor = response["pagination"]["next_cursor"]
if next_cursor:
click.echo(f"More results available. Continue with --cursor {shlex.quote(_text(next_cursor))}", err=True)
8 changes: 4 additions & 4 deletions avrea_cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,13 +267,13 @@ def get_verified_org_slug(client: ApiClient, org_id: str) -> str:
raise click.ClickException("Could not verify the selected organization for this Avrea run URL.")


# Opaque base64 cursors are short — Avrea's cursors are well under 200 chars.
# Most list cursors are short; endpoints with snapshot cursors can set a larger bound.
# 512 leaves headroom for protocol changes while still rejecting paste-mistakes
# (e.g. a whole file pasted into --cursor) before they hit the server.
_MAX_CURSOR_LEN = 512


def validate_cursor(cursor: str | None) -> str | None:
def validate_cursor(cursor: str | None, *, max_length: int = _MAX_CURSOR_LEN) -> str | None:
"""Bound a user-supplied pagination cursor at the CLI boundary.

Cursors are opaque base64 from a previous response, so the only useful
Expand All @@ -284,9 +284,9 @@ def validate_cursor(cursor: str | None) -> str | None:
through."""
if cursor is None:
return None
if not cursor or cursor.strip() != cursor or len(cursor) > _MAX_CURSOR_LEN:
if not cursor or cursor.strip() != cursor or len(cursor) > max_length:
raise click.BadParameter(
f"cursor must be 1..{_MAX_CURSOR_LEN} chars and not surrounded by whitespace (got {len(cursor)} chars)",
f"cursor must be 1..{max_length} chars and not surrounded by whitespace (got {len(cursor)} chars)",
param_hint="--cursor",
)
return cursor
Expand Down
7 changes: 7 additions & 0 deletions avrea_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
("vm", "avrea_cli.vm", "vm", "Manage long-running VMs (SSH/RDP/VNC).", "Core Commands"),
("workflow", "avrea_cli.commands.workflow", "workflow", "List and view workflow definitions.", "Core Commands"),
("cache", "avrea_cli.commands.cache", "cache", "Inspect and manage the Avrea build cache.", "Core Commands"),
(
"tests",
"avrea_cli.commands.tests",
"tests",
"Read a job's tests, timings and recorded failure messages.",
"Core Commands",
),
("sbom", "avrea_cli.commands.sbom", "sbom", "View, generate, and download repository SBOMs.", "Core Commands"),
("log", "avrea_cli.commands.log", "log", "Search across runner execution logs.", "Core Commands"),
("auth", "avrea_cli.commands.auth_cmd", "auth_group", "Authenticate and manage credentials.", "Setup & Config"),
Expand Down
42 changes: 42 additions & 0 deletions docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ avr [GLOBAL OPTIONS] COMMAND [ARGS]...
- [`avr vm`](#avr-vm) — Manage long-running VMs (SSH/RDP/VNC).
- [`avr workflow`](#avr-workflow) — List and view workflow definitions.
- [`avr cache`](#avr-cache) — Inspect and manage the Avrea build cache.
- [`avr tests`](#avr-tests) — Read a job's tests, timings and recorded failure messages.
- [`avr sbom`](#avr-sbom) — View, generate, and download repository SBOMs.
- [`avr log`](#avr-log) — Search across runner execution logs.

Expand Down Expand Up @@ -1303,6 +1304,47 @@ JSON FIELDS
- `--json <TEXT>` — Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields.
- `-q, --jq <TEXT>` — Filter --json output through a jq expression.

### `avr tests`

Read a job's tests, timings and recorded failure messages.

```sh
avr tests [OPTIONS] COMMAND [ARGS]...
```

Use avr tests JOB (or avr tests list JOB). JOB is an Avrea job ID
(job- or awj-) from avr job list.

#### `avr tests list`

Read the latest confirmed execution of JOB, including repeated tests.

```sh
avr tests list [OPTIONS] JOB
```

Names, outcomes, recorded durations and available failure messages are
shown together. A failed test is data, not a command error. Missing,
pending, partial and expired evidence are reported explicitly; an older
execution is never substituted. Complete describes collected reports.

Use --json '*' for the complete response or --json FIELDS to select
fields. Continue with --cursor when pagination.next_cursor is present.
API refusal or a changed report fails the command without partial output.

**Arguments**

- `JOB`

**Options**

- `--org <TEXT>` — Organization ID or slug. Defaults to the configured organization.
- `--repo <TEXT>` — Repository (owner/name or rep-ID). Defaults to the job's repository.
- `-L, --limit <INTEGER RANGE>` — Maximum occurrences to return; short pages are followed, up to 20 result pages. _(default: `100`)_
- `--cursor <TEXT>` — Continue from a previous response's next_cursor.
- `--json <TEXT>` — Output JSON. Pass comma-separated field names, "*" for all fields, or "?" to list available fields.
- `-q, --jq <TEXT>` — Filter --json output through a jq expression.

### `avr sbom`

View, generate, and download repository SBOMs.
Expand Down
Loading
Loading