diff --git a/CHANGELOG.md b/CHANGELOG.md index 023bb293e..06f730a6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 1.0.7-beta.0 — 2026-09-22 + +### Added + +- `fp issues` gains `close`, `archive`, `unarchive` and `clear`. `close` is the second terminal state — "we're done with it", not "we fixed it" — and the difference is what happens next: a **resolved** issue reopens when its audit finding recurs, a **closed** one does not. `archive`/`unarchive` toggle a flag that is orthogonal to state, so taking an issue off the board never overwrites how it ended. `clear` is the bulk operation behind "we changed our agents, give us a fresh board": it resolves every open issue in a scope (`--audit `, `--all-audits`, or `--everything` — exactly one required, no default) plus the audit findings behind them, needs `issues:close` **and** `audits:write`, and previews with `--dry-run` from the same server-side scope predicate the write uses. It writes **no** suppression, so a pattern that survived the agent changes reopens its issue on the next run rather than staying hidden. `--state closed` is accepted by `issues list`, and `Incident` carries `closed_at` / `archived_at`. (#815) +- **`fp-cloud-cli` cuts its first stable release, `0.0.1`.** The commands above drive server endpoints that are being deployed to FailproofAI Cloud, so the CLI half stops being a pre-release: the `0.0.1b3` line the `bump` job opened is cut stable rather than published, `Development Status` moves to `5 - Production/Stable` so the classifier agrees with the one string pip reads, and `0.0.2b0` opens the next beta line. Nothing about the command surface changes at the cut — `pipx install fp-cloud-cli` already resolved the betas, because they were the only releases; now it resolves a version that says so. This is the PyPI package's own line and does not move the npm version, which stays at `1.0.7-beta.0`. (#815) + +### Fixes + +- **One event, two answers: "a recurring finding reopens the resolved issue" and "it opens a new one" were both written down, four lines apart.** `fp audits resolve --help`'s confirm line said `a genuine recurrence re-opens as new`, the skill and its command reference said the same, and the new `clear` text inherited it — while `issues close --help`, the audits guide's own table and the Cloud CLI reference all said the issue **reopens**. Only the second reading makes the rest coherent: a recurrence that opened a different issue would leave `close` with nothing to stay closed *through*, and could not un-archive "a live issue" the way archiving documents. The recurrence sentence now says `reopens` everywhere it appears, in the confirm text a user reads before resolving and in the skill a model reads before acting. (#815) +- **`clear`'s help promised the preview count could not disagree with the write, which nothing enforces.** The dry run and the write share a scope predicate, not a row set: the write re-runs it, so an issue that enters the scope in between is cleared without being in the number the user confirmed. That is the right behaviour for a command whose argument is a *scope* — but the guarantee as written was a stronger one than the two requests can make. The help now says what holds (the count is the real size of the scope, taken server-side) and what does not (it is a count, not a lease), and points at the closing line, which reports what actually changed. (#815) +- **The skill mapped "clear all our issues" to `--all-audits`, which is not all issues.** `--all-audits` leaves alert-born and hand-opened issues on the board, so a model following that row would clear part of it and report a fresh start. Both the intent table and the workflow note now make settling the scope the first step, with what each flag does and does not cover. (#815) + +### Docs + +- Document ending an issue three ways (resolve / close / archive) and clearing a board after an agent change, in the audits guide and the Cloud CLI reference. (#815) + ## 1.0.6 — 2026-09-16 Stable native Hermes policy-enforcement release, validated across CLI and diff --git a/docs/audits/findings-and-issues.mdx b/docs/audits/findings-and-issues.mdx index 728815f80..4c98d0b59 100644 --- a/docs/audits/findings-and-issues.mdx +++ b/docs/audits/findings-and-issues.mdx @@ -43,6 +43,8 @@ A finding is the audit's evidence-backed statement about a failure. An issue is fp issues assign --assignee engineer@example.com fp issues comment-add --body "policy is in observe mode" fp issues resolve --yes + fp issues close --yes + fp issues archive ``` Use `fp issues subscribe `, `fp issues unsubscribe `, and `fp issues subscribers ` to manage watchers. @@ -67,6 +69,62 @@ Create or link an issue when the finding needs assignment, discussion, status ch Resolve the issue when remediation is deployed and verified. Resolve the finding when the failure mode has been addressed for the audit population. Those moments may differ. +## End an issue: resolve, close, or archive + +An issue ends once, and how you end it decides what happens the next time the audit +sees the same pattern. + +| Action | Means | If the pattern comes back | +| --- | --- | --- | +| **Resolve** | You fixed it. | The issue **reopens**, so you find out the fix did not hold. | +| **Close** | You are done with it: won't fix, not a problem, or no longer relevant. | It **stays closed**. | +| **Archive** | Take it off the board. Says nothing about how it ended. | A live issue returns to the board automatically. | + +Resolve and close are both final and neither can overwrite the other, so an issue +someone resolved keeps that record. Archiving is separate from both: you can archive +an issue in any state, and it keeps whatever state it ended in. If an archived issue +is still live and the problem recurs, it comes back to the board on its own — archive +hides history, it cannot hide an active problem. + +Closing an issue that came from an audit also dismisses the finding behind it. It +does not silence that pattern in your other audits; for that, mute or dismiss the +finding itself. + +## Start fresh after changing your agents + +When you ship a round of changes to your agents, the issues already on the board +describe the behavior you just replaced. Clearing resolves them in one step, along +with the audit findings behind them. + + + + 1. Go to **Analyze → Issues** and select **clear**, or open a single audit and select **clear issues** to limit it to that audit's work. + 2. Choose the scope. Each one shows how many issues it covers before you commit to it. + 3. Confirm. The issues are resolved, and so are the audit findings behind them. + + + ```bash + fp issues clear --all-audits --dry-run + fp issues clear --all-audits --yes + + fp issues clear --audit --yes + fp issues clear --everything --yes + ``` + + `--dry-run` reports what would change without changing it. Exactly one of + `--audit`, `--all-audits`, and `--everything` is required. + + + +**Clearing suppresses nothing.** A pattern your changes genuinely fixed stays gone. A +pattern that survived them **reopens** its issue on the next audit run — the same thing +resolving one by hand does — so a fresh start cannot quietly hide a problem you still +have. When you do want a pattern +silenced for good, mute or dismiss the finding instead. + +Clearing needs permission to both close issues and write audits, because it resolves +the findings as well as the issues. + ## Turn an issue into a policy draft diff --git a/docs/reference/cloud-cli.mdx b/docs/reference/cloud-cli.mdx index 404acc524..9eca539b5 100644 --- a/docs/reference/cloud-cli.mdx +++ b/docs/reference/cloud-cli.mdx @@ -291,13 +291,17 @@ Include context during creation when the first run needs it. Creation commits th | Command | Purpose | Options | | --- | --- | --- | -| `fp issues list` | List issues. | `--state`; `--alert-id`; `--limit`, `-n`; `--show-id` | +| `fp issues list` | List issues. Archived issues are hidden. | `--state`; `--alert-id`; `--limit`, `-n`; `--show-id` | | `fp issues count` | Count open or selected issue states. | `--state` | | `fp issues show INCIDENT_ID` | Show issue details, comments, subscribers, and activity. | — | | `fp issues open` | Open a manual or alert-linked issue. | required `--summary`; optional `--title`, `--alert-id`, `--severity` | | `fp issues ack INCIDENT_ID` | Acknowledge an issue. | — | | `fp issues assign INCIDENT_ID` | Replace assignees; omit the option to clear them. | repeatable `--assignee` | -| `fp issues resolve INCIDENT_ID` | Resolve an issue. | `--yes`, `-y` | +| `fp issues resolve INCIDENT_ID` | Resolve an issue: the problem is fixed. A recurring audit finding reopens it. | `--yes`, `-y` | +| `fp issues close INCIDENT_ID` | Close an issue: you are done with it, fixed or not. A recurrence does not reopen it. | `--yes`, `-y` | +| `fp issues archive INCIDENT_ID` | Take an issue off the board without changing how it ended. | — | +| `fp issues unarchive INCIDENT_ID` | Put an archived issue back on the board. | — | +| `fp issues clear` | Resolve every open issue in a scope, plus the audit findings behind them. Requires exactly one scope flag. | one of `--audit`, `--all-audits`, `--everything`; `--dry-run`; `--yes`, `-y` | | `fp issues comment-list INCIDENT_ID` | List comments. | — | | `fp issues comment-add INCIDENT_ID` | Add a comment. | exactly one of `--body`, `--file` | | `fp issues comment-delete INCIDENT_ID COMMENT_ID` | Delete a comment. | `--yes`, `-y` | diff --git a/fp-cloud-cli/CHANGELOG.md b/fp-cloud-cli/CHANGELOG.md index f22fdc71f..86fcbaca9 100644 --- a/fp-cloud-cli/CHANGELOG.md +++ b/fp-cloud-cli/CHANGELOG.md @@ -1,11 +1,52 @@ # Changelog — `fp` CLI -## 0.0.1b3 — 2026-09-12 +## 0.0.1 — 2026-09-22 + +**First stable release.** `0.0.1b1` and `0.0.1b2` are the two betas behind it; the +`0.0.1b3` line the `bump` job opened is cut stable here rather than published, because +the server endpoints these commands drive are deployed to FailproofAI Cloud and the +CLI half should not stay a pre-release behind them. Until now `pipx install +fp-cloud-cli` resolved a beta only because no stable existed — a default that would +have silently changed the day one did. `Development Status` moves to +`5 - Production/Stable` with the version, so the classifier and the one string pip +reads say the same thing. + +Nothing about the command surface changes at the cut: the entries below are the +entire diff from `0.0.1b2`, and `0.0.2b0` opens the next beta line. + +### Added + +- `fp issues close ` — end an issue as won't-fix. Distinct from `resolve`: a + recurring audit finding reopens a **resolved** issue and leaves a **closed** one + alone. On an audit issue it marks the finding `dismissed`, without writing the + org-wide fingerprint suppression that `fp audits dismiss` writes. Exit 9 if the + issue already ended. (#815) +- `fp issues archive ` / `unarchive ` — take an issue off the board and put + it back. Orthogonal to state, allowed in any state, no confirmation prompt. (#815) +- `fp issues clear (--audit | --all-audits | --everything) [--dry-run]` — resolve + every open issue in a scope plus the audit findings behind them, in one server-side + transaction. Exactly one scope flag is required. Needs `issues:close` **and** + `audits:write`. Writes no suppression, so anything still broken reopens its issue + on the next run. The confirm names the count from a dry run that shares its + scope predicate with the write. (#815) +- `issues list --state closed` is accepted, and `Incident` carries `closed_at` and + `archived_at`. (#815) -Open for the next release. `0.0.1b2` published on 2026-09-12 and the `bump` job -moved the version here automatically; nothing has landed against `0.0.1b3` yet. -Add entries as changes merge — this section becomes the GitHub Release body when -it ships. +### Fixes + +- The recurrence sentence said two different things in one release. `audits resolve`'s + confirm line (`a genuine recurrence re-opens as new`), the skill and the command + reference implied a recurrence opens a **different** issue; `issues close --help`, the + audits guide and the CLI reference said the resolved issue **reopens**. The second is + the one the rest of the model needs — a `closed` issue has to have something to stay + closed through — so `reopens` is now what every surface says. (#815) +- `issues clear --help` claimed the `--dry-run` count "cannot disagree" with the write. + The two requests share a scope predicate, not a row set, so an issue entering the scope + between them is cleared without being in the confirmed number — correct for a scoped + clear, but not the guarantee that was written. The help now states the real one, and + names the closing line as the count of what changed. (#815) +- The skill answered "clear all our issues" with `--all-audits`, which leaves alert-born + and hand-opened issues untouched. Settling the scope is now the first step. (#815) ## 0.0.1b2 — 2026-08-25 diff --git a/fp-cloud-cli/README.md b/fp-cloud-cli/README.md index a66b83c77..f07ec157c 100644 --- a/fp-cloud-cli/README.md +++ b/fp-cloud-cli/README.md @@ -90,7 +90,7 @@ fp keys list|show|create|update|disable|regenerate # API keys (secret shown fp users list|show|create|update|disable|enable fp settings list|schema|set fp alerts list|show|create|update|delete|test -fp issues list|count|show|ack|assign|resolve|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open +fp issues list|count|show|ack|assign|resolve|close|archive|unarchive|clear|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open fp audits list|show|create|edit|delete|run|runs|findings|finding # scheduled audits fp audits ack|assign|resolve|dismiss|mute|reopen # triage a finding fp audits context-show|context-set|context-refresh # reference context diff --git a/fp-cloud-cli/fp_cli/_version.py b/fp-cloud-cli/fp_cli/_version.py index cac3a585a..f102a9cad 100644 --- a/fp-cloud-cli/fp_cli/_version.py +++ b/fp-cloud-cli/fp_cli/_version.py @@ -1 +1 @@ -__version__ = "0.0.1b3" +__version__ = "0.0.1" diff --git a/fp-cloud-cli/fp_cli/client.py b/fp-cloud-cli/fp_cli/client.py index fbaeeddf1..e4c1577bb 100644 --- a/fp-cloud-cli/fp_cli/client.py +++ b/fp-cloud-cli/fp_cli/client.py @@ -1161,6 +1161,50 @@ def resolve_incident(ctx: ClientContext, incident_id: str) -> None: _post_json(ctx, f"/api/issues/{incident_id}/resolve") +def close_incident(ctx: ClientContext, incident_id: str) -> None: + """POST /api/issues/{id}/close — end the issue as won't-fix. + + The sibling of ``resolve_incident``, and the difference is what happens next: + a resolved issue REOPENS if its audit finding recurs, a closed one does not. + 409 if the issue already ended. + """ + _post_json(ctx, f"/api/issues/{incident_id}/close") + + +def set_incident_archived(ctx: ClientContext, incident_id: str, archived: bool) -> None: + """POST /api/issues/{id}/archive or /unarchive — hide from the board, or restore. + + Does not change ``state``: an issue ends once, and archiving says nothing + about how. Idempotent on both sides, so a caller never has to read the + current value first. + """ + verb = "archive" if archived else "unarchive" + _post_json(ctx, f"/api/issues/{incident_id}/{verb}") + + +def clear_issues( + ctx: ClientContext, + *, + scope: str, + audit_id: Optional[str] = None, + dry_run: bool = False, +) -> Dict[str, Any]: + """POST /api/issues/bulk-clear — resolve every live issue in a scope. + + ``scope`` is ``audit`` (with ``audit_id``), ``all_audits`` or ``everything``. + Returns ``{issues, findings, dry_run, scope}``. + + Needs ``issues:close`` AND ``audits:write`` — clearing resolves the audit + findings behind the issues, and the second grant is what stops a key that + cannot touch one finding from resolving all of them at once. + """ + body: Dict[str, Any] = {"scope": scope, "dry_run": dry_run} + if audit_id: + body["audit_id"] = audit_id + data = _post_json(ctx, "/api/issues/bulk-clear", body) + return data if isinstance(data, dict) else {} + + def list_incident_comments(ctx: ClientContext, incident_id: str) -> List[IncidentComment]: data = _get_json(ctx, f"/api/issues/{incident_id}/comments") return [IncidentComment.from_dict(c) for c in (data if isinstance(data, list) else [])] diff --git a/fp-cloud-cli/fp_cli/commands/incidents_cmds.py b/fp-cloud-cli/fp_cli/commands/incidents_cmds.py index 92d18dea6..741c996bd 100644 --- a/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +++ b/fp-cloud-cli/fp_cli/commands/incidents_cmds.py @@ -1,4 +1,4 @@ -"""Incident triage: incidents list/count/show/ack/assign/resolve/comment*/subscribe*/open. +"""Incident triage: incidents list/count/show/ack/assign/resolve/close/archive/clear/comment*/subscribe*/open. Incidents live under /api/issues but the triage workflow is distinct, so it gets its own top-level group. The id IS the handle (incidents have no human name), so the @@ -19,13 +19,16 @@ from . import _write _SEVERITIES = ("info", "warning", "critical") -_STATES = ("firing", "acknowledged", "resolved") +# Mirrors `issue_sync::ISSUE_STATES` on the server. `closed` is the second +# 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") _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}$") def _validate_states(value: Optional[str]) -> None: """Reject an unknown ``--state`` value up front (exit 2) rather than letting the server - silently drop it and return a confusing set. Accepts a CSV of firing/acknowledged/resolved.""" + silently drop it and return a confusing set. Accepts a CSV of firing/acknowledged/resolved/closed.""" if value is None: return for s in value.split(","): @@ -67,7 +70,7 @@ def _fail(state: AppState, exc: Exception, *, incident_id: str = "") -> None: def incidents_list( ctx: typer.Context, - state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s): firing, acknowledged, resolved (CSV)."), + 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)."), @@ -244,6 +247,220 @@ def incidents_resolve( output.incident_resolved(incident_id) +def incidents_close( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Issue id."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt. The prompt only appears on an interactive terminal: under --json, or with stdin redirected, this command proceeds without asking."), +) -> None: + """Close an issue — "we're done with it", not "we fixed it". + + The difference from `resolve` is what happens next. A **resolved** issue REOPENS if its + audit finding recurs: someone claimed a fix and the fix did not hold, which is worth + knowing. A **closed** issue does not — closing records a decision (won't fix, not a + problem, stale), and a recurrence is not news about a decision. + + For an issue that came from an audit, this also marks the finding `dismissed`. It does + NOT write the finding's org-wide suppression: closing one issue never hides that pattern + in your other audits. Use `fp audits finding-status --action dismiss` for that. + + Needs `issues:close`. Exits 9 (conflict) if the issue already ended — an issue ends once, + and closing must not overwrite the record that someone believed they had fixed it. + + With `--json`: `{"closed": true, "id": ""}` (or `{cancelled: true}` on a declined + prompt). + + Example: + + * `fp issues close --yes` + """ + state: AppState = ctx.obj + cctx = require_auth(state) + if _write.should_prompt(state, yes): + alert_name = None + try: + alert_name = api.get_incident(cctx, incident_id).alert_name + except NotFoundError as exc: + _fail(state, exc, incident_id=incident_id) + except (ApiError, ForbiddenError): + alert_name = None + if not output.confirm_incident_close(incident_id, alert_name): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.cancelled_plain("nothing changed") + return + try: + api.close_incident(cctx, incident_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action("incident_closed", resource="incident", success=True) + if state.json: + output.emit_json({"closed": True, "id": incident_id}) + else: + output.incident_closed(incident_id) + + +def incidents_archive( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Issue id."), +) -> None: + """Take an issue off the issues board, keeping its history. + + Archiving is separate from state: it does not resolve or close anything, and an issue + that has already ended keeps the record of HOW it ended. It works on a live issue too — + you should not have to triage something in order to stop looking at it — because the + safety net is on the other side: a fresh alert breach or a recurring audit finding puts + a live issue back on the board automatically. Archive can hide a problem; it cannot keep + hiding one that is still happening. A closed (won't-fix) issue is the exception and stays + hidden, since nobody asked for it back. + + No confirmation: archiving destroys nothing and `fp issues unarchive` undoes it. + + Needs `issues:close`. With `--json`: `{"archived": true, "id": ""}`. + + Example: + + * `fp issues archive ` + """ + _set_archived(ctx, incident_id, True) + + +def incidents_unarchive( + ctx: typer.Context, + incident_id: str = typer.Argument(..., help="Issue id."), +) -> None: + """Put an archived issue back on the issues board. See `fp issues archive`. + + Needs `issues:close`. With `--json`: `{"archived": false, "id": ""}`. + + Example: + + * `fp issues unarchive ` + """ + _set_archived(ctx, incident_id, False) + + +def _set_archived(ctx: typer.Context, incident_id: str, archived: bool) -> None: + """Shared body of archive/unarchive. Two commands rather than one with a flag, so each + reads as the verb it is and neither can be invoked meaning the opposite.""" + state: AppState = ctx.obj + cctx = require_auth(state) + try: + api.set_incident_archived(cctx, incident_id, archived) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc, incident_id=incident_id) + _write.record_action( + "incident_archived" if archived else "incident_unarchived", + resource="incident", success=True, + ) + if state.json: + output.emit_json({"archived": archived, "id": incident_id}) + else: + output.incident_archived(incident_id, archived) + + +def incidents_clear( + ctx: typer.Context, + audit_id: Optional[str] = typer.Option(None, "--audit", help="Clear only the issues this audit raised."), + all_audits: bool = typer.Option(False, "--all-audits", help="Clear every issue any audit raised. Alert and hand-opened issues are left alone."), + everything: bool = typer.Option(False, "--everything", help="Clear every open issue in the workspace, whatever opened it."), + dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be cleared and change nothing."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt. The prompt only appears on an interactive terminal: under --json, or with stdin redirected, this command proceeds without asking."), +) -> None: + """Resolve every open issue in a scope — a fresh start after changing your agents. + + Exactly one of `--audit `, `--all-audits` or `--everything` is required: the three + have very different blast radii, so there is deliberately no default. + + This is the same thing as resolving each issue by hand, including what it does NOT do. + **Nothing is deleted, and nothing is suppressed.** A pattern your agent changes genuinely + fixed stays gone; one they did not comes back on the next audit run and REOPENS the issue + it was raised under — a cleared board is not a quiet one. If you want a pattern silenced + for good, that is `fp audits finding-status --action mute`, which is a different and + much bigger hammer. + + Run it with `--dry-run` first: the count comes from the server, taken with the same scope + predicate the write uses, so it is the real size of the scope rather than a client-side + guess. It is a count, not a lease — the confirmed write re-runs that predicate, so an + issue that entered the scope since the preview is cleared along with the rest, which is + what clearing a SCOPE means. The line printed at the end is what actually changed. + + Needs `issues:close` AND `audits:write` — clearing resolves the audit findings behind the + issues, so a key that cannot touch one finding cannot resolve all of them at once. + + With `--json`: `{"issues": n, "findings": n, "dry_run": bool, "scope": "..."}` (or + `{cancelled: true}` on a declined prompt). + + Examples: + + * `fp issues clear --all-audits --dry-run` + * `fp issues clear --audit --yes` + """ + state: AppState = ctx.obj + picked = [bool(audit_id), all_audits, everything] + if sum(picked) != 1: + raise typer.BadParameter( + "choose exactly one of --audit , --all-audits, or --everything.", + param_hint="--all-audits", + ) + if audit_id: + scope, label = "audit", "this audit" + elif all_audits: + scope, label = "all_audits", "every audit" + else: + scope, label = "everything", "the whole workspace" + + cctx = require_auth(state) + + # The dry run is also the preview behind the prompt, so a `--dry-run` call and a + # confirmed clear count the same rows the same way. + try: + preview = api.clear_issues(cctx, scope=scope, audit_id=audit_id, dry_run=True) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc) + return + issues = int(preview.get("issues") or 0) + findings = int(preview.get("findings") or 0) + + if dry_run: + if state.json: + output.emit_json(preview) + else: + output.issues_cleared(issues, findings, dry_run=True) + return + + if issues == 0: + # Nothing to do is not a failure, and prompting to confirm zero rows is noise. + if state.json: + output.emit_json({"issues": 0, "findings": 0, "dry_run": False, "scope": scope}) + else: + output.issues_cleared(0, 0) + return + + if _write.should_prompt(state, yes) and not output.confirm_issues_clear(label, issues, findings): + if state.json: + output.emit_json({"cancelled": True}) + else: + output.cancelled_plain("nothing changed") + return + + try: + result = api.clear_issues(cctx, scope=scope, audit_id=audit_id) + except (ApiError, ForbiddenError, NotFoundError) as exc: + _fail(state, exc) + return + # `mode` and `count` are both on _SAFE_PROP_KEYS, and both are shape: a closed enum we + # authored and an integer. Never an audit id or an issue title. + _write.record_action( + "issues_cleared", resource="incident", success=True, destructive=True, + mode=scope, count=int(result.get("issues") or 0), + ) + if state.json: + output.emit_json(result) + else: + output.issues_cleared(int(result.get("issues") or 0), int(result.get("findings") or 0)) + + def incidents_comment_list( ctx: typer.Context, incident_id: str = typer.Argument(..., help="Incident id."), @@ -454,7 +671,7 @@ 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 / comment-* / subscribe* / open).", + help="Triage issues (list / count / 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) @@ -462,6 +679,10 @@ def register(app: typer.Typer) -> None: inc.command("ack", epilog=GLOBALS_EPILOG)(incidents_ack) inc.command("assign", epilog=GLOBALS_EPILOG)(incidents_assign) inc.command("resolve", epilog=GLOBALS_EPILOG)(incidents_resolve) + inc.command("close", epilog=GLOBALS_EPILOG)(incidents_close) + inc.command("archive", epilog=GLOBALS_EPILOG)(incidents_archive) + inc.command("unarchive", epilog=GLOBALS_EPILOG)(incidents_unarchive) + inc.command("clear", epilog=GLOBALS_EPILOG)(incidents_clear) inc.command("comment-list", epilog=GLOBALS_EPILOG)(incidents_comment_list) inc.command("comment-add", epilog=GLOBALS_EPILOG)(incidents_comment_add) inc.command("comment-delete", epilog=GLOBALS_EPILOG)(incidents_comment_delete) diff --git a/fp-cloud-cli/fp_cli/models.py b/fp-cloud-cli/fp_cli/models.py index 014b84669..c78ee8453 100644 --- a/fp-cloud-cli/fp_cli/models.py +++ b/fp-cloud-cli/fp_cli/models.py @@ -417,6 +417,12 @@ class Incident: acknowledged_by: Optional[str] = None assignees: List[str] = field(default_factory=list) resolved_at: Optional[str] = None + #: When the issue was CLOSED (won't-fix) rather than resolved (fixed). At most + #: one of the two is ever set. Both absent on a live issue. + closed_at: Optional[str] = None + #: When the issue was hidden from the board. Orthogonal to ``state`` — an + #: archived issue keeps whatever state it ended in. + archived_at: Optional[str] = None breach_value: Optional[float] = None breach_summary: Optional[str] = None evidence: Optional[Dict[str, Any]] = None @@ -443,6 +449,11 @@ def from_dict(cls, d: Dict[str, Any]) -> "Incident": acknowledged_by=d.get("acknowledged_by"), assignees=list(d.get("assignees") or []), resolved_at=d.get("resolved_at"), + # `from_dict` is an ALLOWLIST: a key missing here is dropped with no + # error, so a column added server-side renders as a blank column here + # and nowhere complains. That is why these two lines exist at all. + closed_at=d.get("closed_at"), + archived_at=d.get("archived_at"), breach_value=d.get("breach_value"), breach_summary=d.get("breach_summary"), evidence=d.get("evidence"), diff --git a/fp-cloud-cli/fp_cli/output.py b/fp-cloud-cli/fp_cli/output.py index ffd8b5d14..c69e55997 100644 --- a/fp-cloud-cli/fp_cli/output.py +++ b/fp-cloud-cli/fp_cli/output.py @@ -4563,6 +4563,73 @@ def incident_resolved(incident_id: str) -> None: (_short_id(incident_id or "-"), theme.ACCENT)) +def confirm_incident_close(incident_id: str, alert_name: Optional[str] = None) -> bool: + """Plain close confirm (stderr). Says what separates close from resolve — a closed issue + does NOT come back when the pattern recurs — because that is the whole distinction and + nobody can infer it from the word.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("close issue ", style=theme.TEXT) + h.append(_short_id(incident_id or "-"), style=f"bold {theme.ACCENT}") + if alert_name: + h.append(f" ({alert_name})", style=theme.LABEL) + h.append("?", style=theme.TEXT) + return confirm_line( + h, Text("won't fix — it stays closed even if the pattern comes back", style=theme.LABEL) + ) + + +def incident_closed(incident_id: str) -> None: + """Plain green line (stderr): ``✓ closed issue {short id}``.""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), ("closed issue ", theme.TEXT), + (_short_id(incident_id or "-"), theme.ACCENT)) + + +def incident_archived(incident_id: str, archived: bool) -> None: + """Plain green line (stderr): ``✓ archived issue {short id}`` / ``✓ unarchived …``.""" + if _quiet: + return + _stderr.print() + _plain(("✓ ", theme.SUCCESS), + ("archived issue " if archived else "unarchived issue ", theme.TEXT), + (_short_id(incident_id or "-"), theme.ACCENT)) + + +def confirm_issues_clear(scope_label: str, issues: int, findings: int) -> bool: + """The bulk-clear confirm (stderr). Names the COUNT from the server's dry run rather than + the scope alone — a confirmation that cannot say how many rows it will change is a + confirmation in name only — and states the part operators actually need: nothing is + suppressed, so a pattern that survived their agent changes comes back and reopens its issue.""" + h = Text() + h.append("⚠ ", style=f"bold {theme.AMBER}") + h.append("clear ", style=theme.TEXT) + h.append(f"{issues} issue{'' if issues == 1 else 's'}", style=f"bold {theme.ACCENT}") + h.append(f" ({scope_label})", style=theme.LABEL) + h.append("?", style=theme.TEXT) + tail = f"resolves them" + if findings: + tail += f" and {findings} audit finding{'' if findings == 1 else 's'}" + tail += " — nothing is deleted or suppressed" + return confirm_line(h, Text(tail, style=theme.LABEL)) + + +def issues_cleared(issues: int, findings: int, dry_run: bool = False) -> None: + """Plain green line (stderr) for a completed bulk clear, or the dry-run preview.""" + if _quiet: + return + _stderr.print() + noun = f"{issues} issue{'' if issues == 1 else 's'}" + if dry_run: + _plain(("○ ", theme.FAINT), ("would clear ", theme.LABEL), (noun, theme.ACCENT), + (f" · {findings} finding{'' if findings == 1 else 's'}", theme.FAINT)) + return + _plain(("✓ ", theme.SUCCESS), ("cleared ", theme.TEXT), (noun, theme.ACCENT), + (f" · {findings} finding{'' if findings == 1 else 's'} resolved", theme.FAINT)) + + def incident_assigned(incident_id: str, assignees: Sequence[str]) -> None: """Plain green line (stderr): ``✓ assigned {short id} · a@x, b@x`` — or ``✓ cleared assignees on {short id}`` when the list is empty.""" @@ -5754,7 +5821,7 @@ def confirm_finding_action(action: str, finding_id: str, *, title: Optional[str] consequence = { "mute": "future runs stop surfacing this pattern", "dismiss": "it's suppressed as not worth acting on", - "resolve": "it closes; a genuine recurrence re-opens as new", + "resolve": "it closes; a genuine recurrence re-opens it", }.get(action, "this changes the finding's status") return confirm_line(h, Text(consequence, style=theme.LABEL)) diff --git a/fp-cloud-cli/pyproject.toml b/fp-cloud-cli/pyproject.toml index cbf6b8f0e..c0513ea91 100644 --- a/fp-cloud-cli/pyproject.toml +++ b/fp-cloud-cli/pyproject.toml @@ -12,7 +12,7 @@ license = { file = "LICENSE" } authors = [{ name = "Failproof AI", email = "failproofai@exosphere.host" }] keywords = ["agents", "observability", "ai", "monitoring", "failproofai", "cli"] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Programming Language :: Python :: 3", diff --git a/fp-cloud-cli/skill/SKILL.md b/fp-cloud-cli/skill/SKILL.md index 29a7c5ed4..1cb5392fd 100644 --- a/fp-cloud-cli/skill/SKILL.md +++ b/fp-cloud-cli/skill/SKILL.md @@ -192,7 +192,7 @@ you need a flag you don't already know. - `users list|show|create|update|disable|enable` — referenced by **email**. - `settings list|schema|set` — fixed registry; `schema` shows what each key accepts. - `alerts list|show|create|update|delete|test` — referenced by **name**. -- `issues list|count|show|ack|assign|resolve|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open` — by id (short ids accepted). **One board for everything needing attention**: alert breaches, hand-raised issues, and audit findings, told apart by a `source` of `alert` / `manual` / `audit`. (This group was called `incidents` before; the old name is gone.) +- `issues list|count|show|ack|assign|resolve|close|archive|unarchive|clear|comment-add|comment-list|comment-delete|subscribe|subscribers|unsubscribe|open` — by id (short ids accepted). **One board for everything needing attention**: alert breaches, hand-raised issues, and audit findings, told apart by a `source` of `alert` / `manual` / `audit`. (This group was called `incidents` before; the old name is gone.) - `audits list|show|create|edit|delete|run|runs` — scheduled sweeps, referenced by **name**; `audits findings|finding` + the triage verbs `ack|mute|dismiss|resolve|reopen|assign` act on a finding **id**. `audits run ` only *queues* a run (poll `audits runs ` for completion). See §8. **Enforce (cloud-managed policy, session-only — see §2):** @@ -225,6 +225,9 @@ command (`list `, `whoami`, a `list` subcommand) before committing. | "what has this org used this metering window?" | `usage` (or `--json usage` for the complete response) | | "is anything on fire?", "any alerts firing / open issues?" | `alerts list` + `issues list` (and `issues count`) | | "ack / look at / resolve that issue" | `issues list` → `issues show ` → **confirm** → `issues ack`/`resolve ` | +| "clear all our issues", "fresh start", "we changed the agents" | **settle the scope first** (`--all-audits` leaves alert and hand-opened issues alone; `--everything` does not; `--audit ` is one audit) → `issues clear --dry-run` → show the count → **confirm** → `issues clear ` | +| "we're not going to fix that one" | `issues close ` (NOT `resolve` — closed survives a recurrence) | +| "get that off my board" | `issues archive ` | | "run an audit", "what did the audit find?", "any findings to triage?" | `audits list` → `audits run ` (queues) → `audits runs ` (wait for `succeeded`) → `audits findings --audit `; triage with `audits resolve/mute/dismiss ` — **confirm first** | | "give CI / this service an API key" | `keys create --add events:add` (scope to what they describe) — **state it, then create**; capture the one-time secret | | "who has access?", "add / remove a teammate", "make them read-only" | `users list` / `users show ` / `users create`/`update`/`disable` | @@ -267,6 +270,18 @@ fp --json events --full --session-id run-001 --all | jq '.events[].payload' # reliable cross-check that you pulled everything. - **Triage flow:** `issues list` → `issues show ` (read the activity log) → confirm with the user → `issues ack ` or `resolve `. +- **`resolve` vs `close`:** `resolve` claims a fix, so a recurring audit finding + REOPENS it — that is the signal that the fix did not hold. `close` records a + decision (won't fix / not a problem / stale) and survives the recurrence. Pick + the one that matches what the user actually said; they are not synonyms. +- **"clear all our issues" / "fresh start":** that is `issues clear`, not a loop + of `resolve`. **Settle the scope before you run anything** — "all our issues" + does not pick one: `--all-audits` leaves alert and hand-opened issues on the + board, `--everything` takes them too, and `--audit ` is one audit's work. + Ask which they mean, then `--dry-run` first, tell the user the number it + returns, and only then run it for real. It resolves the audit findings too and + writes **no** suppression, so anything still broken comes back and reopens its + issue — say that, because users often expect "clear" to mean "silence". - **Investigate a regression:** `evals --aggregate` to see which score dropped → `evals --score helpfulness:..0.5` to list the bad runs → `events --session-id ` to see what happened inside one. @@ -293,7 +308,7 @@ Two things about the flow matter when driving it from the CLI: either surface works, they never disagree. Triage a finding with `audits ack|mute|dismiss|resolve|reopen ` (durable **mute/dismiss** suppress the pattern org-wide by fingerprint; **resolve** leaves no suppression, so a true - recurrence reopens as new). Reads need `audits:read`, every mutation + recurrence reopens the issue). Reads need `audits:read`, every mutation `audits:write` (note: triaging a finding needs `audits:write`, not an `issues:*` permission — the audit is the system of record and the issue follows it). diff --git a/fp-cloud-cli/skill/references/commands.md b/fp-cloud-cli/skill/references/commands.md index d3f464c5d..bcd683afb 100644 --- a/fp-cloud-cli/skill/references/commands.md +++ b/fp-cloud-cli/skill/references/commands.md @@ -169,7 +169,7 @@ Context is a **separate sub-resource on purpose**: `audits edit` read-merges the - `audits ack [--reason ...]` — seen, stays visible, ranked lower. No confirm. - `audits mute [--reason ...] [--yes]` — stop surfacing this pattern in future runs (durable). Confirms first. - `audits dismiss [--reason ...] [--yes]` — judged not worth acting on; suppressed like mute. Confirms first. -- `audits resolve [--yes]` — you fixed it. Leaves **no** suppression, so a genuine recurrence is raised as new. Confirms first. +- `audits resolve [--yes]` — you fixed it. Leaves **no** suppression, so a genuine recurrence reopens the issue it graduated to. Confirms first. - `audits reopen ` — back to `open` **and** clears any mute/dismiss suppression. The undo for the three above. - `audits assign --to ` — set the owner; the status is untouched. `--to` is required (exit 2 without it). @@ -177,14 +177,17 @@ The title column truncates to whatever width is left so the fixed columns always **`audits run` is async — it only queues.** Success is `{"queued": true}`, not a finished run; the analysis can take minutes. Poll `audits runs ` until the newest row is `succeeded`/`failed` before reading `audits findings`, rather than assuming results exist on the call that queued them. -**Findings and issues are one bucket.** Every finding graduates to an issue (`source: audit`) that stores the finding's full content, so the same problem appears under both `audits findings` and `issues list`. Triage is consistent in **both** directions and needs `audits:write` either way: `audits resolve|mute|dismiss|ack|reopen ` mirrors onto the linked issue, and `issues resolve ` on an audit issue mirrors back onto the finding — the two never disagree. **resolve** leaves no suppression (a genuine recurrence reopens as new); **mute/dismiss** suppress the pattern org-wide by fingerprint. +**Findings and issues are one bucket.** Every finding graduates to an issue (`source: audit`) that stores the finding's full content, so the same problem appears under both `audits findings` and `issues list`. Triage is consistent in **both** directions and needs `audits:write` either way: `audits resolve|mute|dismiss|ack|reopen ` mirrors onto the linked issue, and `issues resolve ` on an audit issue mirrors back onto the finding — the two never disagree. **resolve** leaves no suppression (a genuine recurrence reopens the issue); **mute/dismiss** suppress the pattern org-wide by fingerprint. `issues close ` sits between the two: it marks the finding `dismissed` and the issue stays closed through a recurrence, but it writes no org-wide suppression. `issues clear` is bulk `resolve`, with the same absence of suppression. ## issues -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 needs `issues:close`. -- `issues list [--state firing|acknowledged|resolved] [--alert-id ] [--limit N] [--show-id]` — there is **no** `--severity` filter on this command. +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 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. +- `issues archive ` · `issues unarchive ` — take an issue off the board / put it back. **Orthogonal to state**: archiving resolves nothing and never overwrites how an issue ended, and it works on a live issue too. No confirm (it destroys nothing). A fresh alert breach or a recurring audit finding un-archives a live issue automatically, so archive can hide a problem but cannot keep hiding one that is still happening; a `closed` issue is the exception and stays hidden. +- `issues clear (--audit | --all-audits | --everything) [--dry-run] [--yes]` — **the "we changed our agents, give us a fresh board" command.** Resolves every open issue in the scope in one server-side transaction, plus the audit findings behind them. Exactly one scope flag is required. Needs `issues:close` **and** `audits:write`. **Nothing is deleted and nothing is suppressed** — a pattern the agent changes genuinely fixed stays gone, one they did not comes back on the next run and reopens its issue; for permanent silence use `audits mute`/`dismiss` instead. Always `--dry-run` first: the count comes from the server using the same scope predicate the write uses, so it is the real size of the scope — but it is a count, not a lease: the write re-runs the predicate, so anything that entered the scope since the preview is cleared too, and the closing line reports what actually changed. - `issues open --summary (--title | --alert-id ) [--title ...] [--severity ...]` — `--title` is **required** for a standalone issue (nothing to borrow a name from); with `--alert-id` it is optional and defaults to the alert's name. Missing `--title` on the standalone path → exit 2. - `issues comment-add (--body | --file )` — `--file -` reads stdin; exactly one of the two · `comment-list ` · `comment-delete ` - `issues subscribe [--email ]` · `unsubscribe [--email ]` · `subscribers ` — `--email` defaults to you; naming someone else needs `issues:create` diff --git a/fp-cloud-cli/tests/test_alerting.py b/fp-cloud-cli/tests/test_alerting.py index 90134f248..38e5baf8d 100644 --- a/fp-cloud-cli/tests/test_alerting.py +++ b/fp-cloud-cli/tests/test_alerting.py @@ -642,3 +642,187 @@ def test_issues_show_malformed_id_is_not_found(logged_in, runner): result = runner.invoke(app, ["--json", "issues", "show", "not-a-uuid"]) assert result.exit_code == 6 assert "no issue not-a-uuid" in json.loads(result.stdout)["error"] + +# --- close / archive / clear ------------------------------------------------ +# +# The issue lifecycle grew a second terminal state (`closed`) and an orthogonal +# archived flag. The CLI restates the server's contract by hand — the state +# allowlist, the paths, the response keys — with nothing checking the two agree, +# so these pin the CLI half. + + +@respx.mock +def test_issues_close_posts_to_close_not_resolve(logged_in, runner): + # The whole point of `close` is that it is NOT `resolve`: a resolved issue + # reopens when its audit finding recurs and a closed one does not, so a + # close that quietly hit /resolve would silently give the opposite + # behaviour with an identical success line. + resolve = respx.post(f"{BASE}/api/issues/i1/resolve").mock(return_value=httpx.Response(200, json={})) + close = respx.post(f"{BASE}/api/issues/i1/close").mock( + return_value=httpx.Response(200, json={"id": "i1", "state": "closed", "closed_at": "t"})) + result = runner.invoke(app, ["--json", "issues", "close", "i1", "--yes"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"closed": True, "id": "i1"} + assert close.called + assert not resolve.called + + +@respx.mock +def test_issues_close_human_line(logged_in, runner): + respx.post(f"{BASE}/api/issues/i1/close").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["issues", "close", "i1", "--yes"]) + assert result.exit_code == 0, result.output + assert "closed issue" in result.stderr + + +@respx.mock +def test_issues_close_declined_emits_the_json_cancel_envelope(logged_in, runner, monkeypatch): + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + monkeypatch.setattr(output, "confirm_incident_close", lambda *a, **k: False) + respx.get(f"{BASE}/api/issues/i1").mock( + return_value=httpx.Response(200, json={"id": "i1", "alert_name": "p95"})) + close = respx.post(f"{BASE}/api/issues/i1/close").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "issues", "close", "i1"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"cancelled": True} + assert not close.called + + +@respx.mock +def test_issues_archive_and_unarchive_hit_their_own_verbs(logged_in, runner): + arch = respx.post(f"{BASE}/api/issues/i1/archive").mock(return_value=httpx.Response(200, json={})) + unarch = respx.post(f"{BASE}/api/issues/i1/unarchive").mock(return_value=httpx.Response(200, json={})) + + result = runner.invoke(app, ["--json", "issues", "archive", "i1"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"archived": True, "id": "i1"} + assert arch.called and not unarch.called + + result = runner.invoke(app, ["--json", "issues", "unarchive", "i1"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"archived": False, "id": "i1"} + assert unarch.called + + +@respx.mock +def test_issues_archive_does_not_prompt(logged_in, runner, monkeypatch): + # Archiving destroys nothing and `unarchive` undoes it, so it must not ask — + # a prompt here would be the only blocking one in a cleanup loop. + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + route = respx.post(f"{BASE}/api/issues/i1/archive").mock(return_value=httpx.Response(200, json={})) + result = runner.invoke(app, ["--json", "issues", "archive", "i1"]) + assert result.exit_code == 0, result.output + assert route.called + + +def test_issues_clear_requires_exactly_one_scope(logged_in, runner): + # The three scopes have very different blast radii, so there is deliberately + # no default and no way to pass two. + none_given = runner.invoke(app, ["--json", "issues", "clear"]) + assert none_given.exit_code == 2, none_given.output + + two_given = runner.invoke(app, ["--json", "issues", "clear", "--all-audits", "--everything"]) + assert two_given.exit_code == 2, two_given.output + + +@respx.mock +def test_issues_clear_dry_run_writes_nothing(logged_in, runner): + calls = [] + + def record(request): + calls.append(json.loads(request.content)) + return httpx.Response(200, json={"issues": 12, "findings": 9, "dry_run": True, "scope": "all_audits"}) + + respx.post(f"{BASE}/api/issues/bulk-clear").mock(side_effect=record) + result = runner.invoke(app, ["--json", "issues", "clear", "--all-audits", "--dry-run"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["issues"] == 12 + # Exactly one request, and it carried dry_run — a second (writing) call here + # would mean --dry-run cleared the board. + assert len(calls) == 1 + assert calls[0] == {"scope": "all_audits", "dry_run": True} + + +@respx.mock +def test_issues_clear_previews_then_writes(logged_in, runner): + calls = [] + + def record(request): + body = json.loads(request.content) + calls.append(body) + return httpx.Response(200, json={ + "issues": 3, "findings": 2, "dry_run": body["dry_run"], "scope": "audit"}) + + respx.post(f"{BASE}/api/issues/bulk-clear").mock(side_effect=record) + result = runner.invoke(app, ["--json", "issues", "clear", "--audit", "a1", "--yes"]) + assert result.exit_code == 0, result.output + # Preview first (so the confirm can name a number), then the real write. + assert [c["dry_run"] for c in calls] == [True, False] + assert all(c["audit_id"] == "a1" and c["scope"] == "audit" for c in calls) + assert json.loads(result.stdout)["issues"] == 3 + + +@respx.mock +def test_issues_clear_declined_emits_the_json_cancel_envelope(logged_in, runner, monkeypatch): + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + monkeypatch.setattr(output, "confirm_issues_clear", lambda *a, **k: False) + calls = [] + + def record(request): + calls.append(json.loads(request.content)) + return httpx.Response(200, json={"issues": 5, "findings": 4, "dry_run": True, "scope": "everything"}) + + respx.post(f"{BASE}/api/issues/bulk-clear").mock(side_effect=record) + result = runner.invoke(app, ["--json", "issues", "clear", "--everything"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"cancelled": True} + # Only the preview ran. A declined confirm that had already written would be + # the worst possible version of this bug. + assert [c["dry_run"] for c in calls] == [True] + + +@respx.mock +def test_issues_clear_on_an_empty_scope_does_not_prompt_or_write(logged_in, runner, monkeypatch): + monkeypatch.setattr(_write, "should_prompt", lambda *a, **k: True) + + def boom(*a, **k): # pragma: no cover - asserts it is never reached + raise AssertionError("prompted to confirm clearing zero issues") + + monkeypatch.setattr(output, "confirm_issues_clear", boom) + calls = [] + + def record(request): + calls.append(json.loads(request.content)) + return httpx.Response(200, json={"issues": 0, "findings": 0, "dry_run": True, "scope": "all_audits"}) + + respx.post(f"{BASE}/api/issues/bulk-clear").mock(side_effect=record) + result = runner.invoke(app, ["--json", "issues", "clear", "--all-audits"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["issues"] == 0 + assert [c["dry_run"] for c in calls] == [True] + + +def test_issues_list_accepts_closed_as_a_state(logged_in, runner): + # `--state` is validated client-side (exit 2) against a hand-copied list. A + # state missing from it is rejected here for something the server accepts. + from fp_cli.commands import incidents_cmds + + assert "closed" in incidents_cmds._STATES + bad = runner.invoke(app, ["--json", "issues", "list", "--state", "nope"]) + assert bad.exit_code == 2, bad.output + + +@respx.mock +def test_issues_show_surfaces_closed_at_and_archived_at(logged_in, runner): + # `Incident.from_dict` is an ALLOWLIST — a key it does not name is dropped + # with no error, which renders as a permanently blank column. + respx.get(f"{BASE}/api/issues/i1").mock(return_value=httpx.Response(200, json={ + "id": "i1", "state": "closed", "closed_at": "2026-09-22T10:00:00Z", + "archived_at": "2026-09-22T11:00:00Z", "alert_severity": "warning", + })) + from fp_cli import client as api + + inc = api.get_incident(api.ClientContext(base_url=BASE, token="s"), "i1") + assert inc.closed_at == "2026-09-22T10:00:00Z" + assert inc.archived_at == "2026-09-22T11:00:00Z" + assert inc.state == "closed"