diff --git a/puppy_kit/commands/incident.py b/puppy_kit/commands/incident.py index 2ba8fce..6c82f97 100644 --- a/puppy_kit/commands/incident.py +++ b/puppy_kit/commands/incident.py @@ -558,7 +558,6 @@ def create_incident(title, description, severity, team, assignee, customer_impac @incident.command(name="update") @click.argument("incident_id") @click.option("--title", default=None, help="New incident title") -@click.option("--status", default=None, type=click.Choice(STATUS_CHOICES), help="Incident status") @click.option( "--severity", default=None, type=click.Choice(SEVERITY_CHOICES), help="Incident severity" ) @@ -610,7 +609,6 @@ def create_incident(title, description, severity, team, assignee, customer_impac def update_incident( incident_id, title, - status, severity, assignee, summary, @@ -628,7 +626,12 @@ def update_incident( related_incidents, format, ): - """Update an existing incident.""" + """Update an existing incident. + + Note: Status transitions (active/stable/resolved) must be made via the dedicated + set-status command, not via this update command. Use `puppy incident set-status` + to change incident status. + """ from datadog_api_client.v2.model.incident_update_request import IncidentUpdateRequest from datadog_api_client.v2.model.incident_update_data import IncidentUpdateData from datadog_api_client.v2.model.incident_update_attributes import IncidentUpdateAttributes @@ -649,9 +652,9 @@ def update_incident( services, related_incidents, ] - if not any([title, status, severity, assignee] + field_opts): + if not any([title, severity, assignee] + field_opts): raise click.UsageError( - "No update fields specified. Use --title, --status, --severity, --assignee, or field options." + "No update fields specified. Use --title, --severity, --assignee, or field options." ) client = get_datadog_client() @@ -684,10 +687,6 @@ def update_incident( # Handle field updates via raw requests if any field options are provided field_data = {} - if status is not None: - # "state" is the Datadog custom-field key for incident status; - # IncidentUpdateAttributes does not expose it as a typed attribute. - field_data["state"] = {"type": "dropdown", "value": status} if summary is not None: field_data["summary"] = {"type": "textbox", "value": summary} if root_cause is not None: @@ -765,6 +764,52 @@ def update_incident( console.print(f"[bold]Assignee:[/bold] {assignee}") +@incident.command(name="set-status") +@click.argument("incident_id") +@click.argument("status", type=click.Choice(STATUS_CHOICES)) +@click.option( + "--format", type=click.Choice(["json", "table"]), default="table", help="Output format" +) +@handle_api_error +def set_incident_status(incident_id, status, format): + """Set the status of an incident (active, stable, or resolved). + + Status transitions must be made via this command — the update command does not + accept a status argument. + """ + config = load_config() + base_url = f"https://{config.site}/api/v2/incidents" + headers = { + "DD-API-KEY": config.api_key, + "DD-APPLICATION-KEY": config.app_key, + "Content-Type": "application/json", + } + patch_body = { + "data": { + "type": "incidents", + "id": incident_id, + "attributes": {"fields": {"state": {"type": "dropdown", "value": status}}}, + } + } + + with console.status(f"[cyan]Setting incident {incident_id} status to {status}...[/cyan]"): + try: + resp = requests.patch( + f"{base_url}/{incident_id}", + headers=headers, + json=patch_body, + timeout=30, + ) + resp.raise_for_status() + except requests.exceptions.RequestException as e: + raise click.ClickException(f"Status update failed: {e}") from e + + if format == "json": + click.echo(json.dumps({"id": incident_id, "status": status})) + else: + console.print(f"[green]Incident {incident_id} status set to {status}[/green]") + + @incident.command(name="delete") @click.argument("incident_id") @click.option("--confirm", "confirmed", is_flag=True, help="Skip confirmation prompt") diff --git a/puppy_kit/mcp/server.py b/puppy_kit/mcp/server.py index 1507b40..9c816e3 100644 --- a/puppy_kit/mcp/server.py +++ b/puppy_kit/mcp/server.py @@ -184,7 +184,6 @@ def dd_incidents_update( incident_id: str, title: str | None = None, severity: str | None = None, - status: str | None = None, assignee: str | None = None, summary: str | None = None, root_cause: str | None = None, @@ -200,20 +199,16 @@ def dd_incidents_update( services: list[str] | None = None, related_incidents: list[str] | None = None, ) -> str: - """Update an existing Datadog incident with title, severity, status, commander, and custom fields. + """Update an existing Datadog incident with title, severity, commander, and custom fields. - Use to progress an incident through its lifecycle and populate custom workflow fields. - At least one field must be provided. Typical flow: create with status 'active', set - 'stable' once contained, set 'resolved' once the fix is confirmed. Use custom fields - to document triage results, root cause, detection method, and related resources. - To close an incident always use status='resolved' here — do not use dd_incidents_delete - for real incidents. + Use to progress an incident and populate custom workflow fields. At least one field + must be provided. Use custom fields to document triage results, root cause, detection + method, and related resources. To change incident status use dd_incidents_set_status. Args: incident_id: The incident UUID to update. title: New incident title (optional). severity: New severity — SEV-1 through SEV-5 (optional). - status: 'active', 'stable', or 'resolved' (optional). assignee: Assignee name (e.g., 'muhammad', 'willem', 'jeong') to set as incident commander (optional). summary: Brief summary of the incident (optional). root_cause: Root cause description (optional). @@ -239,8 +234,6 @@ def dd_incidents_update( args += ["--title", title] if severity is not None: args += ["--severity", severity] - if status is not None: - args += ["--status", status] if assignee is not None: args += ["--assignee", assignee] if summary is not None: @@ -277,6 +270,30 @@ def dd_incidents_update( return result.output +@mcp.tool() +def dd_incidents_set_status(incident_id: str, status: str) -> str: + """Set the status of a Datadog incident. + + Use this to transition an incident through its lifecycle. Typical flow: create + with status 'active', set 'stable' once contained, set 'resolved' once the fix + is confirmed. + + Args: + incident_id: The incident UUID. + status: 'active', 'stable', or 'resolved'. + + Returns JSON with id and new status. + """ + from puppy_kit.commands.incident import set_incident_status + + result = CliRunner().invoke( + set_incident_status, + [incident_id, status, "--format", "json"], + catch_exceptions=False, + ) + return result.output + + @mcp.tool() def dd_incidents_get_fields(incident_id: str) -> str: """Return only the custom field values for a single Datadog incident. diff --git a/tests/commands/test_incident.py b/tests/commands/test_incident.py index 6a72fad..42531e1 100644 --- a/tests/commands/test_incident.py +++ b/tests/commands/test_incident.py @@ -392,7 +392,7 @@ def test_update_incident(self, mock_client, runner): mock_patch.return_value = Mock(raise_for_status=Mock()) result = runner.invoke( incident, - ["update", "inc-1", "--title", "Updated title", "--status", "stable"], + ["update", "inc-1", "--title", "Updated title"], ) assert result.exit_code == 0, f"Command failed: {result.output}" @@ -400,7 +400,6 @@ def test_update_incident(self, mock_client, runner): assert "updated" in result.output assert "Updated title" in result.output mock_client.incidents.update_incident.assert_called_once() - mock_patch.assert_called_once() def test_update_incident_severity_only(self, mock_client, runner): """Test updating only the severity of an incident.""" @@ -436,6 +435,44 @@ def test_update_incident_assignee(self, mock_client, runner): mock_client.incidents.update_incident.assert_called_once() +class TestSetStatusIncident: + def test_set_status_table(self, runner): + """Test setting incident status with table output.""" + mock_cfg = Mock(site="datadoghq.com", api_key="test-api-key", app_key="test-app-key") + with patch("puppy_kit.commands.incident.load_config", return_value=mock_cfg): + with patch("puppy_kit.commands.incident.requests.patch") as mock_patch: + mock_patch.return_value = Mock(raise_for_status=Mock()) + result = runner.invoke(incident, ["set-status", "inc-1", "stable"]) + + assert result.exit_code == 0, f"Command failed: {result.output}" + assert "inc-1" in result.output + assert "stable" in result.output + mock_patch.assert_called_once() + call_kwargs = mock_patch.call_args + body = call_kwargs[1]["json"] + assert body["data"]["attributes"]["fields"]["state"]["value"] == "stable" + + def test_set_status_json(self, runner): + """Test setting incident status with JSON output.""" + mock_cfg = Mock(site="datadoghq.com", api_key="test-api-key", app_key="test-app-key") + with patch("puppy_kit.commands.incident.load_config", return_value=mock_cfg): + with patch("puppy_kit.commands.incident.requests.patch") as mock_patch: + mock_patch.return_value = Mock(raise_for_status=Mock()) + result = runner.invoke( + incident, ["set-status", "inc-1", "resolved", "--format", "json"] + ) + + assert result.exit_code == 0, f"Command failed: {result.output}" + data = json.loads(result.output) + assert data["id"] == "inc-1" + assert data["status"] == "resolved" + + def test_set_status_invalid_status(self, runner): + """Test that an invalid status value is rejected.""" + result = runner.invoke(incident, ["set-status", "inc-1", "pending"]) + assert result.exit_code != 0 + + class TestDeleteIncident: def test_delete_incident_with_confirm(self, mock_client, runner): """Test deleting an incident with --confirm flag."""