diff --git a/.github/workflows/issue-filters.yml b/.github/workflows/issue-filters.yml new file mode 100644 index 000000000..0b0cc50fc --- /dev/null +++ b/.github/workflows/issue-filters.yml @@ -0,0 +1,52 @@ +name: Issue filters + +# The existing CI and parity workflows do not target this feature's stacked +# base. Keep verification separate from the workflow edit owned by feat/otel. +on: + pull_request: + branches: [feat/fp-cli-parity] + paths: + - "fp-cloud-cli/**" + - ".github/workflows/issue-filters.yml" + - "CHANGELOG.md" + +permissions: + contents: read + +concurrency: + group: issue-filters-${{ github.ref }} + cancel-in-progress: true + +jobs: + cloud-cli: + name: Issue filters (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + defaults: + run: + working-directory: fp-cloud-cli + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: fp-cloud-cli/uv.lock + - run: uv sync --locked --extra dev --python ${{ matrix.python-version }} + - name: Test all CLI behaviour + env: + FP_CLI_REQUIRE_CONTRACT: "1" + run: uv run --extra dev pytest -q + - run: uv build + - name: Clean wheel install and user-level issue-filter smoke + run: | + uv venv /tmp/fp-issue-smoke --python ${{ matrix.python-version }} + uv pip install --python /tmp/fp-issue-smoke/bin/python dist/*.whl + /tmp/fp-issue-smoke/bin/fp --version + /tmp/fp-issue-smoke/bin/python tests/issue_filters_smoke.py \ + --fp /tmp/fp-issue-smoke/bin/fp --artifacts /tmp/fp-issue-artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8284cf57c..6cf8dd2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,11 @@ # Changelog -## 1.0.11-beta.1 — 2026-10-13 +## 1.0.11-beta.1 — 2026-10-14 ### Features +- Add Cloud CLI issue filters and facets for agent, environment, machine, source/alert/audit, assignee, opened dates and title/summary text (#900). + - Cloud CLI parity: hosted code, LLM-judge and Jev evaluator authoring/deployment, dashboards, sentiment, assistant artifacts/approvals, org controls and the shared mint/pink screen language (#885) - New terminal look for the CLI, step one: the palette gains the redesign's label grey, progress-track grey and a red for failures. Descriptions keep the terminal's own colour, and the greys fall back to the dim attribute below 24-bit, so every screen stays readable on light themes (#881) @@ -31,6 +33,8 @@ ### Fixes +- Align Cloud CLI issue tags, JSON and alert/audit facets with the server's top-level scope contract, without extra requests on normal filtered calls (#900). + - `failproofai policies` showed nothing for a machine still on the pre-pack builtins, while those builtins were enforcing. It now says how many under BUILT IN, with the command that moves them into a pack (#881) - `failproofai config --disconnect` told you to restart the daemon to stop it sending. It stops on its own within a few seconds, because it polls the credential, and now says so (#881) - A config change that landed while the daemon was restarting its collector after a backfill was recorded as already running and never applied. `failproofai config` re-adding an agent hits exactly that, writing its backfill request and then the selection, so the agent stayed off until some unrelated edit (#881) diff --git a/__tests__/ci/issue-filters-workflow.test.ts b/__tests__/ci/issue-filters-workflow.test.ts new file mode 100644 index 000000000..278d7a927 --- /dev/null +++ b/__tests__/ci/issue-filters-workflow.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; + +const workflow = parse(readFileSync( + resolve(process.cwd(), ".github/workflows/issue-filters.yml"), "utf8", +)); +const scripts = (job: { steps: { run?: string }[] }) => + job.steps.map((step) => step.run ?? "").join("\n"); + +describe("issue-filter stacked verification", () => { + it("tests the actual PR base without publishing or release credentials", () => { + expect(workflow.on.pull_request.branches).toContain("feat/fp-cli-parity"); + expect(workflow.on.workflow_dispatch).toBeUndefined(); + expect(workflow.permissions).toEqual({ contents: "read" }); + expect(workflow.on.pull_request.paths).toContain("fp-cloud-cli/**"); + }); + + it("keeps this client-only feature's verification scoped to the CLI", () => { + expect(Object.keys(workflow.jobs)).toEqual(["cloud-cli"]); + expect(workflow.on.pull_request.paths).not.toContain("crates/fpai-collect/**"); + }); + + it("tests both auth modes across supported Python versions and a clean wheel", () => { + const job = workflow.jobs["cloud-cli"]; + expect(job.strategy.matrix["python-version"]).toEqual(["3.10", "3.11", "3.12", "3.13"]); + const run = scripts(job); + expect(run).toContain("uv sync --locked --extra dev"); + expect(run).toContain("pytest -q"); + expect(run).toContain("uv build"); + expect(run).toContain("uv pip install --python /tmp/fp-issue-smoke/bin/python dist/*.whl"); + expect(run).toContain("tests/issue_filters_smoke.py"); + }); +}); diff --git a/fp-cloud-cli/CHANGELOG.md b/fp-cloud-cli/CHANGELOG.md index d028972fc..e450b69df 100644 --- a/fp-cloud-cli/CHANGELOG.md +++ b/fp-cloud-cli/CHANGELOG.md @@ -1,9 +1,11 @@ # Changelog — `fp` CLI -## 0.0.2b1 — 2026-10-13 +## 0.0.2b1 — 2026-10-14 ### Added +- Filter issues list/count and discover facets by agent, environment, machine, source/alert/audit, assignee, opened dates and title/summary text in session or key mode; show agent tags and warn when an older Cloud may ignore filters (#900). + - Hosted evaluator authoring for code, LLM judge and Jev: create/deploy, AI draft specs, dry-run, labels, toggles, instances, schema, jobs and code-only backfill. - Dashboards and tiles; session overview/export/history/re-evaluate; latency and sentiment; query composition/export; alert recipients/issues and agent contracts. - Policy author/backtest/candidacy, assistant artifact tables, fail-closed interactive write approvals, and agent-curated home briefs. @@ -17,6 +19,8 @@ ### Fixes +- Align issue scope JSON and named alert/audit facets with agenteye #1074; avoid capability requests on normal filtered calls and limit empty-result probes and older-Cloud warnings to once per process (#900). + - Remove the unused colour-keyed permission rank, document full-id rendering, emit UTC `Z` timestamps for deploy backfills, and report approval-answer failures without a traceback while restoring the terminal (#885). - Reject natural-language binary judge steps, distinguish unknown routes from missing records on 404, normalize standalone backfill ranges to UTC `Z`, expose all subcommands in help, and render artifact headers before titles with consistent session-only wording (#885). - New Cloud commands use deliberate human summaries/tables and actionable empty states at 80/140 columns without raw response blobs. Evaluator previews display before writes and require explicit `--force` after failures; plan/claim deny key mode, deploy specs can omit the positional key, and older settings-route 404s degrade clearly. Existing JSON payloads stay unchanged (#885). diff --git a/fp-cloud-cli/README.md b/fp-cloud-cli/README.md index 227b29a1f..fdd985093 100644 --- a/fp-cloud-cli/README.md +++ b/fp-cloud-cli/README.md @@ -127,6 +127,34 @@ fp --json events --session-id run-001 --all | jq '.events[].payload' fp --json sessions --since 7d --fields session_id,status,scores ``` +### Filter the failures inbox + +Issue filters work with either a signed-in session or an API key carrying `issues:read`: + +```bash +fp --json issues facets --env prod +fp issues list --agent claude-repo --agent codex-repo --env prod --assignee me --since 7d +fp --json issues count --source audit --audit weekly-audit --machine ci-runner-01 +fp issues list --agent claude-checkout --assignee unassigned --search "tool timeout" \ + --since 2026-10-01T00:00:00Z --until 2026-10-14T00:00:00Z +``` + +`issues list`, `count` and `facets` share `--agent`, `--env`, +`--machine`, `--source alert|audit|manual`, `--alert`, `--audit` and +`--assignee EMAIL|me|unassigned`. These accept repeated flags or comma-separated +values (OR within a filter, AND between filters). `--since` accepts the existing +`all|15m|1h|6h|24h|7d` presets or RFC3339; `--until` accepts RFC3339. Dates apply +to issue opening, and `--search` searches title + summary. Human rows show the +stored agent scope; facets show available values and counts. + +Older Clouds may ignore the new query parameters. A returned issue's top-level +`agent_ids` key confirms filter support without another request. Non-empty +legacy rows warn once; empty lists or zero counts probe facets at most once +per process, and a missing route warns once that results may be unfiltered. +Positive counts make no extra request. Warnings leave stdout JSON usable. Calling +missing `facets` directly exits 6 with “not available on this FailproofAI Cloud yet”. +Upgrade Cloud for filtering. Existing `list --alert-id` remains supported. + ### Cloud-managed policies Three commands for the three jobs the dashboard splits across three pages — diff --git a/fp-cloud-cli/fp_cli/client.py b/fp-cloud-cli/fp_cli/client.py index d60c855e6..6a18ffc8b 100644 --- a/fp-cloud-cli/fp_cli/client.py +++ b/fp-cloud-cli/fp_cli/client.py @@ -672,6 +672,7 @@ def _cloud_not_found(exc: FpCliError, path: str, *, body=None, params=None) -> N or family == "queries" and segments[1:] in (["run"], ["schema"]) or family == "audits" and segments == ["audits", "contracts"] or family == "alerts" and segments == ["alerts", "recipients"] + or family == "issues" and segments in (["issues", "count"], ["issues", "facets"]) or family == "users" and segments == ["users", "defaults"] or family == "usage" and segments == ["usage", "windows"] or family == "settings" and segments == ["settings", "model-context-windows"] @@ -1293,6 +1294,12 @@ def test_alert(ctx: ClientContext, alert_id: str, channels: Optional[List[Dict[s # --- Incidents -------------------------------------------------------------- +# Capability detection is only a fallback for empty results. Keep both the +# probe and the compatibility warning to at most once per CLI process. +_issue_facets_probed = False +_issue_facets_available: Optional[bool] = None +_issue_filters_warned = False + def list_incidents( ctx: ClientContext, @@ -1300,20 +1307,32 @@ def list_incidents( state: Optional[str] = None, alert_id: Optional[str] = None, limit: Optional[int] = None, + filters: Optional[Dict[str, str]] = None, + on_filters_unavailable: Optional[Callable[[], None]] = None, ) -> List[Incident]: """GET /api/issues (or /api/alerts/{id}/issues when alert_id given). The alert-scoped path honours ``state``/``limit`` too — pass them so the filters aren't silently dropped on that path (``_get_json`` omits None params).""" - if alert_id: + if alert_id and not filters: data = _get_json(ctx, f"/api/alerts/{alert_id}/issues", {"state": state, "limit": limit}) else: - data = _get_json(ctx, "/api/issues", {"state": state, "limit": limit}) - return [Incident.from_dict(i) for i in (data if isinstance(data, list) else [])] - - -def count_incidents(ctx: ClientContext, *, state: Optional[str] = None) -> int: + params = {"state": state, "limit": limit, **(filters or {})} + if alert_id: + params["alert_id"] = alert_id + data = cloud_request(ctx, "GET", "/api/issues", params=params) + items = data.get("issues", []) if isinstance(data, dict) else data + _check_issue_filters(ctx, items, filters, on_filters_unavailable) + return [Incident.from_dict(i) for i in (items if isinstance(items, list) else [])] + + +def count_incidents( + ctx: ClientContext, *, state: Optional[str] = None, + filters: Optional[Dict[str, str]] = None, + on_filters_unavailable: Optional[Callable[[], None]] = None, +) -> int: """GET /api/issues/count — {count}.""" - data = _get_json(ctx, "/api/issues/count", {"state": state}) + data = cloud_request(ctx, "GET", "/api/issues/count", params={"state": state, **(filters or {})}) + _check_issue_filters(ctx, data, filters, on_filters_unavailable) if not isinstance(data, dict): return 0 try: @@ -1322,6 +1341,52 @@ def count_incidents(ctx: ClientContext, *, state: Optional[str] = None) -> int: return 0 +def issue_facets( + ctx: ClientContext, *, state: Optional[str] = None, + filters: Optional[Dict[str, str]] = None, +) -> Dict[str, Any]: + """GET /api/issues/facets — available scope/source/assignee values with counts.""" + data = cloud_request(ctx, "GET", "/api/issues/facets", params={"state": state, **(filters or {})}) + if not isinstance(data, dict): + raise ApiError("Cloud returned an invalid issues facets response") + return data + + +def _check_issue_filters( + ctx: ClientContext, data: Any, filters: Optional[Dict[str, str]], + on_unavailable: Optional[Callable[[], None]], +) -> None: + """New Cloud always sends `agent_ids`, even when its array is empty. + + Non-empty lists confirm support from the raw rows (before model defaults + fill missing keys), or warn for legacy rows without making another request. + Empty lists/zero counts probe facets once per process. Positive counts have + no scope marker and make no extra request; a cached missing route still + warrants a warning. Probe auth/network/server errors remain visible. + """ + global _issue_facets_probed, _issue_facets_available, _issue_filters_warned + if not filters: + return + if isinstance(data, list) and data: + if any(isinstance(row, dict) and "agent_ids" in row for row in data): + return + unavailable = True + else: + empty = data == [] or isinstance(data, dict) and data.get("count") == 0 + if empty and not _issue_facets_probed: + _issue_facets_probed = True + try: + issue_facets(ctx, filters=filters) + except NotFoundError: + _issue_facets_available = False + else: + _issue_facets_available = True + unavailable = _issue_facets_available is False + if unavailable and on_unavailable and not _issue_filters_warned: + _issue_filters_warned = True + on_unavailable() + + def get_incident(ctx: ClientContext, incident_id: str) -> Incident: """GET /api/issues/{id} — full detail (comments, subscribers, activity).""" return Incident.from_dict(_get_json(ctx, f"/api/issues/{incident_id}")) diff --git a/fp-cloud-cli/fp_cli/commands/incidents_cmds.py b/fp-cloud-cli/fp_cli/commands/incidents_cmds.py index 741c996bd..e3ec0609e 100644 --- a/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +++ b/fp-cloud-cli/fp_cli/commands/incidents_cmds.py @@ -13,8 +13,8 @@ import typer from .. import client as api -from .. import output -from .._context import GLOBALS_EPILOG, AppState, require_auth, validate_limit +from .. import dates, output +from .._context import GLOBALS_EPILOG, AppState, require_auth, resolve_dates, validate_limit from ..errors import ApiError, ForbiddenError, NotFoundError from . import _write @@ -23,6 +23,7 @@ # TERMINAL state (won't-fix); missing it here would reject `--state closed` # client-side with exit 2 for a state the server accepts. _STATES = ("firing", "acknowledged", "resolved", "closed") +_SOURCES = ("alert", "audit", "manual") _UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") @@ -68,12 +69,70 @@ def _fail(state: AppState, exc: Exception, *, incident_id: str = "") -> None: raise exc +def _issue_filters( + agent, environment, machine, source, alert, audit, assignee, + since, until, search, +) -> dict: + """The shared list/count/facets contract; repeated values become CSV.""" + filters = {} + for key, values in ( + ("agent", agent), ("environment", environment), + ("machine", machine), ("source", source), ("alert_id", alert), + ("audit_id", audit), ("assignee", assignee), + ): + if not values: + continue + parts = [part.strip() for value in values for part in value.split(",")] + if any(not part for part in parts): + raise typer.BadParameter(f"--{key} needs a non-empty value") + if key == "source" and any(part not in _SOURCES for part in parts): + raise typer.BadParameter(f"--source must be one of: {', '.join(_SOURCES)}") + filters[key] = ",".join(dict.fromkeys(parts)) + # Reuse the CLI presets and RFC3339 validation. Compute each bound + # independently: an explicit --until must not cancel a relative --since. + if since in dates.SINCE_CHOICES or since is None: + opened_from, _ = resolve_dates(since, None, None) + else: + try: + opened_from = dates._validate_iso(since, "--since") + except ValueError as exc: + raise typer.BadParameter(str(exc)) from exc + try: + opened_to = dates._validate_iso(until, "--until") if until is not None else None + except ValueError as exc: + raise typer.BadParameter(str(exc)) from exc + if opened_from: + filters["opened_from"] = opened_from + if opened_to: + filters["opened_to"] = opened_to + if search is not None: + filters["q"] = search + return filters + + +def _filters_unavailable() -> None: + output.cli_warn( + "▲ Issue filters are not available on this FailproofAI Cloud yet; " + "Cloud may have returned unfiltered results. Upgrade your Cloud deployment." + ) + + def incidents_list( ctx: typer.Context, state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s): firing, acknowledged, resolved, closed (CSV)."), alert_id: Optional[str] = typer.Option(None, "--alert-id", help="Only incidents for this alert."), limit: int = typer.Option(50, "--limit", "-n", help="Max incidents to return."), show_id: bool = typer.Option(False, "--show-id", help="Show the full incident id instead of the short form (always full in --json)."), + agent: Optional[List[str]] = typer.Option(None, "--agent", help="Agent id (repeatable or CSV)."), + environment: Optional[List[str]] = typer.Option(None, "--env", help="Environment (repeatable or CSV)."), + machine: Optional[List[str]] = typer.Option(None, "--machine", help="Machine id (repeatable or CSV)."), + source: Optional[List[str]] = typer.Option(None, "--source", help="Source: alert, audit, manual (repeatable or CSV)."), + alert: Optional[List[str]] = typer.Option(None, "--alert", help="Alert id (repeatable or CSV)."), + audit: Optional[List[str]] = typer.Option(None, "--audit", help="Audit id (repeatable or CSV)."), + assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Email, me, or unassigned (repeatable or CSV)."), + since: Optional[str] = typer.Option(None, "--since", help="Opened since a preset (all, 15m, 1h, 6h, 24h, 7d) or RFC3339 timestamp."), + until: Optional[str] = typer.Option(None, "--until", help="Opened before an RFC3339 timestamp."), + search: Optional[str] = typer.Option(None, "--search", help="Text in the issue title or summary."), ) -> None: """List incidents in a boxed table (newest-opened first). @@ -93,8 +152,14 @@ def incidents_list( state: AppState = ctx.obj _validate_states(state_filter) validate_limit(limit) + filters = _issue_filters(agent, environment, machine, source, alert, audit, assignee, since, until, search) + if alert_id and "alert_id" in filters: + raise typer.BadParameter("use --alert or --alert-id, not both") cctx = require_auth(state) - incidents = api.list_incidents(cctx, state=state_filter, alert_id=alert_id, limit=limit) + incidents = api.list_incidents( + cctx, state=state_filter, alert_id=alert_id, limit=limit, + filters=filters, on_filters_unavailable=_filters_unavailable, + ) if state.json: output.emit_json({"issues": incidents}) return @@ -105,6 +170,16 @@ def incidents_list( def incidents_count( ctx: typer.Context, state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s) (CSV)."), + agent: Optional[List[str]] = typer.Option(None, "--agent", help="Agent id (repeatable or CSV)."), + environment: Optional[List[str]] = typer.Option(None, "--env", help="Environment (repeatable or CSV)."), + machine: Optional[List[str]] = typer.Option(None, "--machine", help="Machine id (repeatable or CSV)."), + source: Optional[List[str]] = typer.Option(None, "--source", help="Source: alert, audit, manual (repeatable or CSV)."), + alert: Optional[List[str]] = typer.Option(None, "--alert", help="Alert id (repeatable or CSV)."), + audit: Optional[List[str]] = typer.Option(None, "--audit", help="Audit id (repeatable or CSV)."), + assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Email, me, or unassigned (repeatable or CSV)."), + since: Optional[str] = typer.Option(None, "--since", help="Opened since a preset (all, 15m, 1h, 6h, 24h, 7d) or RFC3339 timestamp."), + until: Optional[str] = typer.Option(None, "--until", help="Opened before an RFC3339 timestamp."), + search: Optional[str] = typer.Option(None, "--search", help="Text in the issue title or summary."), ) -> None: """Count incidents (optionally by state) as a compact stat card. @@ -117,14 +192,45 @@ def incidents_count( """ state: AppState = ctx.obj _validate_states(state_filter) + filters = _issue_filters(agent, environment, machine, source, alert, audit, assignee, since, until, search) cctx = require_auth(state) - count = api.count_incidents(cctx, state=state_filter) + count = api.count_incidents(cctx, state=state_filter, filters=filters, on_filters_unavailable=_filters_unavailable) if state.json: output.emit_json({"count": count}) else: output.render_incident_count(count, state=state_filter) +def incidents_facets( + ctx: typer.Context, + state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s) (CSV)."), + agent: Optional[List[str]] = typer.Option(None, "--agent", help="Agent id (repeatable or CSV)."), + environment: Optional[List[str]] = typer.Option(None, "--env", help="Environment (repeatable or CSV)."), + machine: Optional[List[str]] = typer.Option(None, "--machine", help="Machine id (repeatable or CSV)."), + source: Optional[List[str]] = typer.Option(None, "--source", help="Source: alert, audit, manual (repeatable or CSV)."), + alert: Optional[List[str]] = typer.Option(None, "--alert", help="Alert id (repeatable or CSV)."), + audit: Optional[List[str]] = typer.Option(None, "--audit", help="Audit id (repeatable or CSV)."), + assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Email, me, or unassigned (repeatable or CSV)."), + since: Optional[str] = typer.Option(None, "--since", help="Opened since a preset (all, 15m, 1h, 6h, 24h, 7d) or RFC3339 timestamp."), + until: Optional[str] = typer.Option(None, "--until", help="Opened before an RFC3339 timestamp."), + search: Optional[str] = typer.Option(None, "--search", help="Text in the issue title or summary."), +) -> None: + """Show available issue filter values with counts. Needs issues:read. + + Example: + + * `fp --json issues facets --env prod` + """ + state: AppState = ctx.obj + _validate_states(state_filter) + filters = _issue_filters(agent, environment, machine, source, alert, audit, assignee, since, until, search) + data = api.issue_facets(require_auth(state), state=state_filter, filters=filters) + if state.json: + output.emit_json(data) + else: + output.render_issue_facets(data) + + def incidents_show( ctx: typer.Context, incident_id: str = typer.Argument(..., help="Incident id."), @@ -671,10 +777,11 @@ def register(app: typer.Typer) -> None: no_args_is_help=True, rich_markup_mode="markdown", context_settings={"help_option_names": ["-h", "--help"]}, - help="Triage issues (list / count / show / ack / assign / resolve / close / archive / unarchive / clear / comment-* / subscribe* / open).", + help="Triage issues (list / count / facets / show / ack / assign / resolve / close / archive / unarchive / clear / comment-* / subscribe* / open).", ) inc.command("list", epilog=GLOBALS_EPILOG)(incidents_list) inc.command("count", epilog=GLOBALS_EPILOG)(incidents_count) + inc.command("facets", epilog=GLOBALS_EPILOG)(incidents_facets) inc.command("show", epilog=GLOBALS_EPILOG)(incidents_show) inc.command("ack", epilog=GLOBALS_EPILOG)(incidents_ack) inc.command("assign", epilog=GLOBALS_EPILOG)(incidents_assign) diff --git a/fp-cloud-cli/fp_cli/models.py b/fp-cloud-cli/fp_cli/models.py index c78ee8453..5e6682164 100644 --- a/fp-cloud-cli/fp_cli/models.py +++ b/fp-cloud-cli/fp_cli/models.py @@ -430,6 +430,11 @@ class Incident: subscribers: Optional[List[Dict[str, Any]]] = None comments: Optional[List[Dict[str, Any]]] = None activity: Optional[List[Dict[str, Any]]] = None + #: Stored evidence scope, returned as top-level fields by Cloud. + agent_ids: List[str] = field(default_factory=list) + environments: List[str] = field(default_factory=list) + machine_ids: List[str] = field(default_factory=list) + scope_computed_at: Optional[str] = None @classmethod def from_dict(cls, d: Dict[str, Any]) -> "Incident": @@ -461,6 +466,10 @@ def from_dict(cls, d: Dict[str, Any]) -> "Incident": subscribers=d.get("subscribers"), comments=d.get("comments"), activity=d.get("activity"), + agent_ids=list(d.get("agent_ids") or []), + environments=list(d.get("environments") or []), + machine_ids=list(d.get("machine_ids") or []), + scope_computed_at=d.get("scope_computed_at"), ) diff --git a/fp-cloud-cli/fp_cli/output.py b/fp-cloud-cli/fp_cli/output.py index 623ad12b2..edeac7b94 100644 --- a/fp-cloud-cli/fp_cli/output.py +++ b/fp-cloud-cli/fp_cli/output.py @@ -603,7 +603,7 @@ def version_banner(version: str) -> None: # is one level down, in `fp audits --help`, which walks the real # Click tree. ("audits", "Schedule audits and triage their findings.", "list show create edit delete run runs findings finding ack mute dismiss resolve reopen assign context-* contracts"), - ("issues", "Triage and resolve issues.", "list count show ack assign resolve close archive unarchive clear comment-* subscribe subscribers unsubscribe open"), + ("issues", "Triage and resolve issues.", "list count facets show ack assign resolve close archive unarchive clear comment-* subscribe subscribers unsubscribe open"), ("settings", "View and change org settings.", "list schema set context-windows"), ("roles", "Manage permission sets.", "list show create update delete"), ("plan", "Inspect offers and team trial.", "claim"), @@ -4381,9 +4381,14 @@ def render_incidents(incidents: Sequence[Any], *, show_id: bool = False) -> None iid = (getattr(i, "id", "") or "-") if show_id else _short_id(getattr(i, "id", "") or "-") opened = _age_compact(getattr(i, "opened_at", "")) or "-" label = getattr(i, "title", None) or getattr(i, "alert_name", None) or "—" + identity = Text(label, style=theme.TEXT) + # Keep tags with the title so narrow terminals never lose provenance + # by dropping a rightmost column. + for value in getattr(i, "agent_ids", None) or []: + identity.append(f" [agent:{value}]", style=theme.TEXT_DIM) rows.append([ Text(iid, style=theme.TEXT_DIM), - Text(label, style=theme.TEXT), + identity, _incident_source_cell(getattr(i, "source", None), getattr(i, "alert_name", None)), _severity_cell(getattr(i, "alert_severity", "") or "-"), _incident_status_cell(getattr(i, "state", "")), @@ -4397,6 +4402,33 @@ def render_incidents(incidents: Sequence[Any], *, show_id: bool = False) -> None empty_message="no issues", title=title) +def render_issue_facets(data: Dict[str, Any]) -> None: + """Available values with counts, without dumping a raw response in human mode.""" + facets = data.get("facets", data) + rows = [] + for facet, values in facets.items(): + if facet not in ( + "agents", "environments", "machines", "sources", "alerts", "audits", "assignees", + ) or not isinstance(values, list): + continue + for item in values: + if isinstance(item, dict): + label = ( + item.get("name") or item.get("id", "—") + if facet in ("alerts", "audits") + else item.get("value", "—") + ) + rows.append([ + Text(str(facet), style=theme.LABEL), + Text(str(label), style=theme.TEXT), + Text(str(item.get("count", 0)), style=theme.TEXT_DIM), + ]) + render_list_panel( + "issue facets", header=["filter", "value", "count"], rows=rows, + days=set(), order=None, empty_message="no issue filter values", + ) + + def incidents_footer(incidents: Sequence[Any]) -> None: """Distribution summary under the incidents box (stderr): ``{total} incidents · firing {f} · acknowledged {a} · resolved {r}`` — each state count in its state colour, present only when diff --git a/fp-cloud-cli/skill/SKILL.md b/fp-cloud-cli/skill/SKILL.md index 146f14964..26d29bf0f 100644 --- a/fp-cloud-cli/skill/SKILL.md +++ b/fp-cloud-cli/skill/SKILL.md @@ -187,6 +187,12 @@ you need a flag you don't already know. - `errors` — errored events; `--aggregate` for count / sessions / agents / last-seen. - `usage` — current org usage for its fixed 30-day metering window; needs `usage:read`. - `list ` — **discover valid filter values first**: `envs agents event_types score_filters models hooks tools error_types`. +- `issues list|count|facets` — scope the failures inbox by agent/environment/machine, + source or a specific alert/audit, assignee, opened dates and title/summary text. Use + `fp --json issues facets --env prod` to discover values/counts, then + `fp --json issues list --env prod --agent claude-repo --assignee me --since 7d`. + Full flags are in `references/commands.md`. If stderr warns that Cloud may have + returned unfiltered results, do not treat the response as proof of the requested scope. **Manage (permission-gated, mutations):** - `keys list|show|create|update|disable|regenerate` — API keys; secret shown once. diff --git a/fp-cloud-cli/skill/references/commands.md b/fp-cloud-cli/skill/references/commands.md index 6d4ff47cb..b25fdda23 100644 --- a/fp-cloud-cli/skill/references/commands.md +++ b/fp-cloud-cli/skill/references/commands.md @@ -185,6 +185,26 @@ The title column truncates to whatever width is left so the fixed columns always The single board for everything needing human attention — alert breaches (`source: alert`), hand-raised issues (`manual`), and audit findings (`audit`). Referenced by id (short ids accepted, `--show-id` shows them). This group was **renamed from `incidents`**; the old name no longer exists. Reads and ack/comment need `issues:read`; opening, assigning, and subscribing others need `issues:create`; resolving, closing, archiving and clearing need `issues:close` (and `clear` also needs `audits:write`, because it resolves the findings behind the issues). - `issues list [--state firing|acknowledged|resolved|closed] [--alert-id ] [--limit N] [--show-id]` — there is **no** `--severity` filter on this command. Archived issues are hidden. - `issues count` +- `issues facets` — available filter values with counts; read-only, needs `issues:read`. + +`list`, `count` and `facets` share filters (session and API-key mode): +`--agent`, `--env`, `--machine`, `--source alert|audit|manual`, +`--alert `, `--audit `, `--assignee EMAIL|me|unassigned`. +Each accepts repeated flags or comma-separated values. Different dimensions combine +with AND; values within one dimension combine with OR. `--since` accepts the existing +`all|15m|1h|6h|24h|7d` presets or an RFC3339 timestamp; `--until` accepts RFC3339. +Dates filter when the issue **opened**, not when it last fired. `--search TEXT` +searches title + summary. Human issue rows read agent tags from top-level +`agent_ids`; `--json` also keeps `environments`, `machine_ids` and +`scope_computed_at`. Facets render alert/audit names; JSON retains their ids. +Global `--json` goes before `issues`, and facets JSON preserves the server response. +On an older Cloud, missing facets exits 6 with “not available on this FailproofAI Cloud +yet”. A returned row's `agent_ids` key confirms list support without another request. +Non-empty legacy rows warn once. Empty lists/zero counts probe facets at most once +per process and warn once if missing (also under `--json`/`--quiet`); positive +counts make no extra request. +The legacy `list --alert-id` stays supported; use `--alert` for multi-alert filtering. + - `issues show ` — identity + comments + subscribers + **activity log** (read this before acting). An audit-born issue (`source: audit`) carries the full finding it graduated from and back-links to the audit/run. - `issues ack ` · `issues assign --assignee ` (repeatable; omit to clear all assignees; each must be an operator) · `issues resolve ` (calm confirm). **On an audit issue these stay in sync with the finding** — resolving the issue resolves the underlying audit finding, so it can't reappear on the next run (equivalently, triage it with `audits resolve `; both surfaces agree). - `issues close ` (calm confirm) — the second terminal state: **"we're done with it", not "we fixed it"** (won't fix / not a problem / stale). The difference from `resolve` is what happens next: a **resolved** issue REOPENS when its audit finding recurs, a **closed** one does not. On an audit issue this marks the finding `dismissed`; it does **not** write the org-wide fingerprint suppression that `audits dismiss` writes, so closing one issue never hides that pattern in your other audits. Exit 9 if the issue already ended — an issue ends once, and neither terminal state may overwrite the other. diff --git a/fp-cloud-cli/tests/issue_filters_smoke.py b/fp-cloud-cli/tests/issue_filters_smoke.py new file mode 100644 index 000000000..484766c06 --- /dev/null +++ b/fp-cloud-cli/tests/issue_filters_smoke.py @@ -0,0 +1,143 @@ +"""Exercise an installed fp against a local Cloud, never a production deployment.""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlsplit + + +class Handler(BaseHTTPRequestHandler): + legacy = False + calls = [] + + def log_message(self, *args): + pass + + def do_GET(self): + parsed = urlsplit(self.path) + key_mode = parsed.path.startswith("/v1/") + if key_mode: + assert self.headers.get("Authorization") == "Bearer fake-key" + assert not self.headers.get("Cookie") + else: + assert "ae_session=fake-session" in self.headers.get("Cookie", "") + assert not self.headers.get("Authorization") + filters = {key: values[0] for key, values in parse_qs(parsed.query).items() + if key not in ("limit", "state")} + self.calls.append({"path": parsed.path, "filters": filters}) + route = parsed.path.removeprefix("/v1").removeprefix("/api") + issue = {"id": "i1", "title": "Tool timeouts", "state": "firing", + "agent_ids": ["claude-repo"], + "environments": ["prod"], "machine_ids": ["m1"], + "scope_computed_at": "2026-10-14T00:00:00Z"} + status = 200 + if route == "/issues/facets": + status = 404 if self.legacy else 200 + data = {"error": "not found"} if self.legacy else { + "agents": [{"value": "claude-repo", "count": 2}], + "alerts": [{"id": "a1", "name": "Tool failure alert", "count": 2}], + "audits": [{"id": "au1", "name": "Weekly quality audit", "count": 1}], + } + elif route == "/issues/count": + data = {"count": 0 if self.legacy else 2} + elif route == "/issues": + data = [{"id": "old", "title": "Old issue"}] if self.legacy else [issue] + else: + status, data = 404, {"error": "unknown route"} + raw = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--fp", required=True) + parser.add_argument("--artifacts", required=True) + args = parser.parse_args() + artifacts = Path(args.artifacts) + artifacts.mkdir(parents=True, exist_ok=True) + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with tempfile.TemporaryDirectory(prefix="fp-issue-smoke-") as cli_home: + env = {key: value for key, value in os.environ.items() + if not key.startswith("FP_") and key != "FAILPROOFAI_HOME"} + env.update(FP_HOME=cli_home, FP_NO_ANALYTICS="1", TERM="dumb", COLUMNS="140") + for key_mode in (False, True): + auth = ["--api-key", "fake-key"] if key_mode else ["--token", "fake-session"] + mode = "key" if key_mode else "session" + for command in ("list", "count", "facets"): + for as_json in (False, True): + before = len(Handler.calls) + result = subprocess.run([ + args.fp, "--base-url", f"http://127.0.0.1:{server.server_port}", + *auth, *(["--json"] if as_json else []), "issues", command, + "--agent", "claude-repo", "--agent", "codex-repo", "--env", "prod", + "--assignee", "unassigned", "--since", "2026-10-01T00:00:00Z", + "--until", "2026-10-14T00:00:00Z", "--search", "tool timeout", + ], env=env, text=True, capture_output=True, timeout=20) + assert result.returncode == 0, result.stderr + assert len(Handler.calls) == before + 1, "normal calls must not probe facets" + if as_json: + data = json.loads(result.stdout) + assert data["count"] == 2 if command == "count" else bool(data) + if command == "list": + row = data["issues"][0] + assert row["agent_ids"] == ["claude-repo"] + assert row["environments"] == ["prod"] + assert row["machine_ids"] == ["m1"] + assert row["scope_computed_at"] == "2026-10-14T00:00:00Z" + assert "scope" not in row + elif command == "facets": + assert data["alerts"][0]["id"] == "a1" + assert data["audits"][0]["id"] == "au1" + elif command == "list": + assert "[agent:claude-repo]" in result.stdout + elif command == "facets": + assert "claude" in result.stdout and "2" in result.stdout + assert "Tool failure alert" in result.stdout + assert "Weekly quality audit" in result.stdout + assert "a1" not in result.stdout and "au1" not in result.stdout + assert "unfiltered" not in result.stderr + stem = f"issues-{mode}-{command}-{'json' if as_json else 'human'}" + (artifacts / f"{stem}.txt").write_text(result.stdout + result.stderr) + assert Handler.calls[-1]["filters"]["agent"] == "claude-repo,codex-repo" + Handler.legacy = True + for command in ("list", "count", "facets"): + before = len(Handler.calls) + result = subprocess.run([ + args.fp, "--base-url", f"http://127.0.0.1:{server.server_port}", + *auth, "--json", "issues", command, "--agent", "claude-repo", + ], env=env, text=True, capture_output=True, timeout=20) + json.loads(result.stdout) + if command == "facets": + assert result.returncode == 6 + assert "not available on this FailproofAI Cloud yet" in result.stdout + else: + assert result.returncode == 0 + assert result.stderr.count("unfiltered") == 1 + assert len(Handler.calls) == before + (2 if command == "count" else 1) + (artifacts / f"issues-{mode}-{command}-legacy.txt").write_text( + result.stdout + result.stderr) + Handler.legacy = False + (artifacts / "issue-filter-requests.json").write_text(json.dumps(Handler.calls, indent=2)) + finally: + server.shutdown() + server.server_close() + thread.join() + print("Installed fp: session/key filters, facets, human tags, JSON and older-Cloud checks passed.") + + +if __name__ == "__main__": + main() diff --git a/fp-cloud-cli/tests/test_issue_filters.py b/fp-cloud-cli/tests/test_issue_filters.py new file mode 100644 index 000000000..979c1112a --- /dev/null +++ b/fp-cloud-cli/tests/test_issue_filters.py @@ -0,0 +1,303 @@ +"""The issue-filter wire contract in both auth modes, including older Clouds.""" + +import json +from datetime import datetime, timezone + +import httpx +import pytest +import respx +from typer.main import get_command + +from fp_cli import client, dates, output +from fp_cli.app import app +from fp_cli.models import Incident + +BASE = "http://dash.test" +FILTER_FLAGS = [ + "--agent", "claude-repo", "--agent", "codex-repo", + "--env", "prod", "--env", "staging", + "--machine", "m1", "--machine", "m2", + "--source", "alert", "--source", "audit", + "--alert", "a1", "--alert", "a2", + "--audit", "au1", "--audit", "au2", + "--assignee", "me", "--assignee", "unassigned", "--assignee", "a@example.test", + "--since", "2026-10-01T00:00:00Z", "--until", "2026-10-14T00:00:00Z", + "--search", "tool failures & latency", +] +FILTERS = { + "agent": "claude-repo,codex-repo", + "environment": "prod,staging", + "machine": "m1,m2", + "source": "alert,audit", + "alert_id": "a1,a2", + "audit_id": "au1,au2", + "assignee": "me,unassigned,a@example.test", + "opened_from": "2026-10-01T00:00:00Z", + "opened_to": "2026-10-14T00:00:00Z", + "q": "tool failures & latency", +} +ISSUE = { + "id": "i1", "title": "Timeouts", + "agent_ids": ["claude-repo"], + "environments": ["prod"], "machine_ids": ["m1"], + "scope_computed_at": "2026-10-14T00:00:00Z", +} +FACETS = { + "agents": [{"value": "claude-repo", "count": 2}], + "alerts": [{"id": "a1", "name": "Tool failure alert", "count": 4}], + "audits": [{"id": "au1", "name": "Weekly quality audit", "count": 5}], +} + + +@pytest.fixture(autouse=True) +def fresh_filter_detection(monkeypatch): + """Each test is a new process; individual tests can exercise repeated calls.""" + monkeypatch.setattr(client, "_issue_facets_probed", False) + monkeypatch.setattr(client, "_issue_facets_available", None) + monkeypatch.setattr(client, "_issue_filters_warned", False) + + +@pytest.mark.parametrize("key_mode", [False, True]) +@pytest.mark.parametrize("command", ["list", "count", "facets"]) +@respx.mock +def test_all_filters_in_session_and_key_mode(logged_in, runner, key_mode, command): + prefix = "/v1" if key_mode else "/api" + path = f"{BASE}{prefix}/issues" + (f"/{command}" if command != "list" else "") + response = [ISSUE] if command == "list" else ( + {"count": 3} if command == "count" else FACETS) + route = respx.get(path).respond(200, json=response) + auth = ["--api-key", "key"] if key_mode else [] + result = runner.invoke(app, ["--json", *auth, "issues", command, + "--state", "firing,acknowledged", *FILTER_FLAGS]) + assert result.exit_code == 0, result.output + expected = {**FILTERS, "state": "firing,acknowledged"} + if command == "list": + expected["limit"] = "50" + request = route.calls[0].request + assert dict(request.url.params) == expected + if key_mode: + assert request.headers["authorization"] == "Bearer key" + assert "cookie" not in request.headers + else: + assert "ae_session=tok" in request.headers["cookie"] + assert "authorization" not in request.headers + payload = json.loads(result.stdout) + if command == "list": + row = payload["issues"][0] + for key in ("agent_ids", "environments", "machine_ids", "scope_computed_at"): + assert row[key] == ISSUE[key] + assert "scope" not in row + elif command == "count": + assert payload == {"count": 3} + else: + assert payload == FACETS + assert len(respx.calls) == 1, "normal filtered calls need no capability probe" + + +@pytest.mark.parametrize("key_mode", [False, True]) +@respx.mock +def test_old_nonempty_list_warns_without_extra_request(logged_in, runner, key_mode): + prefix = "/v1" if key_mode else "/api" + respx.get(f"{BASE}{prefix}/issues").respond(200, json=[{"id": "old"}]) + auth = ["--api-key", "key"] if key_mode else [] + result = runner.invoke(app, ["--json", "--quiet", *auth, "issues", "list", + "--agent", "claude-repo", "--env", "prod"]) + assert result.exit_code == 0, result.output + assert result.stderr.count("not available on this FailproofAI Cloud yet") == 1 + assert "unfiltered" in result.stderr + assert len(respx.calls) == 1 + assert json.loads(result.stdout)["issues"][0]["id"] == "old" + + +@pytest.mark.parametrize("key_mode", [False, True]) +@pytest.mark.parametrize("command", ["list", "count"]) +@respx.mock +def test_old_empty_result_probes_once_and_preserves_json(logged_in, runner, key_mode, command): + prefix = "/v1" if key_mode else "/api" + path = f"{BASE}{prefix}/issues" + ("/count" if command == "count" else "") + respx.get(path).respond(200, json=[] if command == "list" else {"count": 0}) + facets = respx.get(f"{BASE}{prefix}/issues/facets").respond(404, json={"error": "not found"}) + auth = ["--api-key", "key"] if key_mode else [] + result = runner.invoke(app, ["--json", "--quiet", *auth, "issues", command, "--agent", "claude-repo"]) + assert result.exit_code == 0, result.output + assert result.stderr.count("not available on this FailproofAI Cloud yet") == 1 + assert facets.call_count == 1 + assert json.loads(result.stdout) == ({"issues": []} if command == "list" else {"count": 0}) + + +@pytest.mark.parametrize("key_mode", [False, True]) +@respx.mock +def test_missing_facets_has_existing_availability_error(logged_in, runner, key_mode): + prefix = "/v1" if key_mode else "/api" + respx.get(f"{BASE}{prefix}/issues/facets").respond(404, json={"error": "not found"}) + auth = ["--api-key", "key"] if key_mode else [] + result = runner.invoke(app, ["--json", *auth, "issues", "facets", "--env", "prod"]) + assert result.exit_code == 6, result.output + assert "not available on this FailproofAI Cloud yet" in result.stdout + json.loads(result.stdout) + + +@respx.mock +def test_empty_list_on_new_cloud_probes_facets_without_warning(logged_in, runner): + respx.get(f"{BASE}/api/issues").respond(200, json=[]) + route = respx.get(f"{BASE}/api/issues/facets").respond(200, json=FACETS) + result = runner.invoke(app, ["--json", "issues", "list", "--source", "manual"]) + assert result.exit_code == 0, result.output + assert dict(route.calls[0].request.url.params) == {"source": "manual"} + assert "unfiltered" not in result.stderr + + +@respx.mock +@pytest.mark.parametrize("rows", [ + [{"id": "manual", "agent_ids": []}], + [{"id": "legacy"}, ISSUE], +]) +def test_any_raw_agent_ids_key_confirms_support_without_probe(logged_in, runner, rows): + respx.get(f"{BASE}/api/issues").respond(200, json=rows) + result = runner.invoke(app, ["--json", "issues", "list", "--source", "manual"]) + assert result.exit_code == 0, result.output + assert len(respx.calls) == 1 + assert "unfiltered" not in result.stderr + + +@pytest.mark.parametrize("available", [True, False]) +@respx.mock +def test_empty_result_probe_and_warning_are_once_per_process(logged_in, runner, available): + respx.get(f"{BASE}/api/issues").respond(200, json=[]) + respx.get(f"{BASE}/api/issues/count").respond(200, json={"count": 0}) + route = respx.get(f"{BASE}/api/issues/facets").respond( + 200 if available else 404, json=FACETS if available else {}) + results = [ + runner.invoke(app, ["--json", "issues", command, "--agent", "claude-repo"]) + for command in ("list", "count", "list") + ] + assert all(result.exit_code == 0 for result in results) + assert route.call_count == 1 + warnings = "".join(result.stderr for result in results) + assert warnings.count("unfiltered") == (0 if available else 1) + + +@respx.mock +def test_legacy_row_warning_is_once_per_process(logged_in, runner): + respx.get(f"{BASE}/api/issues").respond(200, json=[{"id": "old"}]) + results = [runner.invoke(app, ["--json", "issues", "list", "--agent", "claude-repo"]) + for _ in range(2)] + assert all(result.exit_code == 0 for result in results) + assert "".join(result.stderr for result in results).count("unfiltered") == 1 + assert len(respx.calls) == 2 + + +@pytest.mark.parametrize("status", [401, 403, 500]) +@respx.mock +def test_probe_errors_are_not_disguised_as_older_cloud(logged_in, runner, status): + respx.get(f"{BASE}/api/issues/count").respond(200, json={"count": 0}) + respx.get(f"{BASE}/api/issues/facets").respond(status, json={"error": "probe failed"}) + result = runner.invoke(app, ["--json", "issues", "count", "--agent", "claude-repo"]) + assert result.exit_code != 0 + assert "unfiltered" not in result.stderr + + +@pytest.mark.parametrize("command", ["list", "count", "facets"]) +@pytest.mark.parametrize("flags", [ + ["--source", "invalid"], ["--agent", ""], ["--agent", "a,,b"], + ["--since", "yesterday"], ["--until", "2026-10-14"], +]) +@respx.mock +def test_invalid_filters_fail_before_http(logged_in, runner, command, flags): + result = runner.invoke(app, ["issues", command, *flags]) + assert result.exit_code == 2, result.output + assert not respx.calls + + +@respx.mock +def test_relative_since_keeps_until_and_csv_is_normalized(logged_in, runner, monkeypatch): + real = dates.resolve_range + now = datetime(2026, 10, 14, tzinfo=timezone.utc) + monkeypatch.setattr(dates, "resolve_range", lambda *args: real(*args, now=now)) + expected = {"agent": "claude-repo,codex-repo", "opened_from": "2026-10-07T00:00:00Z", + "opened_to": "2026-10-14T00:00:00Z"} + route = respx.get(f"{BASE}/api/issues/count").respond(200, json={"count": 3}) + result = runner.invoke(app, ["--json", "issues", "count", + "--agent", "claude-repo, codex-repo", "--agent", "claude-repo", + "--since", "7d", "--until", "2026-10-14T00:00:00Z"]) + assert result.exit_code == 0, result.output + assert dict(route.calls[0].request.url.params) == expected + assert len(respx.calls) == 1 + + +@respx.mock +def test_existing_alert_id_path_is_kept_and_combined_filters_use_issues(logged_in, runner): + legacy = respx.get(f"{BASE}/api/alerts/a1/issues").respond(200, json=[]) + result = runner.invoke(app, ["--json", "issues", "list", "--alert-id", "a1"]) + assert result.exit_code == 0, result.output + assert legacy.called + route = respx.get(f"{BASE}/api/issues").respond(200, json=[{**ISSUE, "agent_ids": ["codex-repo"]}]) + result = runner.invoke(app, ["--json", "issues", "list", "--alert-id", "a1", "--agent", "codex-repo"]) + assert result.exit_code == 0, result.output + assert dict(route.calls[0].request.url.params) == {"limit": "50", "agent": "codex-repo", "alert_id": "a1"} + + +def test_human_rows_include_agent_tags(capsys): + output.render_incidents([Incident.from_dict(ISSUE)]) + out = capsys.readouterr().out + assert "[agent:claude-repo]" in out + + +@pytest.mark.parametrize("command", ["list", "count", "facets"]) +def test_issue_commands_offer_only_the_supported_filter_flags(command): + cmd = get_command(app).commands["issues"].commands[command] + flags = {flag for param in cmd.params for flag in getattr(param, "opts", [])} + expected = { + "--state", "--agent", "--env", "--machine", "--source", "--alert", "--audit", + "--assignee", "--since", "--until", "--search", + } + if command == "list": + expected.update(("--alert-id", "--limit", "-n", "--show-id")) + assert flags == expected + + +def test_older_issue_defaults_scope_fields_without_inventing_tags(capsys): + issue = Incident.from_dict({"id": "old", "title": "Old issue"}) + assert issue.agent_ids == issue.environments == issue.machine_ids == [] + assert issue.scope_computed_at is None + output.render_incidents([issue]) + out = capsys.readouterr().out + assert "[agent:" not in out + + +def test_human_facets_show_values_counts_and_empty_state(capsys): + output.render_issue_facets(FACETS) + out = capsys.readouterr().out + assert "2" in out and "claude-repo" in out + assert "Tool failure alert" in out and "4" in out + assert "Weekly quality audit" in out and "5" in out + assert "a1" not in out and "au1" not in out + assert "{'value'" not in out + output.render_issue_facets({}) + assert "no issue filter values" in capsys.readouterr().out + + +def test_unnamed_alert_and_audit_facets_fall_back_to_id(capsys): + output.render_issue_facets({"alerts": [{"id": "a1", "name": None, "count": 2}], + "audits": [{"id": "au1", "name": "", "count": 1}]}) + out = capsys.readouterr().out + assert "a1" in out and "au1" in out + + +def test_human_facets_ignore_dimensions_outside_the_issue_filters(capsys): + output.render_issue_facets({ + **FACETS, "unrelated": [{"value": "unsupported-value", "count": 1}], + }) + out = capsys.readouterr().out + assert "claude-repo" in out + assert "unrelated" not in out and "unsupported-value" not in out + + +def test_programmatic_client_reports_missing_filters_without_printing(): + def handle(request): + return httpx.Response(404, json={}) if request.url.path.endswith("/facets") else httpx.Response(200, json=[]) + ctx = client.ClientContext(BASE, token="tok", transport=httpx.MockTransport(handle)) + warnings = [] + assert client.list_incidents(ctx, filters={"agent": "codex-repo"}, + on_filters_unavailable=lambda: warnings.append(True)) == [] + assert warnings == [True]