fp-cloud-cli 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fp_cli/__init__.py +10 -0
- fp_cli/__main__.py +4 -0
- fp_cli/_click_compat.py +87 -0
- fp_cli/_context.py +332 -0
- fp_cli/_version.py +1 -0
- fp_cli/analytics.py +432 -0
- fp_cli/analytics_config.py +77 -0
- fp_cli/analytics_registry.py +83 -0
- fp_cli/app.py +492 -0
- fp_cli/auth.py +160 -0
- fp_cli/client.py +1738 -0
- fp_cli/commands/__init__.py +0 -0
- fp_cli/commands/_write.py +214 -0
- fp_cli/commands/agent_cmds.py +407 -0
- fp_cli/commands/alerts_cmds.py +445 -0
- fp_cli/commands/audits_cmds.py +1054 -0
- fp_cli/commands/auth_cmds.py +512 -0
- fp_cli/commands/errors_cmds.py +190 -0
- fp_cli/commands/evals_cmds.py +161 -0
- fp_cli/commands/events_cmds.py +159 -0
- fp_cli/commands/fleet_cmds.py +416 -0
- fp_cli/commands/guardrails_cmds.py +148 -0
- fp_cli/commands/incidents_cmds.py +693 -0
- fp_cli/commands/keys_cmds.py +407 -0
- fp_cli/commands/list_cmds.py +63 -0
- fp_cli/commands/orgs_cmds.py +319 -0
- fp_cli/commands/policies_cmds.py +499 -0
- fp_cli/commands/queries_cmds.py +378 -0
- fp_cli/commands/sessions_cmds.py +151 -0
- fp_cli/commands/settings_cmds.py +150 -0
- fp_cli/commands/usage_cmds.py +35 -0
- fp_cli/commands/users_cmds.py +404 -0
- fp_cli/config.py +330 -0
- fp_cli/dates.py +78 -0
- fp_cli/enforcement.py +345 -0
- fp_cli/errors.py +98 -0
- fp_cli/models.py +902 -0
- fp_cli/orgs.py +30 -0
- fp_cli/output.py +6660 -0
- fp_cli/permissions.py +209 -0
- fp_cli/policy_check.py +290 -0
- fp_cli/py.typed +0 -0
- fp_cli/select.py +322 -0
- fp_cli/theme.py +53 -0
- fp_cloud_cli-0.0.1.dist-info/METADATA +335 -0
- fp_cloud_cli-0.0.1.dist-info/RECORD +50 -0
- fp_cloud_cli-0.0.1.dist-info/WHEEL +5 -0
- fp_cloud_cli-0.0.1.dist-info/entry_points.txt +2 -0
- fp_cloud_cli-0.0.1.dist-info/licenses/LICENSE +42 -0
- fp_cloud_cli-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
"""Incident triage: incidents list/count/show/ack/assign/resolve/close/archive/clear/comment*/subscribe*/open.
|
|
2
|
+
|
|
3
|
+
Incidents live under /api/issues but the triage workflow is distinct, so it
|
|
4
|
+
gets its own top-level group. The id IS the handle (incidents have no human name), so the
|
|
5
|
+
action commands take it directly; the boxed views show a short id + the alert label.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import List, Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from .. import client as api
|
|
16
|
+
from .. import output
|
|
17
|
+
from .._context import GLOBALS_EPILOG, AppState, require_auth, validate_limit
|
|
18
|
+
from ..errors import ApiError, ForbiddenError, NotFoundError
|
|
19
|
+
from . import _write
|
|
20
|
+
|
|
21
|
+
_SEVERITIES = ("info", "warning", "critical")
|
|
22
|
+
# Mirrors `issue_sync::ISSUE_STATES` on the server. `closed` is the second
|
|
23
|
+
# TERMINAL state (won't-fix); missing it here would reject `--state closed`
|
|
24
|
+
# client-side with exit 2 for a state the server accepts.
|
|
25
|
+
_STATES = ("firing", "acknowledged", "resolved", "closed")
|
|
26
|
+
_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}$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _validate_states(value: Optional[str]) -> None:
|
|
30
|
+
"""Reject an unknown ``--state`` value up front (exit 2) rather than letting the server
|
|
31
|
+
silently drop it and return a confusing set. Accepts a CSV of firing/acknowledged/resolved/closed."""
|
|
32
|
+
if value is None:
|
|
33
|
+
return
|
|
34
|
+
for s in value.split(","):
|
|
35
|
+
s = s.strip()
|
|
36
|
+
if s and s not in _STATES:
|
|
37
|
+
raise typer.BadParameter(
|
|
38
|
+
f"'{s}' is not a valid state. Choose from: {', '.join(_STATES)} (CSV).",
|
|
39
|
+
param_hint="--state",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _fail(state: AppState, exc: Exception, *, incident_id: str = "") -> None:
|
|
44
|
+
"""Re-raise as a typed error for the central chokepoint to render (JSON envelope under
|
|
45
|
+
``--json`` on stdout, red box otherwise). A 404 — or a **malformed (non-UUID) id**, which the
|
|
46
|
+
server's path extractor answers with a 400 rather than a 404 — becomes the friendlier
|
|
47
|
+
``no issue <id>`` (exit 6); every other ApiError/ForbiddenError keeps the server's message
|
|
48
|
+
and exit code."""
|
|
49
|
+
# EXACTLY 400, not a range. This read `>= 500` on the belief that the server answers a
|
|
50
|
+
# malformed id with a 500, and it does not: axum's path extractor rejects it at 400 with a
|
|
51
|
+
# plain-text body, which the dashboard turns into the generic "upstream returned non-JSON
|
|
52
|
+
# response". So the remap never fired — `fp issues show not-a-uuid` exited 1 carrying that
|
|
53
|
+
# internal phrase while `fp audits show not-a-uuid` exited 6 with a usable message, and
|
|
54
|
+
# anything branching on exit 6 to mean not-found silently took the wrong arm.
|
|
55
|
+
#
|
|
56
|
+
# `>= 400` is the obvious fix and it is WRONG, which is why this is spelled out. Issue ids
|
|
57
|
+
# are not required to be UUIDs — `fp issues assign i1 --assignee ...` is a documented call —
|
|
58
|
+
# so a non-UUID id reaches real handlers and collects real 4xx answers. A 422 "a@x.com is
|
|
59
|
+
# not an operator" would then be rewritten as "no issue i1", replacing the one sentence that
|
|
60
|
+
# explains the failure with a claim that is false. Only 400 means "the router refused to
|
|
61
|
+
# parse this id"; every other 4xx got past the extractor and has something to say.
|
|
62
|
+
status = getattr(exc, "status", None) or 0
|
|
63
|
+
malformed = bool(incident_id) and not _UUID_RE.match(incident_id) and status == 400
|
|
64
|
+
if incident_id and (malformed or isinstance(exc, NotFoundError)):
|
|
65
|
+
raise NotFoundError(
|
|
66
|
+
f"no issue {incident_id}", hint="run `fp issues list` to see open issues"
|
|
67
|
+
)
|
|
68
|
+
raise exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def incidents_list(
|
|
72
|
+
ctx: typer.Context,
|
|
73
|
+
state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s): firing, acknowledged, resolved, closed (CSV)."),
|
|
74
|
+
alert_id: Optional[str] = typer.Option(None, "--alert-id", help="Only incidents for this alert."),
|
|
75
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Max incidents to return."),
|
|
76
|
+
show_id: bool = typer.Option(False, "--show-id", help="Show the full incident id instead of the short form (always full in --json)."),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""List incidents in a boxed table (newest-opened first).
|
|
79
|
+
|
|
80
|
+
Shows `id · title · source · severity · state · opened · assignees` — the id is the handle the
|
|
81
|
+
action commands take (short by default, full with `--show-id`); `title` is the issue's own
|
|
82
|
+
identifying line (every issue has one, unlike `alert_name`); `source` is where it came from
|
|
83
|
+
(`manual`/`alert`/`audit`) and trails the alert name when there is one; severity is
|
|
84
|
+
colour-coded, state as `● firing`/`● acknowledged`/`○ resolved`, `opened` the compact age.
|
|
85
|
+
A footer breaks down the state distribution. Needs `issues:read`. With `--json`:
|
|
86
|
+
`{"issues": [{id, title, source, source_finding_id, alert_name, alert_severity, state,
|
|
87
|
+
opened_at, assignees, ...}]}`.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
|
|
91
|
+
* `fp issues list --state firing`
|
|
92
|
+
"""
|
|
93
|
+
state: AppState = ctx.obj
|
|
94
|
+
_validate_states(state_filter)
|
|
95
|
+
validate_limit(limit)
|
|
96
|
+
cctx = require_auth(state)
|
|
97
|
+
incidents = api.list_incidents(cctx, state=state_filter, alert_id=alert_id, limit=limit)
|
|
98
|
+
if state.json:
|
|
99
|
+
output.emit_json({"issues": incidents})
|
|
100
|
+
return
|
|
101
|
+
output.render_incidents(incidents, show_id=show_id)
|
|
102
|
+
output.incidents_footer(incidents)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def incidents_count(
|
|
106
|
+
ctx: typer.Context,
|
|
107
|
+
state_filter: Optional[str] = typer.Option(None, "--state", help="Filter by state(s) (CSV)."),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Count incidents (optionally by state) as a compact stat card.
|
|
110
|
+
|
|
111
|
+
With no `--state` the server counts the open ones (firing + acknowledged). Needs
|
|
112
|
+
`issues:read`. With `--json`: `{"count": N}`.
|
|
113
|
+
|
|
114
|
+
Example:
|
|
115
|
+
|
|
116
|
+
* `fp issues count --state firing`
|
|
117
|
+
"""
|
|
118
|
+
state: AppState = ctx.obj
|
|
119
|
+
_validate_states(state_filter)
|
|
120
|
+
cctx = require_auth(state)
|
|
121
|
+
count = api.count_incidents(cctx, state=state_filter)
|
|
122
|
+
if state.json:
|
|
123
|
+
output.emit_json({"count": count})
|
|
124
|
+
else:
|
|
125
|
+
output.render_incident_count(count, state=state_filter)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def incidents_show(
|
|
129
|
+
ctx: typer.Context,
|
|
130
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Show one incident in full — a stack of cards: identity, comments, subscribers, activity.
|
|
133
|
+
|
|
134
|
+
The identity card is headed by the issue's own title and shows severity · state · source ·
|
|
135
|
+
opened, who acknowledged/is assigned, and the breach; empty sections are omitted. Not-found →
|
|
136
|
+
`✗ no incident <short id>`, exit 6. Needs `issues:read`. With `--json`: the full `Incident`
|
|
137
|
+
(including `title`, `source`, `source_finding_id`, and untouched
|
|
138
|
+
comments/subscribers/activity).
|
|
139
|
+
|
|
140
|
+
Example:
|
|
141
|
+
|
|
142
|
+
* `fp issues show <id>`
|
|
143
|
+
"""
|
|
144
|
+
state: AppState = ctx.obj
|
|
145
|
+
cctx = require_auth(state)
|
|
146
|
+
try:
|
|
147
|
+
incident = api.get_incident(cctx, incident_id)
|
|
148
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
149
|
+
_fail(state, exc, incident_id=incident_id)
|
|
150
|
+
if state.json:
|
|
151
|
+
output.emit_json(incident)
|
|
152
|
+
return
|
|
153
|
+
output.render_incident_show(incident)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def incidents_ack(
|
|
157
|
+
ctx: typer.Context,
|
|
158
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Acknowledge an incident (no confirm — it isn't destructive).
|
|
161
|
+
|
|
162
|
+
Needs `issues:read` (ack rides on read). With `--json`: `{"acknowledged": true, "id": "<id>"}`.
|
|
163
|
+
|
|
164
|
+
Example:
|
|
165
|
+
|
|
166
|
+
* `fp issues ack <id>`
|
|
167
|
+
"""
|
|
168
|
+
state: AppState = ctx.obj
|
|
169
|
+
cctx = require_auth(state)
|
|
170
|
+
try:
|
|
171
|
+
api.ack_incident(cctx, incident_id)
|
|
172
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
173
|
+
_fail(state, exc, incident_id=incident_id)
|
|
174
|
+
_write.record_action("incident_acked", resource="incident", success=True)
|
|
175
|
+
if state.json:
|
|
176
|
+
output.emit_json({"acknowledged": True, "id": incident_id})
|
|
177
|
+
else:
|
|
178
|
+
output.incident_acked(incident_id)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def incidents_assign(
|
|
182
|
+
ctx: typer.Context,
|
|
183
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
184
|
+
assignee: Optional[List[str]] = typer.Option(None, "--assignee", help="Operator email to assign (repeatable; omit to clear all)."),
|
|
185
|
+
) -> None:
|
|
186
|
+
"""Set an incident's assignees (replaces the list; omit `--assignee` to clear).
|
|
187
|
+
|
|
188
|
+
Needs `issues:create`. The server rejects the whole call if any email is not an operator —
|
|
189
|
+
that's surfaced as a clean `✗ <message>`. With `--json`: `{"assignees": [...], "id": "<id>"}`.
|
|
190
|
+
|
|
191
|
+
Example:
|
|
192
|
+
|
|
193
|
+
* `fp issues assign <id> --assignee a@example.com --assignee b@example.com`
|
|
194
|
+
"""
|
|
195
|
+
state: AppState = ctx.obj
|
|
196
|
+
cctx = require_auth(state)
|
|
197
|
+
assignees = assignee or []
|
|
198
|
+
try:
|
|
199
|
+
api.assign_incident(cctx, incident_id, assignees)
|
|
200
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
201
|
+
_fail(state, exc, incident_id=incident_id)
|
|
202
|
+
_write.record_action("incident_assigned", resource="incident", success=True)
|
|
203
|
+
if state.json:
|
|
204
|
+
output.emit_json({"assignees": assignees, "id": incident_id})
|
|
205
|
+
else:
|
|
206
|
+
output.incident_assigned(incident_id, assignees)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def incidents_resolve(
|
|
210
|
+
ctx: typer.Context,
|
|
211
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
212
|
+
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."),
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Resolve (close) an incident, after a calm confirm.
|
|
215
|
+
|
|
216
|
+
Needs `issues:close`. With `--json`: `{"resolved": true, "id": "<id>"}` (or `{cancelled: true}`
|
|
217
|
+
on a declined prompt).
|
|
218
|
+
|
|
219
|
+
Example:
|
|
220
|
+
|
|
221
|
+
* `fp issues resolve <id> --yes`
|
|
222
|
+
"""
|
|
223
|
+
state: AppState = ctx.obj
|
|
224
|
+
cctx = require_auth(state)
|
|
225
|
+
if _write.should_prompt(state, yes):
|
|
226
|
+
alert_name = None
|
|
227
|
+
try:
|
|
228
|
+
alert_name = api.get_incident(cctx, incident_id).alert_name
|
|
229
|
+
except NotFoundError as exc:
|
|
230
|
+
_fail(state, exc, incident_id=incident_id)
|
|
231
|
+
except (ApiError, ForbiddenError):
|
|
232
|
+
alert_name = None
|
|
233
|
+
if not output.confirm_incident_resolve(incident_id, alert_name):
|
|
234
|
+
if state.json:
|
|
235
|
+
output.emit_json({"cancelled": True})
|
|
236
|
+
else:
|
|
237
|
+
output.cancelled_plain("nothing changed")
|
|
238
|
+
return
|
|
239
|
+
try:
|
|
240
|
+
api.resolve_incident(cctx, incident_id)
|
|
241
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
242
|
+
_fail(state, exc, incident_id=incident_id)
|
|
243
|
+
_write.record_action("incident_resolved", resource="incident", success=True)
|
|
244
|
+
if state.json:
|
|
245
|
+
output.emit_json({"resolved": True, "id": incident_id})
|
|
246
|
+
else:
|
|
247
|
+
output.incident_resolved(incident_id)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def incidents_close(
|
|
251
|
+
ctx: typer.Context,
|
|
252
|
+
incident_id: str = typer.Argument(..., help="Issue id."),
|
|
253
|
+
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."),
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Close an issue — "we're done with it", not "we fixed it".
|
|
256
|
+
|
|
257
|
+
The difference from `resolve` is what happens next. A **resolved** issue REOPENS if its
|
|
258
|
+
audit finding recurs: someone claimed a fix and the fix did not hold, which is worth
|
|
259
|
+
knowing. A **closed** issue does not — closing records a decision (won't fix, not a
|
|
260
|
+
problem, stale), and a recurrence is not news about a decision.
|
|
261
|
+
|
|
262
|
+
For an issue that came from an audit, this also marks the finding `dismissed`. It does
|
|
263
|
+
NOT write the finding's org-wide suppression: closing one issue never hides that pattern
|
|
264
|
+
in your other audits. Use `fp audits finding-status <id> --action dismiss` for that.
|
|
265
|
+
|
|
266
|
+
Needs `issues:close`. Exits 9 (conflict) if the issue already ended — an issue ends once,
|
|
267
|
+
and closing must not overwrite the record that someone believed they had fixed it.
|
|
268
|
+
|
|
269
|
+
With `--json`: `{"closed": true, "id": "<id>"}` (or `{cancelled: true}` on a declined
|
|
270
|
+
prompt).
|
|
271
|
+
|
|
272
|
+
Example:
|
|
273
|
+
|
|
274
|
+
* `fp issues close <id> --yes`
|
|
275
|
+
"""
|
|
276
|
+
state: AppState = ctx.obj
|
|
277
|
+
cctx = require_auth(state)
|
|
278
|
+
if _write.should_prompt(state, yes):
|
|
279
|
+
alert_name = None
|
|
280
|
+
try:
|
|
281
|
+
alert_name = api.get_incident(cctx, incident_id).alert_name
|
|
282
|
+
except NotFoundError as exc:
|
|
283
|
+
_fail(state, exc, incident_id=incident_id)
|
|
284
|
+
except (ApiError, ForbiddenError):
|
|
285
|
+
alert_name = None
|
|
286
|
+
if not output.confirm_incident_close(incident_id, alert_name):
|
|
287
|
+
if state.json:
|
|
288
|
+
output.emit_json({"cancelled": True})
|
|
289
|
+
else:
|
|
290
|
+
output.cancelled_plain("nothing changed")
|
|
291
|
+
return
|
|
292
|
+
try:
|
|
293
|
+
api.close_incident(cctx, incident_id)
|
|
294
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
295
|
+
_fail(state, exc, incident_id=incident_id)
|
|
296
|
+
_write.record_action("incident_closed", resource="incident", success=True)
|
|
297
|
+
if state.json:
|
|
298
|
+
output.emit_json({"closed": True, "id": incident_id})
|
|
299
|
+
else:
|
|
300
|
+
output.incident_closed(incident_id)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def incidents_archive(
|
|
304
|
+
ctx: typer.Context,
|
|
305
|
+
incident_id: str = typer.Argument(..., help="Issue id."),
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Take an issue off the issues board, keeping its history.
|
|
308
|
+
|
|
309
|
+
Archiving is separate from state: it does not resolve or close anything, and an issue
|
|
310
|
+
that has already ended keeps the record of HOW it ended. It works on a live issue too —
|
|
311
|
+
you should not have to triage something in order to stop looking at it — because the
|
|
312
|
+
safety net is on the other side: a fresh alert breach or a recurring audit finding puts
|
|
313
|
+
a live issue back on the board automatically. Archive can hide a problem; it cannot keep
|
|
314
|
+
hiding one that is still happening. A closed (won't-fix) issue is the exception and stays
|
|
315
|
+
hidden, since nobody asked for it back.
|
|
316
|
+
|
|
317
|
+
No confirmation: archiving destroys nothing and `fp issues unarchive` undoes it.
|
|
318
|
+
|
|
319
|
+
Needs `issues:close`. With `--json`: `{"archived": true, "id": "<id>"}`.
|
|
320
|
+
|
|
321
|
+
Example:
|
|
322
|
+
|
|
323
|
+
* `fp issues archive <id>`
|
|
324
|
+
"""
|
|
325
|
+
_set_archived(ctx, incident_id, True)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def incidents_unarchive(
|
|
329
|
+
ctx: typer.Context,
|
|
330
|
+
incident_id: str = typer.Argument(..., help="Issue id."),
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Put an archived issue back on the issues board. See `fp issues archive`.
|
|
333
|
+
|
|
334
|
+
Needs `issues:close`. With `--json`: `{"archived": false, "id": "<id>"}`.
|
|
335
|
+
|
|
336
|
+
Example:
|
|
337
|
+
|
|
338
|
+
* `fp issues unarchive <id>`
|
|
339
|
+
"""
|
|
340
|
+
_set_archived(ctx, incident_id, False)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _set_archived(ctx: typer.Context, incident_id: str, archived: bool) -> None:
|
|
344
|
+
"""Shared body of archive/unarchive. Two commands rather than one with a flag, so each
|
|
345
|
+
reads as the verb it is and neither can be invoked meaning the opposite."""
|
|
346
|
+
state: AppState = ctx.obj
|
|
347
|
+
cctx = require_auth(state)
|
|
348
|
+
try:
|
|
349
|
+
api.set_incident_archived(cctx, incident_id, archived)
|
|
350
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
351
|
+
_fail(state, exc, incident_id=incident_id)
|
|
352
|
+
_write.record_action(
|
|
353
|
+
"incident_archived" if archived else "incident_unarchived",
|
|
354
|
+
resource="incident", success=True,
|
|
355
|
+
)
|
|
356
|
+
if state.json:
|
|
357
|
+
output.emit_json({"archived": archived, "id": incident_id})
|
|
358
|
+
else:
|
|
359
|
+
output.incident_archived(incident_id, archived)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def incidents_clear(
|
|
363
|
+
ctx: typer.Context,
|
|
364
|
+
audit_id: Optional[str] = typer.Option(None, "--audit", help="Clear only the issues this audit raised."),
|
|
365
|
+
all_audits: bool = typer.Option(False, "--all-audits", help="Clear every issue any audit raised. Alert and hand-opened issues are left alone."),
|
|
366
|
+
everything: bool = typer.Option(False, "--everything", help="Clear every open issue in the workspace, whatever opened it."),
|
|
367
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print what would be cleared and change nothing."),
|
|
368
|
+
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."),
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Resolve every open issue in a scope — a fresh start after changing your agents.
|
|
371
|
+
|
|
372
|
+
Exactly one of `--audit <id>`, `--all-audits` or `--everything` is required: the three
|
|
373
|
+
have very different blast radii, so there is deliberately no default.
|
|
374
|
+
|
|
375
|
+
This is the same thing as resolving each issue by hand, including what it does NOT do.
|
|
376
|
+
**Nothing is deleted, and nothing is suppressed.** A pattern your agent changes genuinely
|
|
377
|
+
fixed stays gone; one they did not comes back on the next audit run and REOPENS the issue
|
|
378
|
+
it was raised under — a cleared board is not a quiet one. If you want a pattern silenced
|
|
379
|
+
for good, that is `fp audits finding-status <id> --action mute`, which is a different and
|
|
380
|
+
much bigger hammer.
|
|
381
|
+
|
|
382
|
+
Run it with `--dry-run` first: the count comes from the server, taken with the same scope
|
|
383
|
+
predicate the write uses, so it is the real size of the scope rather than a client-side
|
|
384
|
+
guess. It is a count, not a lease — the confirmed write re-runs that predicate, so an
|
|
385
|
+
issue that entered the scope since the preview is cleared along with the rest, which is
|
|
386
|
+
what clearing a SCOPE means. The line printed at the end is what actually changed.
|
|
387
|
+
|
|
388
|
+
Needs `issues:close` AND `audits:write` — clearing resolves the audit findings behind the
|
|
389
|
+
issues, so a key that cannot touch one finding cannot resolve all of them at once.
|
|
390
|
+
|
|
391
|
+
With `--json`: `{"issues": n, "findings": n, "dry_run": bool, "scope": "..."}` (or
|
|
392
|
+
`{cancelled: true}` on a declined prompt).
|
|
393
|
+
|
|
394
|
+
Examples:
|
|
395
|
+
|
|
396
|
+
* `fp issues clear --all-audits --dry-run`
|
|
397
|
+
* `fp issues clear --audit <id> --yes`
|
|
398
|
+
"""
|
|
399
|
+
state: AppState = ctx.obj
|
|
400
|
+
picked = [bool(audit_id), all_audits, everything]
|
|
401
|
+
if sum(picked) != 1:
|
|
402
|
+
raise typer.BadParameter(
|
|
403
|
+
"choose exactly one of --audit <id>, --all-audits, or --everything.",
|
|
404
|
+
param_hint="--all-audits",
|
|
405
|
+
)
|
|
406
|
+
if audit_id:
|
|
407
|
+
scope, label = "audit", "this audit"
|
|
408
|
+
elif all_audits:
|
|
409
|
+
scope, label = "all_audits", "every audit"
|
|
410
|
+
else:
|
|
411
|
+
scope, label = "everything", "the whole workspace"
|
|
412
|
+
|
|
413
|
+
cctx = require_auth(state)
|
|
414
|
+
|
|
415
|
+
# The dry run is also the preview behind the prompt, so a `--dry-run` call and a
|
|
416
|
+
# confirmed clear count the same rows the same way.
|
|
417
|
+
try:
|
|
418
|
+
preview = api.clear_issues(cctx, scope=scope, audit_id=audit_id, dry_run=True)
|
|
419
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
420
|
+
_fail(state, exc)
|
|
421
|
+
return
|
|
422
|
+
issues = int(preview.get("issues") or 0)
|
|
423
|
+
findings = int(preview.get("findings") or 0)
|
|
424
|
+
|
|
425
|
+
if dry_run:
|
|
426
|
+
if state.json:
|
|
427
|
+
output.emit_json(preview)
|
|
428
|
+
else:
|
|
429
|
+
output.issues_cleared(issues, findings, dry_run=True)
|
|
430
|
+
return
|
|
431
|
+
|
|
432
|
+
if issues == 0:
|
|
433
|
+
# Nothing to do is not a failure, and prompting to confirm zero rows is noise.
|
|
434
|
+
if state.json:
|
|
435
|
+
output.emit_json({"issues": 0, "findings": 0, "dry_run": False, "scope": scope})
|
|
436
|
+
else:
|
|
437
|
+
output.issues_cleared(0, 0)
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
if _write.should_prompt(state, yes) and not output.confirm_issues_clear(label, issues, findings):
|
|
441
|
+
if state.json:
|
|
442
|
+
output.emit_json({"cancelled": True})
|
|
443
|
+
else:
|
|
444
|
+
output.cancelled_plain("nothing changed")
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
result = api.clear_issues(cctx, scope=scope, audit_id=audit_id)
|
|
449
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
450
|
+
_fail(state, exc)
|
|
451
|
+
return
|
|
452
|
+
# `mode` and `count` are both on _SAFE_PROP_KEYS, and both are shape: a closed enum we
|
|
453
|
+
# authored and an integer. Never an audit id or an issue title.
|
|
454
|
+
_write.record_action(
|
|
455
|
+
"issues_cleared", resource="incident", success=True, destructive=True,
|
|
456
|
+
mode=scope, count=int(result.get("issues") or 0),
|
|
457
|
+
)
|
|
458
|
+
if state.json:
|
|
459
|
+
output.emit_json(result)
|
|
460
|
+
else:
|
|
461
|
+
output.issues_cleared(int(result.get("issues") or 0), int(result.get("findings") or 0))
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def incidents_comment_list(
|
|
465
|
+
ctx: typer.Context,
|
|
466
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
467
|
+
) -> None:
|
|
468
|
+
"""List an incident's comments in a boxed table.
|
|
469
|
+
|
|
470
|
+
Shows `author · when · body` (the body wraps; a deleted comment shows a dim `(deleted)`).
|
|
471
|
+
Needs `issues:read`. With `--json`: `{"comments": [{id, author_email, body, created_at, ...}]}`.
|
|
472
|
+
"""
|
|
473
|
+
state: AppState = ctx.obj
|
|
474
|
+
cctx = require_auth(state)
|
|
475
|
+
try:
|
|
476
|
+
comments = api.list_incident_comments(cctx, incident_id)
|
|
477
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
478
|
+
_fail(state, exc, incident_id=incident_id)
|
|
479
|
+
if state.json:
|
|
480
|
+
output.emit_json({"comments": comments})
|
|
481
|
+
return
|
|
482
|
+
output.render_incident_comments(comments)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def incidents_comment_add(
|
|
486
|
+
ctx: typer.Context,
|
|
487
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
488
|
+
body: Optional[str] = typer.Option(None, "--body", help="Comment text (or use --file/-)."),
|
|
489
|
+
file: Optional[str] = typer.Option(None, "--file", help="Read the comment body from a file, or `-` for stdin."),
|
|
490
|
+
) -> None:
|
|
491
|
+
"""Add a comment to an incident (rendered as a green "comment added" card).
|
|
492
|
+
|
|
493
|
+
Needs `issues:read` (commenting rides on read). Provide exactly one of `--body` or `--file`/stdin. With `--json`: the
|
|
494
|
+
created comment.
|
|
495
|
+
|
|
496
|
+
Example:
|
|
497
|
+
|
|
498
|
+
* `fp issues comment-add <id> --body "looking into it"`
|
|
499
|
+
"""
|
|
500
|
+
state: AppState = ctx.obj
|
|
501
|
+
if (body is None) == (file is None):
|
|
502
|
+
raise typer.BadParameter("Provide exactly one of --body or --file.")
|
|
503
|
+
text = body if body is not None else _write.read_text_arg(file)
|
|
504
|
+
cctx = require_auth(state)
|
|
505
|
+
try:
|
|
506
|
+
comment = api.create_incident_comment(cctx, incident_id, text)
|
|
507
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
508
|
+
_fail(state, exc, incident_id=incident_id)
|
|
509
|
+
_write.record_action("incident_comment_added", resource="incident", success=True)
|
|
510
|
+
if state.json:
|
|
511
|
+
output.emit_json(comment)
|
|
512
|
+
else:
|
|
513
|
+
output.render_incident_comment_added(comment)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def incidents_comment_delete(
|
|
517
|
+
ctx: typer.Context,
|
|
518
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
519
|
+
comment_id: str = typer.Argument(..., help="Comment id."),
|
|
520
|
+
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."),
|
|
521
|
+
) -> None:
|
|
522
|
+
"""Delete an incident comment, after an amber preview + confirm.
|
|
523
|
+
|
|
524
|
+
Resolves the comment first (so an unknown comment id → `✗ no comment …`, exit 6), previews it,
|
|
525
|
+
then confirms. Needs `issues:read` to delete your own comment, `issues:close` to moderate others'. With `--json`: `{"deleted": true, "id": "<comment_id>"}`
|
|
526
|
+
(or `{cancelled: true}` on a declined prompt).
|
|
527
|
+
"""
|
|
528
|
+
state: AppState = ctx.obj
|
|
529
|
+
cctx = require_auth(state)
|
|
530
|
+
try:
|
|
531
|
+
comments = api.list_incident_comments(cctx, incident_id)
|
|
532
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
533
|
+
_fail(state, exc, incident_id=incident_id)
|
|
534
|
+
match = next((c for c in comments if c.id == comment_id), None)
|
|
535
|
+
if match is None:
|
|
536
|
+
raise NotFoundError(f"no comment {comment_id}")
|
|
537
|
+
if _write.should_prompt(state, yes):
|
|
538
|
+
output.render_incident_comment_delete_preview(match)
|
|
539
|
+
if not output.confirm_incident_comment_delete():
|
|
540
|
+
if state.json:
|
|
541
|
+
output.emit_json({"cancelled": True})
|
|
542
|
+
else:
|
|
543
|
+
output.cancelled_plain("nothing deleted")
|
|
544
|
+
return
|
|
545
|
+
try:
|
|
546
|
+
api.delete_incident_comment(cctx, incident_id, comment_id)
|
|
547
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
548
|
+
_fail(state, exc, incident_id=incident_id)
|
|
549
|
+
_write.record_action("incident_comment_deleted", resource="incident", success=True, destructive=True)
|
|
550
|
+
if state.json:
|
|
551
|
+
output.emit_json({"deleted": True, "id": comment_id})
|
|
552
|
+
else:
|
|
553
|
+
output.incident_comment_deleted()
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def incidents_subscribers(
|
|
557
|
+
ctx: typer.Context,
|
|
558
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
559
|
+
) -> None:
|
|
560
|
+
"""List who is subscribed to an incident in a boxed table.
|
|
561
|
+
|
|
562
|
+
Shows `email · source · subscribed`. Needs `issues:read`. With `--json`:
|
|
563
|
+
`{"subscribers": [{email, source, subscribed_at, ...}]}`.
|
|
564
|
+
"""
|
|
565
|
+
state: AppState = ctx.obj
|
|
566
|
+
cctx = require_auth(state)
|
|
567
|
+
try:
|
|
568
|
+
subs = api.list_incident_subscribers(cctx, incident_id)
|
|
569
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
570
|
+
_fail(state, exc, incident_id=incident_id)
|
|
571
|
+
if state.json:
|
|
572
|
+
output.emit_json({"subscribers": subs})
|
|
573
|
+
return
|
|
574
|
+
output.render_incident_subscribers(subs)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def incidents_subscribe(
|
|
578
|
+
ctx: typer.Context,
|
|
579
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
580
|
+
email: Optional[str] = typer.Option(None, "--email", help="Email to subscribe (default: you)."),
|
|
581
|
+
) -> None:
|
|
582
|
+
"""Subscribe to an incident's notifications.
|
|
583
|
+
|
|
584
|
+
With `--json`: `{"subscribed": true, "id": "<id>"}`.
|
|
585
|
+
"""
|
|
586
|
+
state: AppState = ctx.obj
|
|
587
|
+
cctx = require_auth(state)
|
|
588
|
+
try:
|
|
589
|
+
api.subscribe_incident(cctx, incident_id, email)
|
|
590
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
591
|
+
_fail(state, exc, incident_id=incident_id)
|
|
592
|
+
_write.record_action("incident_subscribed", resource="incident", success=True)
|
|
593
|
+
if state.json:
|
|
594
|
+
output.emit_json({"subscribed": True, "id": incident_id})
|
|
595
|
+
else:
|
|
596
|
+
output.incident_subscribed(incident_id, email)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def incidents_unsubscribe(
|
|
600
|
+
ctx: typer.Context,
|
|
601
|
+
incident_id: str = typer.Argument(..., help="Incident id."),
|
|
602
|
+
email: Optional[str] = typer.Option(None, "--email", help="Email to unsubscribe (default: you)."),
|
|
603
|
+
) -> None:
|
|
604
|
+
"""Unsubscribe from an incident's notifications.
|
|
605
|
+
|
|
606
|
+
With `--json`: `{"unsubscribed": true, "id": "<id>"}`.
|
|
607
|
+
"""
|
|
608
|
+
state: AppState = ctx.obj
|
|
609
|
+
cctx = require_auth(state)
|
|
610
|
+
try:
|
|
611
|
+
api.unsubscribe_incident(cctx, incident_id, email)
|
|
612
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
613
|
+
_fail(state, exc, incident_id=incident_id)
|
|
614
|
+
_write.record_action("incident_unsubscribed", resource="incident", success=True)
|
|
615
|
+
if state.json:
|
|
616
|
+
output.emit_json({"unsubscribed": True, "id": incident_id})
|
|
617
|
+
else:
|
|
618
|
+
output.incident_unsubscribed(incident_id, email)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def incidents_open(
|
|
622
|
+
ctx: typer.Context,
|
|
623
|
+
summary: str = typer.Option(..., "--summary", help="Short description of the incident."),
|
|
624
|
+
title: Optional[str] = typer.Option(None, "--title", help="Short title. Required unless --alert-id is given."),
|
|
625
|
+
alert_id: Optional[str] = typer.Option(None, "--alert-id", help="Link to an alert (inherits its severity)."),
|
|
626
|
+
severity: Optional[str] = typer.Option(None, "--severity", help=f"Severity for a standalone incident: {', '.join(_SEVERITIES)}."),
|
|
627
|
+
) -> None:
|
|
628
|
+
"""Open a manual incident (standalone, or linked to an alert) — rendered as a green card.
|
|
629
|
+
|
|
630
|
+
Needs `issues:create`. `--title` is required for a standalone incident (there's no parent
|
|
631
|
+
alert whose name it could borrow) and optional with `--alert-id`, where it defaults to the
|
|
632
|
+
alert's name. A missing `--title` or an invalid `--severity` → exit 2. With `--json`:
|
|
633
|
+
`{id, newly_opened, state}`.
|
|
634
|
+
|
|
635
|
+
Examples:
|
|
636
|
+
|
|
637
|
+
* `fp issues open --title "checkout 500s" --summary "manual page" --severity critical`
|
|
638
|
+
* `fp issues open --alert-id <id> --summary "paging on this again"`
|
|
639
|
+
"""
|
|
640
|
+
state: AppState = ctx.obj
|
|
641
|
+
if severity and severity not in _SEVERITIES:
|
|
642
|
+
raise typer.BadParameter(f"severity must be one of: {', '.join(_SEVERITIES)}.")
|
|
643
|
+
if not alert_id and not (title or "").strip():
|
|
644
|
+
raise typer.BadParameter("--title is required for a standalone issue (or pass --alert-id).")
|
|
645
|
+
cctx = require_auth(state)
|
|
646
|
+
try:
|
|
647
|
+
result = api.open_incident(cctx, summary=summary, alert_id=alert_id, severity=severity, title=title)
|
|
648
|
+
except (ApiError, ForbiddenError, NotFoundError) as exc:
|
|
649
|
+
_fail(state, exc, incident_id=alert_id or "")
|
|
650
|
+
_write.record_action("incident_opened", resource="incident", success=True)
|
|
651
|
+
if state.json:
|
|
652
|
+
output.emit_json(result)
|
|
653
|
+
return
|
|
654
|
+
# The open response is minimal ({id, newly_opened, state}); re-fetch the canonical incident
|
|
655
|
+
# for a richer card (its real severity — esp. a linked incident inheriting the alert's).
|
|
656
|
+
inc = None
|
|
657
|
+
try:
|
|
658
|
+
inc = api.get_incident(cctx, str(result.get("id", "")))
|
|
659
|
+
except Exception:
|
|
660
|
+
inc = None
|
|
661
|
+
sev = (inc.alert_severity if inc else None) or severity or ""
|
|
662
|
+
st = (inc.state if inc else None) or str(result.get("state", "") or "")
|
|
663
|
+
# Prefer the server's stored title — on the linked path it may have been
|
|
664
|
+
# defaulted to the alert's name rather than anything we sent.
|
|
665
|
+
hero = (inc.title if inc else None) or str(result.get("title", "") or "") or (title or "")
|
|
666
|
+
output.render_incident_opened(summary=summary, severity=sev, state=st, title=hero)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def register(app: typer.Typer) -> None:
|
|
670
|
+
inc = typer.Typer(
|
|
671
|
+
no_args_is_help=True,
|
|
672
|
+
rich_markup_mode="markdown",
|
|
673
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
674
|
+
help="Triage issues (list / count / show / ack / assign / resolve / close / archive / unarchive / clear / comment-* / subscribe* / open).",
|
|
675
|
+
)
|
|
676
|
+
inc.command("list", epilog=GLOBALS_EPILOG)(incidents_list)
|
|
677
|
+
inc.command("count", epilog=GLOBALS_EPILOG)(incidents_count)
|
|
678
|
+
inc.command("show", epilog=GLOBALS_EPILOG)(incidents_show)
|
|
679
|
+
inc.command("ack", epilog=GLOBALS_EPILOG)(incidents_ack)
|
|
680
|
+
inc.command("assign", epilog=GLOBALS_EPILOG)(incidents_assign)
|
|
681
|
+
inc.command("resolve", epilog=GLOBALS_EPILOG)(incidents_resolve)
|
|
682
|
+
inc.command("close", epilog=GLOBALS_EPILOG)(incidents_close)
|
|
683
|
+
inc.command("archive", epilog=GLOBALS_EPILOG)(incidents_archive)
|
|
684
|
+
inc.command("unarchive", epilog=GLOBALS_EPILOG)(incidents_unarchive)
|
|
685
|
+
inc.command("clear", epilog=GLOBALS_EPILOG)(incidents_clear)
|
|
686
|
+
inc.command("comment-list", epilog=GLOBALS_EPILOG)(incidents_comment_list)
|
|
687
|
+
inc.command("comment-add", epilog=GLOBALS_EPILOG)(incidents_comment_add)
|
|
688
|
+
inc.command("comment-delete", epilog=GLOBALS_EPILOG)(incidents_comment_delete)
|
|
689
|
+
inc.command("subscribers", epilog=GLOBALS_EPILOG)(incidents_subscribers)
|
|
690
|
+
inc.command("subscribe", epilog=GLOBALS_EPILOG)(incidents_subscribe)
|
|
691
|
+
inc.command("unsubscribe", epilog=GLOBALS_EPILOG)(incidents_unsubscribe)
|
|
692
|
+
inc.command("open", epilog=GLOBALS_EPILOG)(incidents_open)
|
|
693
|
+
app.add_typer(inc, name="issues")
|