From 242789b827fecf83f109e8c76de4d469ddcac55e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 11:37:13 +0000 Subject: [PATCH 01/14] chore(deps): upgrade datadog-api-client to master HEAD 2026-05-29 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the SDK pin from v0.30.0 (aa0c8416) to the latest master commit (d4954b11) to pick up all changes through 2026-05-29. Breaking changes handled: - Remove deprecated incident teams endpoints (SDK #1572): removes pup incidents teams command and 5 unstable ops (167 → 162) Co-Authored-By: Claude --- Cargo.lock | 4 +- Cargo.toml | 4 +- src/client.rs | 8 +--- src/commands/incidents.rs | 78 --------------------------------------- src/main.rs | 43 --------------------- 5 files changed, 5 insertions(+), 132 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 19570a1..c9d83a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -829,8 +829,8 @@ checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "datadog-api-client" -version = "0.30.0" -source = "git+https://github.com/DataDog/datadog-api-client-rust?rev=aa0c8416e3af27038cf6a17e74ff1bf11d6bc1a6#aa0c8416e3af27038cf6a17e74ff1bf11d6bc1a6" +version = "0.31.0" +source = "git+https://github.com/DataDog/datadog-api-client-rust?rev=d4954b117c5451c0a8932dd0ad0db450fcb0989c#d4954b117c5451c0a8932dd0ad0db450fcb0989c" dependencies = [ "async-stream", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 509959d..168643c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,9 +127,9 @@ ssh-key = { version = "0.6", features = ["p256", "std"], optional = true } futures = { version = "0.3", optional = true } tokio-util = { version = "0.7", features = ["compat", "io"], optional = true } -# Datadog API client — pinned to 0.30.0 release tag +# Datadog API client — pinned to master HEAD 2026-05-29 # Use default-features = false; feature sets are activated per-target via features above -datadog-api-client = { git = "https://github.com/DataDog/datadog-api-client-rust", rev = "aa0c8416e3af27038cf6a17e74ff1bf11d6bc1a6", optional = true, default-features = false } +datadog-api-client = { git = "https://github.com/DataDog/datadog-api-client-rust", rev = "d4954b117c5451c0a8932dd0ad0db450fcb0989c", optional = true, default-features = false } # HTTP middleware (version-matched to DD client; compiles on all targets) reqwest-middleware = "0.5" diff --git a/src/client.rs b/src/client.rs index bc8f74d..42cc95d 100644 --- a/src/client.rs +++ b/src/client.rs @@ -244,12 +244,6 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.get_incident_postmortem_template", "v2.list_incident_postmortem_templates", "v2.update_incident_postmortem_template", - // Incident Teams (5) - "v2.create_incident_team", - "v2.delete_incident_team", - "v2.get_incident_team", - "v2.list_incident_teams", - "v2.update_incident_team", // Incident Services (5) "v2.create_incident_service", "v2.delete_incident_service", @@ -1279,7 +1273,7 @@ mod tests { #[test] fn test_unstable_ops_count() { - assert_eq!(UNSTABLE_OPS.len(), 167); + assert_eq!(UNSTABLE_OPS.len(), 162); } #[test] diff --git a/src/commands/incidents.rs b/src/commands/incidents.rs index 18289bc..4d13bfe 100644 --- a/src/commands/incidents.rs +++ b/src/commands/incidents.rs @@ -2,9 +2,6 @@ use anyhow::{bail, Result}; use datadog_api_client::datadogV2::api_incident_services::{ GetIncidentServiceOptionalParams, IncidentServicesAPI, ListIncidentServicesOptionalParams, }; -use datadog_api_client::datadogV2::api_incident_teams::{ - GetIncidentTeamOptionalParams, IncidentTeamsAPI, ListIncidentTeamsOptionalParams, -}; use datadog_api_client::datadogV2::api_incidents::{ CreateGlobalIncidentHandleOptionalParams, GetIncidentOptionalParams, ImportIncidentOptionalParams, IncidentsAPI, ListGlobalIncidentHandlesOptionalParams, @@ -226,64 +223,6 @@ pub async fn postmortem_templates_delete(cfg: &Config, template_id: &str) -> Res Ok(()) } -// --------------------------------------------------------------------------- -// Incident teams -// --------------------------------------------------------------------------- - -fn make_teams_api(cfg: &Config) -> IncidentTeamsAPI { - crate::make_api!(IncidentTeamsAPI, cfg) -} - -pub async fn teams_list(cfg: &Config) -> Result<()> { - let api = make_teams_api(cfg); - let resp = api - .list_incident_teams(ListIncidentTeamsOptionalParams::default()) - .await - .map_err(|e| anyhow::anyhow!("failed to list incident teams: {:?}", e))?; - formatter::output(cfg, &resp) -} - -pub async fn teams_get(cfg: &Config, team_id: &str) -> Result<()> { - let api = make_teams_api(cfg); - let resp = api - .get_incident_team( - team_id.to_string(), - GetIncidentTeamOptionalParams::default(), - ) - .await - .map_err(|e| anyhow::anyhow!("failed to get incident team: {:?}", e))?; - formatter::output(cfg, &resp) -} - -pub async fn teams_create(cfg: &Config, file: &str) -> Result<()> { - let body = util::read_json_file(file)?; - let api = make_teams_api(cfg); - let resp = api - .create_incident_team(body) - .await - .map_err(|e| anyhow::anyhow!("failed to create incident team: {:?}", e))?; - formatter::output(cfg, &resp) -} - -pub async fn teams_update(cfg: &Config, team_id: &str, file: &str) -> Result<()> { - let body = util::read_json_file(file)?; - let api = make_teams_api(cfg); - let resp = api - .update_incident_team(team_id.to_string(), body) - .await - .map_err(|e| anyhow::anyhow!("failed to update incident team: {:?}", e))?; - formatter::output(cfg, &resp) -} - -pub async fn teams_delete(cfg: &Config, team_id: &str) -> Result<()> { - let api = make_teams_api(cfg); - api.delete_incident_team(team_id.to_string()) - .await - .map_err(|e| anyhow::anyhow!("failed to delete incident team: {:?}", e))?; - println!("Incident team {team_id} deleted."); - Ok(()) -} - // --------------------------------------------------------------------------- // Incident services // --------------------------------------------------------------------------- @@ -539,23 +478,6 @@ mod tests { cleanup_env(); } - #[tokio::test] - async fn test_incident_teams_list() { - let _lock = lock_env().await; - std::env::set_var("DD_TOKEN_STORAGE", "file"); - let mut server = mockito::Server::new_async().await; - let cfg = test_config(&server.url()); - let _mock = mock_any(&mut server, "GET", r#"{"data":[],"meta":{}}"#).await; - let result = super::teams_list(&cfg).await; - assert!( - result.is_ok(), - "incident teams list failed: {:?}", - result.err() - ); - cleanup_env(); - std::env::remove_var("DD_TOKEN_STORAGE"); - } - #[tokio::test] async fn test_incident_services_list() { let _lock = lock_env().await; diff --git a/src/main.rs b/src/main.rs index dae432c..8fbdede 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3096,11 +3096,6 @@ enum IncidentActions { #[command(subcommand)] action: IncidentPostmortemActions, }, - /// Manage incident teams - Teams { - #[command(subcommand)] - action: IncidentTeamActions, - }, /// Manage incident services Services { #[command(subcommand)] @@ -3113,27 +3108,6 @@ enum IncidentActions { }, } -#[derive(Subcommand)] -enum IncidentTeamActions { - /// List incident teams - List, - /// Get incident team details - Get { team_id: String }, - /// Create an incident team from JSON - Create { - #[arg(long, help = "JSON file with team data (required)")] - file: String, - }, - /// Update an incident team - Update { - team_id: String, - #[arg(long, help = "JSON file with team data (required)")] - file: String, - }, - /// Delete an incident team - Delete { team_id: String }, -} - #[derive(Subcommand)] enum IncidentServiceActions { /// List incident services @@ -10990,23 +10964,6 @@ async fn main_inner() -> anyhow::Result<()> { .await?; } }, - IncidentActions::Teams { action } => match action { - IncidentTeamActions::List => { - commands::incidents::teams_list(&cfg).await?; - } - IncidentTeamActions::Get { team_id } => { - commands::incidents::teams_get(&cfg, &team_id).await?; - } - IncidentTeamActions::Create { file } => { - commands::incidents::teams_create(&cfg, &file).await?; - } - IncidentTeamActions::Update { team_id, file } => { - commands::incidents::teams_update(&cfg, &team_id, &file).await?; - } - IncidentTeamActions::Delete { team_id } => { - commands::incidents::teams_delete(&cfg, &team_id).await?; - } - }, IncidentActions::Services { action } => match action { IncidentServiceActions::List => { commands::incidents::services_list(&cfg).await?; From 0effe439e050b10ee987991c8b875561ac698534 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 11:53:34 +0000 Subject: [PATCH 02/14] feat(llm-obs): add dataset batch-update, clone, restore subcommands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap three new SDK endpoints introduced in datadog-api-client-rust PR #1655 (available at rev d4954b11 picked up by chore/upgrade-dd-sdk-to-master): - `pup llm-obs datasets batch-update` — batch insert/update/delete records via LLMObsDatasetBatchUpdateRequest / batch_update_llm_obs_dataset - `pup llm-obs datasets clone` — clone a dataset into a new dataset via LLMObsDatasetCloneRequest / clone_llm_obs_dataset - `pup llm-obs datasets restore` — restore a dataset to a previous version via LLMObsDatasetRestoreVersionRequest / restore_llm_obs_dataset_version Changes: - src/commands/llm_obs.rs: add datasets_batch_update, datasets_clone, datasets_restore functions; add 6 tests (happy-path + error for each) - src/main.rs: add BatchUpdate/Clone/Restore variants to LlmObsDatasetsActions and dispatch arms - src/client.rs: add 3 new unstable op IDs; update count assertion 162 → 165 - docs/COMMANDS.md: update llm-obs datasets command listing Co-Authored-By: Claude https://claude.ai/code/session_01MaHfvEkY66q99gwcPZi4Xc --- docs/COMMANDS.md | 4 +- src/client.rs | 7 +- src/commands/llm_obs.rs | 214 +++++++++++++++++++++++++++++++++++++++- src/main.rs | 56 +++++++++++ 4 files changed, 274 insertions(+), 7 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0948138..6f0dbd7 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -66,7 +66,7 @@ pup [options] # Nested commands | data-deletion | requests (list, create, cancel) | src/commands/data_deletion.rs | ✅ | | data-governance | scanner-rules (list) | src/commands/data_governance.rs | ✅ | | obs-pipelines | list, get, create, update, delete, validate | src/commands/obs_pipelines.rs | ✅ | -| llm-obs | projects (create, list), experiments (create, list, update, delete, summary, events (list, get), metric-values, dimension-values), datasets (create, list), spans (search) | src/commands/llm_obs.rs | ✅ | +| llm-obs | projects (create, list), experiments (create, list, update, delete, summary, events (list, get), metric-values, dimension-values), datasets (create, list, batch-update, clone, restore), spans (search) | src/commands/llm_obs.rs | ✅ | | reference-tables | list, get, create, batch-query | src/commands/reference_tables.rs | ✅ | | network | flows list, devices (list, get, interfaces, tags), interfaces (list, update) | src/commands/network.rs | ✅ | | cloud | aws, gcp, azure, oci | src/commands/cloud.rs | ✅ | @@ -243,7 +243,7 @@ Available on all commands: ### v0.28.0 — New Command Groups and Full Pipeline Implementation -- ✅ **llm-obs** (new) — LLM Observability: projects (create, list), experiments (create, list, update, delete, summary, events (list, get), metric-values, dimension-values), datasets (create, list), spans (search) +- ✅ **llm-obs** (new) — LLM Observability: projects (create, list), experiments (create, list, update, delete, summary, events (list, get), metric-values, dimension-values), datasets (create, list, batch-update, clone, restore), spans (search) - ✅ **reference-tables** (new) — Reference table management (list, get, create, batch-query) - ✅ **obs-pipelines** (upgraded from placeholder) — Full CRUD: list, get, create, update, delete, validate - **costs** — Added cloud cost configs: `aws-config`, `azure-config`, `gcp-config` (list, get, create, delete each) diff --git a/src/client.rs b/src/client.rs index 42cc95d..fefb09f 100644 --- a/src/client.rs +++ b/src/client.rs @@ -350,7 +350,7 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.delete_aws_cloud_auth_persona_mapping", "v2.get_aws_cloud_auth_persona_mapping", "v2.list_aws_cloud_auth_persona_mappings", - // LLM Observability (18) + // LLM Observability (21) "v2.create_llm_obs_project", "v2.list_llm_obs_projects", "v2.create_llm_obs_experiment", @@ -359,6 +359,9 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.delete_llm_obs_experiments", "v2.create_llm_obs_dataset", "v2.list_llm_obs_datasets", + "v2.batch_update_llm_obs_dataset", + "v2.clone_llm_obs_dataset", + "v2.restore_llm_obs_dataset_version", "v2.create_llm_obs_annotation_queue", "v2.list_llm_obs_annotation_queues", "v2.update_llm_obs_annotation_queue", @@ -1273,7 +1276,7 @@ mod tests { #[test] fn test_unstable_ops_count() { - assert_eq!(UNSTABLE_OPS.len(), 162); + assert_eq!(UNSTABLE_OPS.len(), 165); } #[test] diff --git a/src/commands/llm_obs.rs b/src/commands/llm_obs.rs index 6532c62..216463b 100644 --- a/src/commands/llm_obs.rs +++ b/src/commands/llm_obs.rs @@ -4,9 +4,11 @@ use datadog_api_client::datadogV2::api_llm_observability::{ }; use datadog_api_client::datadogV2::model::{ LLMObsAnnotationQueueInteractionsRequest, LLMObsAnnotationQueueRequest, - LLMObsAnnotationQueueUpdateRequest, LLMObsCustomEvalConfigUpdateRequest, LLMObsDatasetRequest, - LLMObsDeleteAnnotationQueueInteractionsRequest, LLMObsDeleteExperimentsRequest, - LLMObsExperimentRequest, LLMObsExperimentUpdateRequest, LLMObsProjectRequest, + LLMObsAnnotationQueueUpdateRequest, LLMObsCustomEvalConfigUpdateRequest, + LLMObsDatasetBatchUpdateRequest, LLMObsDatasetCloneRequest, LLMObsDatasetRequest, + LLMObsDatasetRestoreVersionRequest, LLMObsDeleteAnnotationQueueInteractionsRequest, + LLMObsDeleteExperimentsRequest, LLMObsExperimentRequest, LLMObsExperimentUpdateRequest, + LLMObsProjectRequest, }; use crate::client; @@ -108,6 +110,51 @@ pub async fn datasets_list(cfg: &Config, project_id: &str) -> Result<()> { formatter::output(cfg, &resp) } +pub async fn datasets_batch_update( + cfg: &Config, + project_id: &str, + dataset_id: &str, + file: &str, +) -> Result<()> { + let body: LLMObsDatasetBatchUpdateRequest = util::read_json_file(file)?; + let api = make_api(cfg); + let resp = api + .batch_update_llm_obs_dataset(project_id.to_string(), dataset_id.to_string(), body) + .await + .map_err(|e| anyhow::anyhow!("failed to batch update dataset records: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn datasets_clone( + cfg: &Config, + project_id: &str, + dataset_id: &str, + file: &str, +) -> Result<()> { + let body: LLMObsDatasetCloneRequest = util::read_json_file(file)?; + let api = make_api(cfg); + let resp = api + .clone_llm_obs_dataset(project_id.to_string(), dataset_id.to_string(), body) + .await + .map_err(|e| anyhow::anyhow!("failed to clone dataset: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn datasets_restore( + cfg: &Config, + project_id: &str, + dataset_id: &str, + file: &str, +) -> Result<()> { + let body: LLMObsDatasetRestoreVersionRequest = util::read_json_file(file)?; + let api = make_api(cfg); + let resp = api + .restore_llm_obs_dataset_version(project_id.to_string(), dataset_id.to_string(), body) + .await + .map_err(|e| anyhow::anyhow!("failed to restore dataset version: {e:?}"))?; + formatter::output(cfg, &resp) +} + // ---- Experiment analytics (no typed equivalent — unstable MCP endpoints) ---- pub async fn experiments_summary(cfg: &Config, experiment_id: &str) -> Result<()> { @@ -2637,4 +2684,165 @@ mod tests { assert!(result.is_ok(), "spans_search failed: {:?}", result.err()); cleanup_env(); } + + // ---- datasets_batch_update ---- + + #[tokio::test] + async fn test_llm_obs_datasets_batch_update() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let tmp = write_temp_json( + "pup_test_ds_batch_update.json", + r#"{"data":{"type":"dataset_batch_update","attributes":{"upsert":[],"delete":[]}}}"#, + ); + let resp_body = r#"{"data":{"type":"dataset_records_mutation","attributes":{"upserted_ids":[],"deleted_ids":[]}}}"#; + let _mock = mock_any(&mut server, "POST", resp_body).await; + + let result = + super::datasets_batch_update(&cfg, "proj-1", "ds-1", tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "datasets_batch_update failed: {:?}", + result.err() + ); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_llm_obs_datasets_batch_update_400() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let tmp = write_temp_json( + "pup_test_ds_batch_update_400.json", + r#"{"data":{"type":"dataset_batch_update","attributes":{"upsert":[],"delete":[]}}}"#, + ); + let _mock = server + .mock("POST", mockito::Matcher::Any) + .match_query(mockito::Matcher::Any) + .with_status(400) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["bad request"]}"#) + .create_async() + .await; + + let result = + super::datasets_batch_update(&cfg, "proj-1", "ds-1", tmp.to_str().unwrap()).await; + assert!(result.is_err(), "should fail on 400"); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + // ---- datasets_clone ---- + + #[tokio::test] + async fn test_llm_obs_datasets_clone() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let tmp = write_temp_json( + "pup_test_ds_clone.json", + r#"{"data":{"type":"dataset_clone","attributes":{"name":"cloned-dataset"}}}"#, + ); + let resp_body = r#"{"data":{"id":"ds-2","type":"datasets","attributes":{"name":"cloned-dataset","description":null,"metadata":null,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","current_version":1}}}"#; + let _mock = mock_any(&mut server, "POST", resp_body).await; + + let result = super::datasets_clone(&cfg, "proj-1", "ds-1", tmp.to_str().unwrap()).await; + assert!(result.is_ok(), "datasets_clone failed: {:?}", result.err()); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_llm_obs_datasets_clone_404() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let tmp = write_temp_json( + "pup_test_ds_clone_404.json", + r#"{"data":{"type":"dataset_clone","attributes":{"name":"cloned-dataset"}}}"#, + ); + let _mock = server + .mock("POST", mockito::Matcher::Any) + .match_query(mockito::Matcher::Any) + .with_status(404) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + + let result = + super::datasets_clone(&cfg, "proj-1", "ds-missing", tmp.to_str().unwrap()).await; + assert!(result.is_err(), "should fail on 404"); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + // ---- datasets_restore ---- + + #[tokio::test] + async fn test_llm_obs_datasets_restore() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let tmp = write_temp_json( + "pup_test_ds_restore.json", + r#"{"data":{"type":"dataset_restore","attributes":{"version":2}}}"#, + ); + let resp_body = r#"{"data":{"id":"ds-1","type":"datasets","attributes":{"name":"my-dataset","description":null,"metadata":null,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","current_version":2}}}"#; + let _mock = mock_any(&mut server, "POST", resp_body).await; + + let result = super::datasets_restore(&cfg, "proj-1", "ds-1", tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "datasets_restore failed: {:?}", + result.err() + ); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_llm_obs_datasets_restore_400() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let tmp = write_temp_json( + "pup_test_ds_restore_400.json", + r#"{"data":{"type":"dataset_restore","attributes":{"version":99}}}"#, + ); + let _mock = server + .mock("POST", mockito::Matcher::Any) + .match_query(mockito::Matcher::Any) + .with_status(400) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["invalid version"]}"#) + .create_async() + .await; + + let result = super::datasets_restore(&cfg, "proj-1", "ds-1", tmp.to_str().unwrap()).await; + assert!(result.is_err(), "should fail on 400"); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } } diff --git a/src/main.rs b/src/main.rs index 8fbdede..fc8ceac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8682,6 +8682,33 @@ enum LlmObsDatasetsActions { #[arg(long, help = "Project ID (required)")] project_id: String, }, + /// Batch insert, update, and delete records in a dataset + BatchUpdate { + #[arg(long, help = "Project ID (required)")] + project_id: String, + #[arg(long, help = "Dataset ID (required)")] + dataset_id: String, + #[arg(long, help = "JSON file with batch update body (required)")] + file: String, + }, + /// Clone a dataset into a new dataset + Clone { + #[arg(long, help = "Project ID (required)")] + project_id: String, + #[arg(long, help = "Dataset ID to clone (required)")] + dataset_id: String, + #[arg(long, help = "JSON file with clone body (required)")] + file: String, + }, + /// Restore a dataset to a previous version + Restore { + #[arg(long, help = "Project ID (required)")] + project_id: String, + #[arg(long, help = "Dataset ID (required)")] + dataset_id: String, + #[arg(long, help = "JSON file with restore version body (required)")] + file: String, + }, } #[derive(Subcommand)] @@ -14661,6 +14688,35 @@ async fn main_inner() -> anyhow::Result<()> { LlmObsDatasetsActions::List { project_id } => { commands::llm_obs::datasets_list(&cfg, &project_id).await?; } + LlmObsDatasetsActions::BatchUpdate { + project_id, + dataset_id, + file, + } => { + commands::llm_obs::datasets_batch_update( + &cfg, + &project_id, + &dataset_id, + &file, + ) + .await?; + } + LlmObsDatasetsActions::Clone { + project_id, + dataset_id, + file, + } => { + commands::llm_obs::datasets_clone(&cfg, &project_id, &dataset_id, &file) + .await?; + } + LlmObsDatasetsActions::Restore { + project_id, + dataset_id, + file, + } => { + commands::llm_obs::datasets_restore(&cfg, &project_id, &dataset_id, &file) + .await?; + } }, LlmObsActions::Spans { action } => match action { LlmObsSpansActions::Search { From e8f8050d79fa98f169e7b140ec032f2be85a2c97 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 11:55:08 +0000 Subject: [PATCH 03/14] feat(error-tracking): add --state, --team, --assignee filters to issues search Exposes new SDK filter params from PRs #1568 and #1480: - --state: OPEN, ACKNOWLEDGED, RESOLVED, IGNORED, EXCLUDED - --team : filter by team UUID assignee - --assignee : filter by user UUID assignee Co-Authored-By: Claude --- docs/COMMANDS.md | 10 ++- src/commands/error_tracking.rs | 156 ++++++++++++++++++++++++++++++++- src/main.rs | 15 +++- 3 files changed, 178 insertions(+), 3 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0948138..4851e55 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -172,7 +172,7 @@ pup infrastructure hosts list ### Development & Quality - **cicd** - CI/CD visibility (pipelines, events, tests, dora, flaky-tests) - **code-coverage** - Code coverage summaries (branch, commit) -- **error-tracking** - Error management (issues search, issues get) +- **error-tracking** - Error management (issues search, issues get); search supports `--state`, `--team`, `--assignee` filters - **scorecards** - Service quality (rules, outcomes) - **service-catalog** - Service registry (list, get) - **idp** - Service Catalog agent access (assist, find, owner, deps, register) @@ -222,6 +222,14 @@ Available on all commands: ## Recent Enhancements +### v0.64.x — Error Tracking Issue Filters (SDK PRs #1568, #1480) + +- **error-tracking issues search** — new optional filter flags: + - `--state ` — filter by issue state: `OPEN`, `ACKNOWLEDGED`, `RESOLVED`, `IGNORED`, `EXCLUDED` + - `--team ` — filter by team UUID assignee + - `--assignee ` — filter by user UUID assignee + - These flags are independent of the existing `--track`/`--persona` mutual exclusion + ### v0.34.1 — ACP Server (Datadog AI Agent Integration) - ✅ **acp** (new) — Local ACP + OpenAI-compatible server that proxies to Datadog Bits AI diff --git a/src/commands/error_tracking.rs b/src/commands/error_tracking.rs index 00dce41..75ca9bf 100644 --- a/src/commands/error_tracking.rs +++ b/src/commands/error_tracking.rs @@ -3,7 +3,7 @@ use datadog_api_client::datadogV2::api_error_tracking::{ ErrorTrackingAPI, GetIssueOptionalParams, SearchIssuesOptionalParams, }; use datadog_api_client::datadogV2::model::{ - IssuesSearchRequest, IssuesSearchRequestData, IssuesSearchRequestDataAttributes, + IssueState, IssuesSearchRequest, IssuesSearchRequestData, IssuesSearchRequestDataAttributes, IssuesSearchRequestDataAttributesOrderBy, IssuesSearchRequestDataAttributesPersona, IssuesSearchRequestDataAttributesTrack, IssuesSearchRequestDataType, }; @@ -22,6 +22,9 @@ pub async fn issues_search( order_by: String, track: Option, persona: Option, + state: Option, + team: Option, + assignee: Option, ) -> Result<()> { let api = crate::make_api!(ErrorTrackingAPI, cfg); @@ -66,6 +69,31 @@ pub async fn issues_search( }; attrs = attrs.persona(persona_value); } + if let Some(ref s) = state { + let state_value = match s.to_uppercase().as_str() { + "OPEN" => IssueState::OPEN, + "ACKNOWLEDGED" => IssueState::ACKNOWLEDGED, + "RESOLVED" => IssueState::RESOLVED, + "IGNORED" => IssueState::IGNORED, + "EXCLUDED" => IssueState::EXCLUDED, + other => anyhow::bail!( + "invalid --state value '{}': must be OPEN, ACKNOWLEDGED, RESOLVED, IGNORED, or EXCLUDED", + other + ), + }; + attrs = attrs.states(vec![state_value]); + } + if let Some(ref t) = team { + let team_id = uuid::Uuid::parse_str(t) + .map_err(|_| anyhow::anyhow!("invalid --team value '{}': must be a valid UUID", t))?; + attrs = attrs.team_ids(vec![team_id]); + } + if let Some(ref a) = assignee { + let assignee_id = uuid::Uuid::parse_str(a).map_err(|_| { + anyhow::anyhow!("invalid --assignee value '{}': must be a valid UUID", a) + })?; + attrs = attrs.assignee_ids(vec![assignee_id]); + } let data = IssuesSearchRequestData::new(attrs, IssuesSearchRequestDataType::SEARCH_REQUEST); let body = IssuesSearchRequest::new(data); let params = SearchIssuesOptionalParams::default(); @@ -119,6 +147,9 @@ mod tests { "TOTAL_COUNT".into(), Some("trace".into()), None, + None, + None, + None, ) .await; cleanup_env(); @@ -139,6 +170,9 @@ mod tests { "TOTAL_COUNT".into(), None, Some("BROWSER".into()), + None, + None, + None, ) .await; cleanup_env(); @@ -159,6 +193,9 @@ mod tests { "TOTAL_COUNT".into(), Some("RUM".into()), None, + None, + None, + None, ) .await; cleanup_env(); @@ -176,6 +213,9 @@ mod tests { "INVALID".into(), Some("trace".into()), None, + None, + None, + None, ) .await; assert!(result.is_err()); @@ -185,6 +225,102 @@ mod tests { .contains("invalid --order-by value")); } + #[tokio::test] + async fn test_issues_search_with_state() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data": []}"#).await; + let result = super::issues_search( + &cfg, + None, + 10, + "1d".into(), + "now".into(), + "TOTAL_COUNT".into(), + Some("trace".into()), + None, + Some("OPEN".into()), + None, + None, + ) + .await; + assert!(result.is_ok()); + cleanup_env(); + } + + #[tokio::test] + async fn test_issues_search_invalid_state() { + let cfg = test_config("http://unused.local"); + let result = super::issues_search( + &cfg, + None, + 10, + "1d".into(), + "now".into(), + "TOTAL_COUNT".into(), + Some("trace".into()), + None, + Some("BADSTATE".into()), + None, + None, + ) + .await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid --state value")); + } + + #[tokio::test] + async fn test_issues_search_invalid_team_uuid() { + let cfg = test_config("http://unused.local"); + let result = super::issues_search( + &cfg, + None, + 10, + "1d".into(), + "now".into(), + "TOTAL_COUNT".into(), + Some("trace".into()), + None, + None, + Some("not-a-uuid".into()), + None, + ) + .await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid --team value")); + } + + #[tokio::test] + async fn test_issues_search_invalid_assignee_uuid() { + let cfg = test_config("http://unused.local"); + let result = super::issues_search( + &cfg, + None, + 10, + "1d".into(), + "now".into(), + "TOTAL_COUNT".into(), + Some("trace".into()), + None, + None, + None, + Some("not-a-uuid".into()), + ) + .await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid --assignee value")); + } + #[test] fn test_error_tracking_clap_mutual_exclusivity() { let result = crate::Cli::command().try_get_matches_from([ @@ -216,4 +352,22 @@ mod tests { "expected error when neither --track nor --persona is provided" ); } + + #[test] + fn test_error_tracking_clap_state_accepted() { + let result = crate::Cli::command().try_get_matches_from([ + "pup", + "error-tracking", + "issues", + "search", + "--track", + "trace", + "--state", + "OPEN", + ]); + assert!( + result.is_ok(), + "expected success when --state is provided alongside --track" + ); + } } diff --git a/src/main.rs b/src/main.rs index 8fbdede..bb3565d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6879,6 +6879,15 @@ enum ErrorTrackingIssueActions { help = "Client persona filter: ALL, BROWSER, MOBILE, or BACKEND" )] persona: Option, + #[arg( + long, + help = "Filter by issue state: OPEN, ACKNOWLEDGED, RESOLVED, IGNORED, EXCLUDED" + )] + state: Option, + #[arg(long, help = "Filter by team UUID assignee")] + team: Option, + #[arg(long, help = "Filter by user UUID assignee")] + assignee: Option, }, /// Get issue details Get { issue_id: String }, @@ -13045,9 +13054,13 @@ async fn main_inner() -> anyhow::Result<()> { order_by, track, persona, + state, + team, + assignee, } => { commands::error_tracking::issues_search( - &cfg, query, limit, from, to, order_by, track, persona, + &cfg, query, limit, from, to, order_by, track, persona, state, team, + assignee, ) .await?; } From 7c2a872d97d0943c26e56f33e77920f90e83d557 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:19:55 +0000 Subject: [PATCH 04/14] fix(llm-obs): drop unit-valued binding in datasets_restore restore_llm_obs_dataset_version returns no body; binding its unit result tripped clippy::let_unit_value (-D warnings) and broke the Check/Test and Windows cross-compile jobs. Follow the delete pattern: call, then print a confirmation. Co-Authored-By: Claude --- src/commands/llm_obs.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/llm_obs.rs b/src/commands/llm_obs.rs index 216463b..40004f7 100644 --- a/src/commands/llm_obs.rs +++ b/src/commands/llm_obs.rs @@ -148,11 +148,11 @@ pub async fn datasets_restore( ) -> Result<()> { let body: LLMObsDatasetRestoreVersionRequest = util::read_json_file(file)?; let api = make_api(cfg); - let resp = api - .restore_llm_obs_dataset_version(project_id.to_string(), dataset_id.to_string(), body) + api.restore_llm_obs_dataset_version(project_id.to_string(), dataset_id.to_string(), body) .await .map_err(|e| anyhow::anyhow!("failed to restore dataset version: {e:?}"))?; - formatter::output(cfg, &resp) + println!("Dataset {dataset_id} restored."); + Ok(()) } // ---- Experiment analytics (no typed equivalent — unstable MCP endpoints) ---- From 29f11a3616cb48be1d09482988ea3af58517b7e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 12:44:44 +0000 Subject: [PATCH 05/14] test(llm-obs): fix dataset request bodies to match SDK schema The batch-update/clone/restore tests built request JSON with stale field names (type "dataset_*", missing required id, wrong attribute keys), so util::read_json_file failed before the API call and the happy-path asserts panicked. Align bodies with the real SDK models: - data.id required; data.type is "datasets" - batch-update attrs use insert_records/delete_records - restore attr is dataset_version (not version) - batch-update response data is an array (LLMObsDatasetRecordsMutationResponse) Co-Authored-By: Claude --- src/commands/llm_obs.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/commands/llm_obs.rs b/src/commands/llm_obs.rs index 40004f7..af05a95 100644 --- a/src/commands/llm_obs.rs +++ b/src/commands/llm_obs.rs @@ -2696,9 +2696,9 @@ mod tests { let tmp = write_temp_json( "pup_test_ds_batch_update.json", - r#"{"data":{"type":"dataset_batch_update","attributes":{"upsert":[],"delete":[]}}}"#, + r#"{"data":{"id":"ds-1","type":"datasets","attributes":{"insert_records":[],"delete_records":[]}}}"#, ); - let resp_body = r#"{"data":{"type":"dataset_records_mutation","attributes":{"upserted_ids":[],"deleted_ids":[]}}}"#; + let resp_body = r#"{"data":[]}"#; let _mock = mock_any(&mut server, "POST", resp_body).await; let result = @@ -2722,7 +2722,7 @@ mod tests { let tmp = write_temp_json( "pup_test_ds_batch_update_400.json", - r#"{"data":{"type":"dataset_batch_update","attributes":{"upsert":[],"delete":[]}}}"#, + r#"{"data":{"id":"ds-1","type":"datasets","attributes":{"insert_records":[],"delete_records":[]}}}"#, ); let _mock = server .mock("POST", mockito::Matcher::Any) @@ -2752,7 +2752,7 @@ mod tests { let tmp = write_temp_json( "pup_test_ds_clone.json", - r#"{"data":{"type":"dataset_clone","attributes":{"name":"cloned-dataset"}}}"#, + r#"{"data":{"id":"ds-1","type":"datasets","attributes":{"name":"cloned-dataset"}}}"#, ); let resp_body = r#"{"data":{"id":"ds-2","type":"datasets","attributes":{"name":"cloned-dataset","description":null,"metadata":null,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","current_version":1}}}"#; let _mock = mock_any(&mut server, "POST", resp_body).await; @@ -2773,7 +2773,7 @@ mod tests { let tmp = write_temp_json( "pup_test_ds_clone_404.json", - r#"{"data":{"type":"dataset_clone","attributes":{"name":"cloned-dataset"}}}"#, + r#"{"data":{"id":"ds-1","type":"datasets","attributes":{"name":"cloned-dataset"}}}"#, ); let _mock = server .mock("POST", mockito::Matcher::Any) @@ -2803,7 +2803,7 @@ mod tests { let tmp = write_temp_json( "pup_test_ds_restore.json", - r#"{"data":{"type":"dataset_restore","attributes":{"version":2}}}"#, + r#"{"data":{"id":"ds-1","type":"datasets","attributes":{"dataset_version":2}}}"#, ); let resp_body = r#"{"data":{"id":"ds-1","type":"datasets","attributes":{"name":"my-dataset","description":null,"metadata":null,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","current_version":2}}}"#; let _mock = mock_any(&mut server, "POST", resp_body).await; @@ -2828,7 +2828,7 @@ mod tests { let tmp = write_temp_json( "pup_test_ds_restore_400.json", - r#"{"data":{"type":"dataset_restore","attributes":{"version":99}}}"#, + r#"{"data":{"id":"ds-1","type":"datasets","attributes":{"dataset_version":99}}}"#, ); let _mock = server .mock("POST", mockito::Matcher::Any) From 0054c6811390e7b8c9099bc85cee6727435a9981 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 07:56:07 +0000 Subject: [PATCH 06/14] feat(cost): add OCI configs and cost anomalies list commands - pup cost oci-configs list: wraps ListCostOCIConfigs (SDK #1540) - pup cost anomalies list: wraps ListCostAnomalies (SDK #1588) Registers v2.list_cost_anomalies as an unstable op and adds the two API-key-only endpoints to the OAuth exclusion table. Co-Authored-By: Claude --- docs/COMMANDS.md | 2 +- src/client.rs | 16 +++++++++-- src/commands/cost.rs | 65 +++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 31 +++++++++++++++++++++ 4 files changed, 109 insertions(+), 5 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0948138..5c42e49 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -197,7 +197,7 @@ pup infrastructure hosts list ### Cost & Usage - **usage** - Usage and billing (summary, hourly) -- **costs** - Cost management: `datadog` subgroup (projected, attribution, by-org, aws-config, azure-config, gcp-config) and `ccm` subgroup (custom-costs, tag-descriptions, tag-metadata, tags, tag-keys, budgets, commitments) +- **costs** - Cost management: `datadog` subgroup (projected, attribution, by-org, aws-config, azure-config, gcp-config), `ccm` subgroup (custom-costs, tag-descriptions, tag-metadata, tags, tag-keys, budgets, commitments), `oci-configs` subgroup (list), and `anomalies` subgroup (list) ### Configuration & Data Management - **obs-pipelines** - Observability pipelines (list, get, create, update, delete, validate) diff --git a/src/client.rs b/src/client.rs index 42cc95d..4ebc8fd 100644 --- a/src/client.rs +++ b/src/client.rs @@ -414,6 +414,8 @@ static UNSTABLE_OPS: &[&str] = &[ "v2.get_investigation", "v2.list_investigations", "v2.trigger_investigation", + // Cloud Cost Management — Anomalies (1) + "v2.list_cost_anomalies", ]; // --------------------------------------------------------------------------- @@ -636,7 +638,7 @@ static OAUTH_EXCLUDED_ENDPOINTS: &[EndpointRequirement] = &[ path: "/api/v2/obs-pipelines/pipelines/validate", method: "POST", }, - // Cost / Billing (9) — API key only, no OAuth support + // Cost / Billing (11) — API key only, no OAuth support EndpointRequirement { path: "/api/v2/usage/projected_cost", method: "GET", @@ -698,6 +700,14 @@ static OAUTH_EXCLUDED_ENDPOINTS: &[EndpointRequirement] = &[ path: "/api/v2/cost/gcp_uc_config/", method: "DELETE", }, + EndpointRequirement { + path: "/api/v2/cost/oci_config", + method: "GET", + }, + EndpointRequirement { + path: "/api/v2/cost/anomalies", + method: "GET", + }, // Profiling (4) // No OAuth scope is declared for Continuous Profiler endpoints; force API-key auth. EndpointRequirement { @@ -1273,12 +1283,12 @@ mod tests { #[test] fn test_unstable_ops_count() { - assert_eq!(UNSTABLE_OPS.len(), 162); + assert_eq!(UNSTABLE_OPS.len(), 163); } #[test] fn test_oauth_excluded_count() { - assert_eq!(OAUTH_EXCLUDED_ENDPOINTS.len(), 52); + assert_eq!(OAUTH_EXCLUDED_ENDPOINTS.len(), 54); } #[test] diff --git a/src/commands/cost.rs b/src/commands/cost.rs index 211c831..d93b5c9 100644 --- a/src/commands/cost.rs +++ b/src/commands/cost.rs @@ -1,5 +1,7 @@ use anyhow::Result; -use datadog_api_client::datadogV2::api_cloud_cost_management::CloudCostManagementAPI; +use datadog_api_client::datadogV2::api_cloud_cost_management::{ + CloudCostManagementAPI, ListCostAnomaliesOptionalParams, +}; use datadog_api_client::datadogV2::api_usage_metering::{ GetCostByOrgOptionalParams, GetMonthlyCostAttributionOptionalParams, GetProjectedCostOptionalParams, UsageMeteringAPI as UsageMeteringV2API, @@ -179,6 +181,28 @@ pub async fn gcp_config_delete(cfg: &Config, id: i64) -> Result<()> { Ok(()) } +// ---- Cloud Cost Management — OCI Configs ---- + +pub async fn oci_configs_list(cfg: &Config) -> Result<()> { + let api = make_ccm_api(cfg); + let resp = api + .list_cost_oci_configs() + .await + .map_err(|e| anyhow::anyhow!("failed to list OCI configs: {e:?}"))?; + formatter::output(cfg, &resp) +} + +// ---- Cloud Cost Management — Anomalies ---- + +pub async fn anomalies_list(cfg: &Config) -> Result<()> { + let api = make_ccm_api(cfg); + let resp = api + .list_cost_anomalies(ListCostAnomaliesOptionalParams::default()) + .await + .map_err(|e| anyhow::anyhow!("failed to list cost anomalies: {e:?}"))?; + formatter::output(cfg, &resp) +} + #[cfg(test)] mod tests { @@ -193,4 +217,43 @@ mod tests { let _ = super::projected(&cfg).await; cleanup_env(); } + + #[tokio::test] + async fn test_oci_configs_list() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = mock_any(&mut server, "GET", r#"{"data":[]}"#).await; + let result = super::oci_configs_list(&cfg).await; + assert!(result.is_ok(), "oci_configs_list failed: {:?}", result.err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_oci_configs_list_error() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("GET", mockito::Matcher::Any) + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::oci_configs_list(&cfg).await; + assert!(result.is_err(), "expected error for 403 response"); + cleanup_env(); + } + + #[tokio::test] + async fn test_anomalies_list() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = mock_any(&mut server, "GET", r#"{"data":null}"#).await; + let result = super::anomalies_list(&cfg).await; + assert!(result.is_ok(), "anomalies_list failed: {:?}", result.err()); + cleanup_env(); + } } diff --git a/src/main.rs b/src/main.rs index 8fbdede..882f026 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7492,6 +7492,17 @@ enum CostActions { #[command(subcommand)] action: CostCcmActions, }, + /// Manage OCI (Oracle Cloud Infrastructure) cost configs + #[command(name = "oci-configs")] + OciConfigs { + #[command(subcommand)] + action: CostOciConfigsActions, + }, + /// Manage Cloud Cost Management anomalies + Anomalies { + #[command(subcommand)] + action: CostAnomaliesActions, + }, } #[derive(Subcommand)] @@ -7919,6 +7930,20 @@ enum CostCcmCommitmentsActions { }, } +// ---- Cost OCI Configs ---- +#[derive(Subcommand)] +enum CostOciConfigsActions { + /// List OCI cost configs + List, +} + +// ---- Cost Anomalies ---- +#[derive(Subcommand)] +enum CostAnomaliesActions { + /// List detected cost anomalies + List, +} + // ---- Misc ---- #[derive(Subcommand)] enum MiscActions { @@ -13903,6 +13928,12 @@ async fn main_inner() -> anyhow::Result<()> { } }, }, + CostActions::OciConfigs { action } => match action { + CostOciConfigsActions::List => commands::cost::oci_configs_list(&cfg).await?, + }, + CostActions::Anomalies { action } => match action { + CostAnomaliesActions::List => commands::cost::anomalies_list(&cfg).await?, + }, } } // --- Misc --- From dc0981920fded302e14f4507c040872f4bfd4dc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:12:01 +0000 Subject: [PATCH 07/14] feat(security): add findings mute and rules bulk-convert commands - pup security findings mute: wraps MuteSecurityFindings (stable, SDK #1519/#1660) - pup security rules bulk-convert: wraps BulkConvertExistingSecurityMonitoringRules (#1675) Co-Authored-By: Claude --- docs/COMMANDS.md | 2 + src/commands/security.rs | 138 ++++++++++++++++++++++++++++++++++++++- src/main.rs | 20 ++++++ 3 files changed, 157 insertions(+), 3 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0948138..fa1172c 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -161,6 +161,8 @@ pup infrastructure hosts list ### Security & Compliance - **security** - Security monitoring (rules, signals, findings, content-packs, risk-scores) + - `pup security findings mute --file ` — Mute or unmute up to 100 findings (stable, SDK #1519/#1660) + - `pup security rules bulk-convert --file ` — Bulk convert existing rules to Terraform ZIP archive (SDK #1675) - **static-analysis** - Code security (custom-rulesets, custom-rules) - **audit-logs** - Audit trail (list, search) - **data-governance** - Sensitive data scanning (scanner-rules list) diff --git a/src/commands/security.rs b/src/commands/security.rs index 3f7baf6..72fa945 100644 --- a/src/commands/security.rs +++ b/src/commands/security.rs @@ -18,9 +18,10 @@ use datadog_api_client::datadogV2::api_security_monitoring::{ use datadog_api_client::datadogV2::model::{ ApplicationSecurityWafCustomRuleCreateRequest, ApplicationSecurityWafCustomRuleUpdateRequest, ApplicationSecurityWafExclusionFilterCreateRequest, - ApplicationSecurityWafExclusionFilterUpdateRequest, RestrictionPolicyUpdateRequest, - SecurityMonitoringRuleBulkExportAttributes, SecurityMonitoringRuleBulkExportData, - SecurityMonitoringRuleBulkExportDataType, SecurityMonitoringRuleBulkExportPayload, + ApplicationSecurityWafExclusionFilterUpdateRequest, MuteFindingsRequest, + RestrictionPolicyUpdateRequest, SecurityMonitoringRuleBulkExportAttributes, + SecurityMonitoringRuleBulkExportData, SecurityMonitoringRuleBulkExportDataType, + SecurityMonitoringRuleBulkExportPayload, SecurityMonitoringRuleConvertBulkPayload, SecurityMonitoringRuleConvertPayload, SecurityMonitoringRuleSort, SecurityMonitoringSignalListRequest, SecurityMonitoringSignalListRequestFilter, SecurityMonitoringSignalListRequestPage, SecurityMonitoringSignalsSort, @@ -288,6 +289,21 @@ pub async fn findings_search(cfg: &Config, query: Option, limit: i64) -> formatter::output(cfg, &resp) } +// ---- Mute Findings ---- + +/// Mute or unmute security findings (stable, SDK #1519/#1660). +/// Accepts up to 100 finding IDs per request. The `--file` must contain a +/// JSON body shaped as `MuteFindingsRequest` (see Datadog docs). +pub async fn findings_mute(cfg: &Config, file: &str) -> Result<()> { + let body: MuteFindingsRequest = util::read_json_file(file)?; + let api = crate::make_api!(SecurityMonitoringAPI, cfg); + let resp = api + .mute_security_findings(body) + .await + .map_err(|e| anyhow::anyhow!("failed to mute findings: {e:?}"))?; + formatter::output(cfg, &resp) +} + // ---- Bulk Export ---- pub async fn rules_bulk_export(cfg: &Config, rule_ids: Vec) -> Result<()> { @@ -330,6 +346,22 @@ pub async fn rules_to_terraform(cfg: &Config, file: &str) -> Result<()> { formatter::output(cfg, &resp) } +/// Bulk convert existing security monitoring rules to Terraform (SDK #1675). +/// The `--file` must contain a JSON body shaped as +/// `SecurityMonitoringRuleConvertBulkPayload`. Returns a ZIP archive written +/// to stdout (pipe to a file if you want to save it). +pub async fn rules_bulk_convert(cfg: &Config, file: &str) -> Result<()> { + let body: SecurityMonitoringRuleConvertBulkPayload = util::read_json_file(file)?; + let api = crate::make_api!(SecurityMonitoringAPI, cfg); + let bytes = api + .bulk_convert_existing_security_monitoring_rules(body) + .await + .map_err(|e| anyhow::anyhow!("failed to bulk convert security rules: {e:?}"))?; + let output = String::from_utf8_lossy(&bytes); + println!("{output}"); + Ok(()) +} + pub async fn terraform_export(cfg: &Config, resource_type: &str, resource_id: &str) -> Result<()> { let rt = parse_terraform_resource_type(resource_type)?; let api = crate::make_api!(SecurityMonitoringAPI, cfg); @@ -1099,4 +1131,104 @@ mod tests { .to_string() .contains("invalid --sort value")); } + + #[tokio::test] + async fn test_findings_mute_ok() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let tmp = write_temp_json( + "pup_test_findings_mute.json", + r#"{"data":{"type":"mute","attributes":{"mute":{"is_muted":true,"reason":"FALSE_POSITIVE"}},"relationships":{"findings":{"data":[]}}}}"#, + ); + let _mock = mock_any( + &mut server, + "PATCH", + r#"{"data":{"id":"mute-job-1","type":"mute_findings_response"}}"#, + ) + .await; + let result = super::findings_mute(&cfg, tmp.to_str().unwrap()).await; + assert!(result.is_ok(), "findings_mute failed: {:?}", result.err()); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_findings_mute_error() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let tmp = write_temp_json( + "pup_test_findings_mute_err.json", + r#"{"data":{"type":"mute","attributes":{"mute":{"is_muted":true,"reason":"FALSE_POSITIVE"}},"relationships":{"findings":{"data":[]}}}}"#, + ); + let _mock = server + .mock("PATCH", mockito::Matcher::Any) + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::findings_mute(&cfg, tmp.to_str().unwrap()).await; + assert!(result.is_err(), "expected error for 403 response"); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_rules_bulk_convert_ok() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let tmp = write_temp_json( + "pup_test_rules_bulk_convert.json", + r#"{"data":{"type":"security_monitoring_rules_convert_bulk","attributes":{"ruleIds":["abc-123"]}}}"#, + ); + let zip_bytes: &[u8] = b"PK\x03\x04fake-zip-bytes"; + let _mock = server + .mock("POST", mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/zip") + .with_body(zip_bytes) + .create_async() + .await; + let result = super::rules_bulk_convert(&cfg, tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "rules_bulk_convert failed: {:?}", + result.err() + ); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } + + #[tokio::test] + async fn test_rules_bulk_convert_error() { + let _lock = lock_env().await; + std::env::set_var("DD_TOKEN_STORAGE", "file"); + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let tmp = write_temp_json( + "pup_test_rules_bulk_convert_err.json", + r#"{"data":{"type":"security_monitoring_rules_convert_bulk","attributes":{"ruleIds":["bad"]}}}"#, + ); + let _mock = server + .mock("POST", mockito::Matcher::Any) + .with_status(400) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["Bad Request"]}"#) + .create_async() + .await; + let result = super::rules_bulk_convert(&cfg, tmp.to_str().unwrap()).await; + assert!(result.is_err(), "expected error for 400 response"); + let _ = std::fs::remove_file(tmp); + cleanup_env(); + std::env::remove_var("DD_TOKEN_STORAGE"); + } } diff --git a/src/main.rs b/src/main.rs index 8fbdede..151bf11 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4689,6 +4689,15 @@ enum SecurityRuleActions { #[arg(long, help = "JSON file with the rule conversion payload (required)")] file: String, }, + /// Bulk convert existing rules to Terraform (returns a ZIP archive) + #[command(name = "bulk-convert")] + BulkConvert { + #[arg( + long, + help = "JSON file with SecurityMonitoringRuleConvertBulkPayload body (required)" + )] + file: String, + }, } #[derive(Subcommand)] @@ -4759,6 +4768,11 @@ enum SecurityFindingActions { #[arg(long, default_value_t = 100)] limit: i64, }, + /// Mute or unmute security findings (up to 100 per request) + Mute { + #[arg(long, help = "JSON file with MuteFindingsRequest body (required)")] + file: String, + }, } #[derive(Subcommand)] @@ -11674,6 +11688,9 @@ async fn main_inner() -> anyhow::Result<()> { SecurityRuleActions::ToTerraform { file } => { commands::security::rules_to_terraform(&cfg, &file).await?; } + SecurityRuleActions::BulkConvert { file } => { + commands::security::rules_bulk_convert(&cfg, &file).await?; + } }, SecurityActions::Signals { action } => match action { SecuritySignalActions::List { @@ -11709,6 +11726,9 @@ async fn main_inner() -> anyhow::Result<()> { commands::security::findings_analyze(&cfg, &query, &from, &to, limit) .await?; } + SecurityFindingActions::Mute { file } => { + commands::security::findings_mute(&cfg, &file).await?; + } }, SecurityActions::ContentPacks { action } => match action { SecurityContentPackActions::List => { From 44a1ded1bf09a7953122e9c9b99cbb7ff965656b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:28:35 +0000 Subject: [PATCH 08/14] feat(synthetics): add downtime list/create/delete commands Wraps the Synthetics downtime endpoints (SDK #1518): - pup synthetics downtime list [--filters] - pup synthetics downtime create --file - pup synthetics downtime delete Co-Authored-By: Claude --- docs/COMMANDS.md | 5 +- src/commands/synthetics.rs | 165 ++++++++++++++++++++++++++++++++++++- src/main.rs | 43 ++++++++++ 3 files changed, 208 insertions(+), 5 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0948138..57c9000 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -44,7 +44,7 @@ pup [options] # Nested commands | api-keys | list, get, create, delete | src/commands/api_keys.rs | ✅ | | app-keys | list, get, create, update, delete | src/commands/app_keys.rs | ✅ | | infrastructure | hosts (list, get) | src/commands/infrastructure.rs | ✅ | -| synthetics | tests, locations, suites | src/commands/synthetics.rs | ✅ | +| synthetics | tests, locations, suites, downtime | src/commands/synthetics.rs | ✅ | | symdb | search | src/commands/symdb.rs | ✅ | | logs-restriction | list, get, create, update, delete, roles (list, add) | src/commands/logs_restriction.rs | ✅ | | processes | list | src/commands/processes.rs | ✅ | @@ -148,7 +148,7 @@ pup infrastructure hosts list - **monitors** - Monitor management (list, get, delete) - **dashboards** - Dashboard management (list, get, delete, url) - **slos** - Service Level Objectives (list, get, delete, status) -- **synthetics** - Synthetic monitoring (tests, locations, suites) +- **synthetics** - Synthetic monitoring (tests, locations, suites, downtime) - **notebooks** - Investigation notebooks (list, get, delete) - **downtime** - Monitor downtime (list, get, cancel) - **status-pages** - Status pages with components and degradations @@ -256,6 +256,7 @@ Available on all commands: - **integrations** — Added Jira integration (accounts, templates CRUD) and ServiceNow integration (instances, templates, users, assignment groups, business services) - **cloud** — Added OCI integration (tenancy configs CRUD, products) - **synthetics** — Added suites management (V2 API: search, get, create, update, delete) +- **synthetics** — Added downtime management (V2 API: list, create, delete) - **security** — Added content packs (list, activate, deactivate), bulk rule export, and entity risk scores - **incidents** — Added global settings, handles, and postmortem template management - **cases** — Added Jira/ServiceNow issue linking, case project moves, and notification rules diff --git a/src/commands/synthetics.rs b/src/commands/synthetics.rs index 1a5a1c1..77ab8a9 100644 --- a/src/commands/synthetics.rs +++ b/src/commands/synthetics.rs @@ -6,12 +6,13 @@ use datadog_api_client::datadogV2::api_synthetics::{ GetSyntheticsBrowserTestResultOptionalParams, GetSyntheticsTestResultOptionalParams, GetSyntheticsTestVersionOptionalParams, ListSyntheticsBrowserTestLatestResultsOptionalParams, ListSyntheticsTestLatestResultsOptionalParams, ListSyntheticsTestVersionsOptionalParams, - SearchSuitesOptionalParams, SyntheticsAPI as SyntheticsV2API, + ListSyntheticsDowntimesOptionalParams, SearchSuitesOptionalParams, + SyntheticsAPI as SyntheticsV2API, }; use datadog_api_client::datadogV2::model::{ DeletedSuitesRequestDelete, DeletedSuitesRequestDeleteAttributes, - DeletedSuitesRequestDeleteRequest, SuiteCreateEditRequest, SyntheticsTestResultRunType, - SyntheticsTestResultStatus, + DeletedSuitesRequestDeleteRequest, SuiteCreateEditRequest, SyntheticsDowntimeRequest, + SyntheticsTestResultRunType, SyntheticsTestResultStatus, }; use crate::config::Config; @@ -540,6 +541,47 @@ pub async fn tests_list_versions( formatter::output(cfg, &resp) } +// ---- Downtimes (V2 API) ---- + +pub async fn downtime_list( + cfg: &Config, + filter_test_ids: Option, + filter_active: Option, +) -> Result<()> { + let api = crate::make_api!(SyntheticsV2API, cfg); + let mut params = ListSyntheticsDowntimesOptionalParams::default(); + if let Some(ids) = filter_test_ids { + params = params.filter_test_ids(ids); + } + if let Some(active) = filter_active { + params = params.filter_active(active); + } + let resp = api + .list_synthetics_downtimes(params) + .await + .map_err(|e| anyhow::anyhow!("failed to list synthetics downtimes: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn downtime_create(cfg: &Config, file: &str) -> Result<()> { + let api = crate::make_api!(SyntheticsV2API, cfg); + let body: SyntheticsDowntimeRequest = crate::util::read_json_file(file)?; + let resp = api + .create_synthetics_downtime(body) + .await + .map_err(|e| anyhow::anyhow!("failed to create synthetics downtime: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn downtime_delete(cfg: &Config, downtime_id: &str) -> Result<()> { + let api = crate::make_api!(SyntheticsV2API, cfg); + api.delete_synthetics_downtime(downtime_id.to_string()) + .await + .map_err(|e| anyhow::anyhow!("failed to delete synthetics downtime: {e:?}"))?; + println!("Synthetics downtime {downtime_id} deleted."); + Ok(()) +} + // ---- Multistep (V2 API) ---- pub async fn multistep_get_subtests(cfg: &Config, public_id: &str) -> Result<()> { @@ -820,4 +862,121 @@ mod tests { .to_string() .contains("at least one result-id")); } + + #[tokio::test] + async fn test_synthetics_downtime_list() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + let _mock = mock_any(&mut s, "GET", r#"{"data":[]}"#).await; + let result = super::downtime_list(&cfg, None, None).await; + assert!( + result.is_ok(), + "downtime_list failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_synthetics_downtime_list_with_filters() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + let _mock = mock_any(&mut s, "GET", r#"{"data":[]}"#).await; + let result = super::downtime_list( + &cfg, + Some("abc-def-ghi".to_string()), + Some("true".to_string()), + ) + .await; + assert!( + result.is_ok(), + "downtime_list with filters failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_synthetics_downtime_list_error() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("GET", mockito::Matcher::Any) + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["Forbidden"]}"#) + .create_async() + .await; + let result = super::downtime_list(&cfg, None, None).await; + assert!(result.is_err(), "expected 403 error from downtime_list"); + cleanup_env(); + } + + #[tokio::test] + async fn test_synthetics_downtime_create() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + let _mock = mock_any( + &mut s, + "POST", + r#"{"data":{"id":"dt-123","type":"downtime","attributes":{"createdAt":"2024-01-01T00:00:00+00:00","createdBy":"u1","createdByName":"User One","description":"","isEnabled":true,"name":"test","tags":[],"testIds":[],"timeSlots":[],"updatedAt":"2024-01-01T00:00:00+00:00","updatedBy":"u1","updatedByName":"User One"}}}"#, + ) + .await; + let tmp = write_temp_json( + "downtime_create.json", + r#"{"data":{"type":"downtime","attributes":{"name":"test","isEnabled":true,"testIds":[],"timeSlots":[]}}}"#, + ); + let result = super::downtime_create(&cfg, tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "downtime_create failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_synthetics_downtime_delete() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + let _mock = server_mock_delete(&mut s).await; + let result = super::downtime_delete(&cfg, "dt-abc-123").await; + assert!( + result.is_ok(), + "downtime_delete failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_synthetics_downtime_delete_error() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = server + .mock("DELETE", mockito::Matcher::Any) + .with_status(404) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["Not Found"]}"#) + .create_async() + .await; + let result = super::downtime_delete(&cfg, "nonexistent-dt").await; + assert!(result.is_err(), "expected 404 error from downtime_delete"); + cleanup_env(); + } + + async fn server_mock_delete(s: &mut mockito::Server) -> mockito::Mock { + s.mock("DELETE", mockito::Matcher::Any) + .with_status(204) + .with_header("content-type", "application/json") + .with_body("") + .create_async() + .await + } } diff --git a/src/main.rs b/src/main.rs index 8fbdede..31bf19c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3547,6 +3547,34 @@ enum SyntheticsActions { #[command(subcommand)] action: SyntheticsMultistepActions, }, + /// Manage Synthetics downtimes + Downtime { + #[command(subcommand)] + action: SyntheticsDowntimeActions, + }, +} + +#[derive(Subcommand)] +enum SyntheticsDowntimeActions { + /// List all Synthetics downtimes + List { + /// Comma-separated list of Synthetics test public IDs to filter by + #[arg(long = "filter-test-ids")] + filter_test_ids: Option, + /// If set to true, return only currently active downtimes + #[arg(long = "filter-active")] + filter_active: Option, + }, + /// Create a new Synthetics downtime (body from JSON file) + Create { + #[arg(long, help = "JSON file with downtime definition (required)")] + file: String, + }, + /// Delete a Synthetics downtime by ID + Delete { + /// Downtime ID to delete + downtime_id: String, + }, } #[derive(Subcommand)] @@ -11398,6 +11426,21 @@ async fn main_inner() -> anyhow::Result<()> { .await?; } }, + SyntheticsActions::Downtime { action } => match action { + SyntheticsDowntimeActions::List { + filter_test_ids, + filter_active, + } => { + commands::synthetics::downtime_list(&cfg, filter_test_ids, filter_active) + .await?; + } + SyntheticsDowntimeActions::Create { file } => { + commands::synthetics::downtime_create(&cfg, &file).await?; + } + SyntheticsDowntimeActions::Delete { downtime_id } => { + commands::synthetics::downtime_delete(&cfg, &downtime_id).await?; + } + }, } } // --- Test Optimization --- From f04988c3bde1fa454f148ff4ccb8b203ac7448da Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:37:23 +0000 Subject: [PATCH 09/14] style(cost): apply cargo fmt Co-Authored-By: Claude --- src/commands/cost.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/commands/cost.rs b/src/commands/cost.rs index d93b5c9..a0fdbea 100644 --- a/src/commands/cost.rs +++ b/src/commands/cost.rs @@ -225,7 +225,11 @@ mod tests { let cfg = test_config(&server.url()); let _mock = mock_any(&mut server, "GET", r#"{"data":[]}"#).await; let result = super::oci_configs_list(&cfg).await; - assert!(result.is_ok(), "oci_configs_list failed: {:?}", result.err()); + assert!( + result.is_ok(), + "oci_configs_list failed: {:?}", + result.err() + ); cleanup_env(); } From 3309bf8edb7cfa1fbb291c2814f2c011e50404f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:37:25 +0000 Subject: [PATCH 10/14] style(synthetics): apply cargo fmt Co-Authored-By: Claude --- src/commands/synthetics.rs | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/commands/synthetics.rs b/src/commands/synthetics.rs index 77ab8a9..08b5246 100644 --- a/src/commands/synthetics.rs +++ b/src/commands/synthetics.rs @@ -5,8 +5,8 @@ use datadog_api_client::datadogV1::api_synthetics::{ use datadog_api_client::datadogV2::api_synthetics::{ GetSyntheticsBrowserTestResultOptionalParams, GetSyntheticsTestResultOptionalParams, GetSyntheticsTestVersionOptionalParams, ListSyntheticsBrowserTestLatestResultsOptionalParams, - ListSyntheticsTestLatestResultsOptionalParams, ListSyntheticsTestVersionsOptionalParams, - ListSyntheticsDowntimesOptionalParams, SearchSuitesOptionalParams, + ListSyntheticsDowntimesOptionalParams, ListSyntheticsTestLatestResultsOptionalParams, + ListSyntheticsTestVersionsOptionalParams, SearchSuitesOptionalParams, SyntheticsAPI as SyntheticsV2API, }; use datadog_api_client::datadogV2::model::{ @@ -870,11 +870,7 @@ mod tests { let cfg = test_config(&s.url()); let _mock = mock_any(&mut s, "GET", r#"{"data":[]}"#).await; let result = super::downtime_list(&cfg, None, None).await; - assert!( - result.is_ok(), - "downtime_list failed: {:?}", - result.err() - ); + assert!(result.is_ok(), "downtime_list failed: {:?}", result.err()); cleanup_env(); } @@ -931,11 +927,7 @@ mod tests { r#"{"data":{"type":"downtime","attributes":{"name":"test","isEnabled":true,"testIds":[],"timeSlots":[]}}}"#, ); let result = super::downtime_create(&cfg, tmp.to_str().unwrap()).await; - assert!( - result.is_ok(), - "downtime_create failed: {:?}", - result.err() - ); + assert!(result.is_ok(), "downtime_create failed: {:?}", result.err()); cleanup_env(); } @@ -946,11 +938,7 @@ mod tests { let cfg = test_config(&s.url()); let _mock = server_mock_delete(&mut s).await; let result = super::downtime_delete(&cfg, "dt-abc-123").await; - assert!( - result.is_ok(), - "downtime_delete failed: {:?}", - result.err() - ); + assert!(result.is_ok(), "downtime_delete failed: {:?}", result.err()); cleanup_env(); } From 19ea3d341d8f4e7dcab8ff5f2c955b21e42692aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:44:42 +0000 Subject: [PATCH 11/14] feat(annotations): add annotations API v2 commands New pup annotations domain wrapping the Annotations API v2 (SDK #1631): list, get-page, create, update, delete. Registers the 5 annotation operations as unstable ops (162 -> 167). Co-Authored-By: Claude --- docs/COMMANDS.md | 1 + src/client.rs | 8 +- src/commands/annotations.rs | 238 ++++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/main.rs | 102 ++++++++++++++++ 5 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/commands/annotations.rs diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0948138..32263ff 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -22,6 +22,7 @@ pup [options] # Nested commands | Domain | Subcommands | File | Status | |--------|-------------|------|--------| | acp | serve | src/commands/acp.rs | ✅ | +| annotations | list, get-page, create, update, delete | src/commands/annotations.rs | ✅ (unstable) | | auth | login, logout, status, refresh | src/commands/auth.rs | ✅ | | metrics | query, list, get, search | src/commands/metrics.rs | ✅ | | logs | search, list, aggregate | src/commands/logs.rs | ✅ | diff --git a/src/client.rs b/src/client.rs index 42cc95d..f9ef2da 100644 --- a/src/client.rs +++ b/src/client.rs @@ -225,6 +225,12 @@ macro_rules! make_api_no_auth { /// All unstable operations (snake_case for the Rust DD client). static UNSTABLE_OPS: &[&str] = &[ + // Annotations (5) + "v2.create_annotation", + "v2.delete_annotation", + "v2.get_page_annotations", + "v2.list_annotations", + "v2.update_annotation", // Incidents (26) "v2.list_incidents", "v2.search_incidents", @@ -1273,7 +1279,7 @@ mod tests { #[test] fn test_unstable_ops_count() { - assert_eq!(UNSTABLE_OPS.len(), 162); + assert_eq!(UNSTABLE_OPS.len(), 167); } #[test] diff --git a/src/commands/annotations.rs b/src/commands/annotations.rs new file mode 100644 index 0000000..7c84249 --- /dev/null +++ b/src/commands/annotations.rs @@ -0,0 +1,238 @@ +use anyhow::Result; +use datadog_api_client::datadogV2::api_annotations::{ + AnnotationsAPI, ListAnnotationsOptionalParams, +}; +use datadog_api_client::datadogV2::model::{AnnotationCreateRequest, AnnotationUpdateRequest}; + +use crate::config::Config; +use crate::formatter; +use crate::util; + +pub async fn list( + cfg: &Config, + page_id: &str, + start_time: i64, + end_time: i64, + widget_id: Option, +) -> Result<()> { + let api = crate::make_api!(AnnotationsAPI, cfg); + let params = if let Some(wid) = widget_id { + ListAnnotationsOptionalParams::default().widget_id(wid) + } else { + ListAnnotationsOptionalParams::default() + }; + let resp = api + .list_annotations(page_id.to_string(), start_time, end_time, params) + .await + .map_err(|e| anyhow::anyhow!("failed to list annotations: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn get_page(cfg: &Config, page_id: &str, start_time: i64, end_time: i64) -> Result<()> { + let api = crate::make_api!(AnnotationsAPI, cfg); + let resp = api + .get_page_annotations(page_id.to_string(), start_time, end_time) + .await + .map_err(|e| anyhow::anyhow!("failed to get page annotations: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn create(cfg: &Config, file: &str) -> Result<()> { + let api = crate::make_api!(AnnotationsAPI, cfg); + let body: AnnotationCreateRequest = util::read_json_file(file)?; + let resp = api + .create_annotation(body) + .await + .map_err(|e| anyhow::anyhow!("failed to create annotation: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn update(cfg: &Config, annotation_id: uuid::Uuid, file: &str) -> Result<()> { + let api = crate::make_api!(AnnotationsAPI, cfg); + let body: AnnotationUpdateRequest = util::read_json_file(file)?; + let resp = api + .update_annotation(annotation_id, body) + .await + .map_err(|e| anyhow::anyhow!("failed to update annotation: {e:?}"))?; + formatter::output(cfg, &resp) +} + +pub async fn delete(cfg: &Config, annotation_id: uuid::Uuid) -> Result<()> { + let api = crate::make_api!(AnnotationsAPI, cfg); + api.delete_annotation(annotation_id) + .await + .map_err(|e| anyhow::anyhow!("failed to delete annotation: {e:?}"))?; + println!("Successfully deleted annotation {annotation_id}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::test_support::*; + + #[tokio::test] + async fn test_annotations_list() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, r#"{"data":[]}"#).await; + let result = super::list(&cfg, "test-page", 0, 3600, None).await; + assert!( + result.is_ok(), + "annotations list failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_annotations_list_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(403) + .with_body("forbidden") + .create_async() + .await; + let result = super::list(&cfg, "test-page", 0, 3600, None).await; + assert!(result.is_err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_annotations_get_page() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"p1","type":"page_annotations","attributes":{"annotations":{},"global_annotations":[],"widget_mapping":{}}}}"#, + ) + .await; + let result = super::get_page(&cfg, "test-page", 0, 3600).await; + assert!( + result.is_ok(), + "get page annotations failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_annotations_get_page_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("GET", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let result = super::get_page(&cfg, "missing-page", 0, 3600).await; + assert!(result.is_err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_annotations_create() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"00000000-0000-0000-0000-000000000001","type":"annotation","attributes":{"author_id":"u1","color":"blue","created_at":0,"description":"x","end_time":0,"modified_at":0,"page_id":"p1","start_time":0,"type":"pointInTime"}}}"#, + ) + .await; + let tmp = write_temp_json( + "annotation_create.json", + r#"{"data":{"type":"annotation","attributes":{"color":"blue","description":"deploy","page_id":"p1","start_time":0,"type":"pointInTime"}}}"#, + ); + let result = super::create(&cfg, tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "annotation create failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_annotations_create_bad_file() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "{}").await; + let result = super::create(&cfg, "/nonexistent/file.json").await; + assert!(result.is_err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_annotations_update() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all( + &mut s, + r#"{"data":{"id":"00000000-0000-0000-0000-000000000001","type":"annotation","attributes":{"author_id":"u1","color":"blue","created_at":0,"description":"x","end_time":0,"modified_at":0,"page_id":"p1","start_time":0,"type":"pointInTime"}}}"#, + ) + .await; + let tmp = write_temp_json( + "annotation_update.json", + r#"{"data":{"type":"annotation","attributes":{"color":"green","description":"rollback","page_id":"p1","start_time":1000,"type":"pointInTime"}}}"#, + ); + let id = uuid::Uuid::nil(); + let result = super::update(&cfg, id, tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "annotation update failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_annotations_update_bad_file() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "{}").await; + let id = uuid::Uuid::nil(); + let result = super::update(&cfg, id, "/nonexistent/file.json").await; + assert!(result.is_err()); + cleanup_env(); + } + + #[tokio::test] + async fn test_annotations_delete() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + mock_all(&mut s, "").await; + let id = uuid::Uuid::nil(); + let result = super::delete(&cfg, id).await; + assert!( + result.is_ok(), + "annotation delete failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_annotations_delete_error() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + s.mock("DELETE", mockito::Matcher::Any) + .with_status(404) + .with_body(r#"{"errors":["not found"]}"#) + .create_async() + .await; + let id = uuid::Uuid::nil(); + let result = super::delete(&cfg, id).await; + assert!(result.is_err()); + cleanup_env(); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f27fcad..ef94112 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod acp; pub mod agent; pub mod agentless_scanning; pub mod alias; +pub mod annotations; pub mod api; pub mod api_keys; pub mod apm; diff --git a/src/main.rs b/src/main.rs index 8fbdede..c2c5ab2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -160,6 +160,33 @@ enum Commands { #[command(subcommand)] action: AgentlessScanningActions, }, + /// Manage Datadog annotations + /// + /// Create, list, update, and delete annotations on dashboards and notebook pages. + /// Annotations mark events such as deployments, incidents, or other notable moments in time. + /// + /// COMMANDS: + /// list List annotations for a page within a time window + /// get-page Get all annotations on a page grouped by widget + /// create Create a new annotation from JSON + /// update Update an existing annotation + /// delete Delete an annotation by ID + /// + /// EXAMPLES: + /// pup annotations list --page-id my-page --start 0 --end 3600 + /// pup annotations get-page --page-id my-page --start 0 --end 3600 + /// pup annotations create --file annotation.json + /// pup annotations update --file updated.json + /// pup annotations delete + /// + /// AUTHENTICATION: + /// Requires OAuth2 (via 'pup auth login') or DD_API_KEY + DD_APP_KEY. + /// Note: Annotations API is currently in unstable/preview status. + #[command(verbatim_doc_comment)] + Annotations { + #[command(subcommand)] + action: AnnotationsActions, + }, /// Create shortcuts for pup commands /// /// Aliases can be used to make shortcuts for pup commands or to compose multiple commands. @@ -9148,6 +9175,48 @@ enum AliasActions { }, } +// ---- Annotations ---- +#[derive(Subcommand)] +enum AnnotationsActions { + /// List annotations for a page within a time window + List { + #[arg(long, help = "Page ID (dashboard or notebook page)")] + page_id: String, + #[arg(long, help = "Start of time window (Unix epoch seconds)")] + start: i64, + #[arg(long, help = "End of time window (Unix epoch seconds)")] + end: i64, + #[arg(long, help = "Optional widget ID to filter results")] + widget_id: Option, + }, + /// Get all annotations on a page grouped by widget + GetPage { + #[arg(long, help = "Page ID (dashboard or notebook page)")] + page_id: String, + #[arg(long, help = "Start of time window (Unix epoch seconds)")] + start: i64, + #[arg(long, help = "End of time window (Unix epoch seconds)")] + end: i64, + }, + /// Create a new annotation from a JSON file + Create { + #[arg(long, help = "JSON file with AnnotationCreateRequest body (required)")] + file: String, + }, + /// Update an existing annotation + Update { + /// Annotation UUID + annotation_id: uuid::Uuid, + #[arg(long, help = "JSON file with AnnotationUpdateRequest body (required)")] + file: String, + }, + /// Delete an annotation by ID + Delete { + /// Annotation UUID + annotation_id: uuid::Uuid, + }, +} + // ---- Skills ---- #[cfg(not(target_arch = "wasm32"))] #[derive(Subcommand)] @@ -14306,6 +14375,39 @@ async fn main_inner() -> anyhow::Result<()> { AliasActions::Delete { names } => commands::alias::delete(names)?, AliasActions::Import { file } => commands::alias::import(&file)?, }, + // --- Annotations --- + Commands::Annotations { action } => { + cfg.validate_auth()?; + match action { + AnnotationsActions::List { + page_id, + start, + end, + widget_id, + } => { + commands::annotations::list(&cfg, &page_id, start, end, widget_id).await?; + } + AnnotationsActions::GetPage { + page_id, + start, + end, + } => { + commands::annotations::get_page(&cfg, &page_id, start, end).await?; + } + AnnotationsActions::Create { file } => { + commands::annotations::create(&cfg, &file).await?; + } + AnnotationsActions::Update { + annotation_id, + file, + } => { + commands::annotations::update(&cfg, annotation_id, &file).await?; + } + AnnotationsActions::Delete { annotation_id } => { + commands::annotations::delete(&cfg, annotation_id).await?; + } + } + } // --- Api --- Commands::Api { endpoint, From 51731acb6006905f3dcdc82a2a39fd4d021845b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:50:45 +0000 Subject: [PATCH 12/14] feat(metrics): add window-seconds and cross-org timeseries query - pup metrics tags list --window-seconds: lookback window param (SDK #1593) - pup metrics query-timeseries --file: v2 timeseries formula query, supporting cross_org_uuids per-query for cross-organisation reads (SDK #1564) Co-Authored-By: Claude --- docs/COMMANDS.md | 4 +- src/commands/metrics.rs | 107 ++++++++++++++++++++++++++++++++++++++-- src/main.rs | 41 ++++++++++++++- 3 files changed, 144 insertions(+), 8 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0948138..f5f45f7 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -23,7 +23,7 @@ pup [options] # Nested commands |--------|-------------|------|--------| | acp | serve | src/commands/acp.rs | ✅ | | auth | login, logout, status, refresh | src/commands/auth.rs | ✅ | -| metrics | query, list, get, search | src/commands/metrics.rs | ✅ | +| metrics | query, list, search, timeseries, metadata, tags, submit | src/commands/metrics.rs | ✅ | | logs | search, list, aggregate | src/commands/logs.rs | ✅ | | traces | metrics (list, get, create, update, delete) | src/commands/traces.rs | ✅ | | monitors | list, get, delete, search | src/commands/monitors.rs | ✅ | @@ -113,6 +113,8 @@ pup logs search --query="service:api" --from="7d" --storage="flex" pup dbm samples search --query="dbm_type:activity service:orders env:prod" --from="1h" --limit=10 pup metrics search --query="avg:system.cpu.user{*}" --from="1h" pup metrics query --query="avg:system.cpu.user{*}" --from="1h" +pup metrics tags list system.cpu.user --window-seconds=3600 +pup metrics timeseries --file=request.json pup events search --query="@user.id:12345" ``` diff --git a/src/commands/metrics.rs b/src/commands/metrics.rs index d682c7f..1ba836c 100644 --- a/src/commands/metrics.rs +++ b/src/commands/metrics.rs @@ -136,20 +136,39 @@ pub async fn submit(cfg: &Config, file: &str) -> Result<()> { formatter::output(cfg, &resp) } -pub async fn tags_list(cfg: &Config, metric_name: &str) -> Result<()> { +pub async fn tags_list(cfg: &Config, metric_name: &str, window_seconds: Option) -> Result<()> { use datadog_api_client::datadogV2::api_metrics::ListTagsByMetricNameOptionalParams; let api = crate::make_api!(MetricsV2API, cfg); + let mut params = ListTagsByMetricNameOptionalParams::default(); + if let Some(w) = window_seconds { + params = params.window_seconds(w); + } let resp = api - .list_tags_by_metric_name( - metric_name.to_string(), - ListTagsByMetricNameOptionalParams::default(), - ) + .list_tags_by_metric_name(metric_name.to_string(), params) .await .map_err(|e| anyhow::anyhow!("failed to list tags for metric {metric_name}: {e:?}"))?; formatter::output(cfg, &resp) } +/// Query timeseries data using the v2 metrics API. +/// +/// The request body is provided as a JSON file matching the +/// `TimeseriesFormulaQueryRequest` schema. Use `cross_org_uuids` inside the +/// individual query objects in that file to enable cross-organisation queries +/// (SDK PR #1564). +pub async fn query_timeseries(cfg: &Config, file: &str) -> Result<()> { + use datadog_api_client::datadogV2::model::TimeseriesFormulaQueryRequest; + + let api = crate::make_api!(MetricsV2API, cfg); + let body: TimeseriesFormulaQueryRequest = util::read_json_file(file)?; + let resp = api + .query_timeseries_data(body) + .await + .map_err(|e| anyhow::anyhow!("failed to query timeseries data: {e:?}"))?; + formatter::output(cfg, &resp) +} + #[cfg(test)] mod tests { use crate::test_support::*; @@ -255,4 +274,82 @@ mod tests { ); cleanup_env(); } + + #[tokio::test] + async fn test_metrics_tags_list_no_window() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = mock_any(&mut server, "GET", r#"{"data": null}"#).await; + + let result = super::tags_list(&cfg, "system.cpu.user", None).await; + assert!( + result.is_ok(), + "metrics tags list failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_metrics_tags_list_with_window() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = mock_any(&mut server, "GET", r#"{"data": null}"#).await; + + let result = super::tags_list(&cfg, "system.cpu.user", Some(3600)).await; + assert!( + result.is_ok(), + "metrics tags list with window_seconds failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_metrics_query_timeseries() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _mock = mock_any( + &mut server, + "POST", + r#"{"data": {"type": "timeseries_response", "attributes": {"times": [], "values": [], "series": []}}}"#, + ) + .await; + + let body = r#"{ + "data": { + "attributes": { + "formulas": [{"formula": "a"}], + "from": 0, + "interval": 5000, + "queries": [{"data_source": "metrics", "query": "avg:system.cpu.user{*}", "name": "a"}], + "to": 3600000 + }, + "type": "timeseries_request" + } + }"#; + let tmp = write_temp_json("timeseries_test.json", body); + let result = super::query_timeseries(&cfg, tmp.to_str().unwrap()).await; + assert!( + result.is_ok(), + "metrics query timeseries failed: {:?}", + result.err() + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_metrics_query_timeseries_bad_file() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + let _ = mock_any(&mut server, "POST", r#"{}"#).await; + + let result = super::query_timeseries(&cfg, "/nonexistent/path/request.json").await; + assert!(result.is_err(), "expected error for missing file, got ok"); + cleanup_env(); + } } diff --git a/src/main.rs b/src/main.rs index 8fbdede..21b5beb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3425,6 +3425,31 @@ enum MetricActions { #[command(subcommand)] action: MetricTagActions, }, + /// Query v2 timeseries data via TimeseriesFormulaQueryRequest JSON body (SDK PR #1564) + /// + /// Provide a JSON file matching the TimeseriesFormulaQueryRequest schema. + /// To query across organisations, include `cross_org_uuids` inside each + /// query object in the file (SDK PR #1564 added this field to + /// MetricsTimeseriesQuery and MetricsScalarQuery). + /// + /// Example body (timeseries_request.json): + /// { + /// "data": { + /// "attributes": { + /// "formulas": [{"formula": "a"}], + /// "from": 1700000000000, + /// "interval": 5000, + /// "queries": [{"data_source": "metrics", "query": "avg:system.cpu.user{*}", "name": "a", + /// "cross_org_uuids": [""]}], + /// "to": 1700003600000 + /// }, + /// "type": "timeseries_request" + /// } + /// } + Timeseries { + #[arg(long, help = "JSON file with TimeseriesFormulaQueryRequest body")] + file: String, + }, } #[derive(Subcommand)] @@ -3436,6 +3461,11 @@ enum MetricTagActions { from: String, #[arg(long, default_value = "now", help = "End time")] to: String, + #[arg( + long, + help = "Lookback window in seconds (SDK PR #1593; default 3600, max 2592000)" + )] + window_seconds: Option, }, } @@ -11189,10 +11219,17 @@ async fn main_inner() -> anyhow::Result<()> { } }, MetricActions::Tags { action } => match action { - MetricTagActions::List { metric_name, .. } => { - commands::metrics::tags_list(&cfg, &metric_name).await?; + MetricTagActions::List { + metric_name, + window_seconds, + .. + } => { + commands::metrics::tags_list(&cfg, &metric_name, window_seconds).await?; } }, + MetricActions::Timeseries { file } => { + commands::metrics::query_timeseries(&cfg, &file).await?; + } } } // --- SLOs --- From 1fd420a5d3f74b286e8b9ab3c843208e6e9aef87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 09:26:34 +0000 Subject: [PATCH 13/14] refactor(annotations): nest under dashboards and notebooks Annotations attach to a page (dashboard: or notebook:), so expose the actions under both `pup dashboards annotations` and `pup notebooks annotations` via a shared run_annotations dispatch, instead of a top-level `pup annotations` domain. Removing the top-level variant also fixes the alphabetical-order check in test_commands.rs. Co-Authored-By: Claude --- docs/COMMANDS.md | 5 +-- src/main.rs | 99 +++++++++++++++++++----------------------------- 2 files changed, 41 insertions(+), 63 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 32263ff..1a4440d 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -22,13 +22,12 @@ pup [options] # Nested commands | Domain | Subcommands | File | Status | |--------|-------------|------|--------| | acp | serve | src/commands/acp.rs | ✅ | -| annotations | list, get-page, create, update, delete | src/commands/annotations.rs | ✅ (unstable) | | auth | login, logout, status, refresh | src/commands/auth.rs | ✅ | | metrics | query, list, get, search | src/commands/metrics.rs | ✅ | | logs | search, list, aggregate | src/commands/logs.rs | ✅ | | traces | metrics (list, get, create, update, delete) | src/commands/traces.rs | ✅ | | monitors | list, get, delete, search | src/commands/monitors.rs | ✅ | -| dashboards | list, get, delete, url | src/commands/dashboards.rs | ✅ | +| dashboards | list, get, delete, url, annotations (list, get-page, create, update, delete) | src/commands/dashboards.rs, src/commands/annotations.rs | ✅ | | dbm | samples (search) | src/commands/dbm.rs | ✅ | | ddsql | table, time-series, spec, schema (tables, columns) | src/commands/ddsql.rs | ✅ | | debugger | probes (list, get, create, delete, watch) | src/commands/debugger.rs | ✅ | @@ -50,7 +49,7 @@ pup [options] # Nested commands | logs-restriction | list, get, create, update, delete, roles (list, add) | src/commands/logs_restriction.rs | ✅ | | processes | list | src/commands/processes.rs | ✅ | | users | list, get, roles, service-accounts (create, app-keys CRUD) | src/commands/users.rs | ✅ | -| notebooks | list, get, delete | src/commands/notebooks.rs | ✅ | +| notebooks | list, get, delete, annotations (list, get-page, create, update, delete) | src/commands/notebooks.rs, src/commands/annotations.rs | ✅ | | security | rules, signals, findings, content-packs, risk-scores | src/commands/security.rs | ✅ | | organizations | get, list | src/commands/organizations.rs | ✅ | | service-catalog | list, get | src/commands/service_catalog.rs | ✅ | diff --git a/src/main.rs b/src/main.rs index c2c5ab2..638bdd3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -160,33 +160,6 @@ enum Commands { #[command(subcommand)] action: AgentlessScanningActions, }, - /// Manage Datadog annotations - /// - /// Create, list, update, and delete annotations on dashboards and notebook pages. - /// Annotations mark events such as deployments, incidents, or other notable moments in time. - /// - /// COMMANDS: - /// list List annotations for a page within a time window - /// get-page Get all annotations on a page grouped by widget - /// create Create a new annotation from JSON - /// update Update an existing annotation - /// delete Delete an annotation by ID - /// - /// EXAMPLES: - /// pup annotations list --page-id my-page --start 0 --end 3600 - /// pup annotations get-page --page-id my-page --start 0 --end 3600 - /// pup annotations create --file annotation.json - /// pup annotations update --file updated.json - /// pup annotations delete - /// - /// AUTHENTICATION: - /// Requires OAuth2 (via 'pup auth login') or DD_API_KEY + DD_APP_KEY. - /// Note: Annotations API is currently in unstable/preview status. - #[command(verbatim_doc_comment)] - Annotations { - #[command(subcommand)] - action: AnnotationsActions, - }, /// Create shortcuts for pup commands /// /// Aliases can be used to make shortcuts for pup commands or to compose multiple commands. @@ -3252,6 +3225,11 @@ enum DashboardActions { #[command(subcommand)] action: WidgetActions, }, + /// Manage annotations on dashboard pages + Annotations { + #[command(subcommand)] + action: AnnotationsActions, + }, } // ---- Debugger ---- @@ -5740,6 +5718,11 @@ enum NotebookActions { }, /// Delete a notebook Delete { notebook_id: i64 }, + /// Manage annotations on notebook pages + Annotations { + #[command(subcommand)] + action: AnnotationsActions, + }, } // ---- RUM ---- @@ -9217,6 +9200,33 @@ enum AnnotationsActions { }, } +/// Shared dispatch for annotation subcommands. Annotations attach to a page +/// (`dashboard:` or `notebook:`), so the same actions are exposed under +/// both `pup dashboards annotations` and `pup notebooks annotations`. +async fn run_annotations(cfg: &config::Config, action: AnnotationsActions) -> anyhow::Result<()> { + match action { + AnnotationsActions::List { + page_id, + start, + end, + widget_id, + } => commands::annotations::list(cfg, &page_id, start, end, widget_id).await, + AnnotationsActions::GetPage { + page_id, + start, + end, + } => commands::annotations::get_page(cfg, &page_id, start, end).await, + AnnotationsActions::Create { file } => commands::annotations::create(cfg, &file).await, + AnnotationsActions::Update { + annotation_id, + file, + } => commands::annotations::update(cfg, annotation_id, &file).await, + AnnotationsActions::Delete { annotation_id } => { + commands::annotations::delete(cfg, annotation_id).await + } + } +} + // ---- Skills ---- #[cfg(not(target_arch = "wasm32"))] #[derive(Subcommand)] @@ -11059,6 +11069,7 @@ async fn main_inner() -> anyhow::Result<()> { Commands::Dashboards { action } => { cfg.validate_auth()?; match action { + DashboardActions::Annotations { action } => run_annotations(&cfg, action).await?, DashboardActions::List => commands::dashboards::list(&cfg).await?, DashboardActions::Get { id } => commands::dashboards::get(&cfg, &id).await?, DashboardActions::Url { @@ -12456,6 +12467,7 @@ async fn main_inner() -> anyhow::Result<()> { Commands::Notebooks { action } => { cfg.validate_auth()?; match action { + NotebookActions::Annotations { action } => run_annotations(&cfg, action).await?, NotebookActions::List => commands::notebooks::list(&cfg).await?, NotebookActions::Get { notebook_id } => { commands::notebooks::get(&cfg, notebook_id).await?; @@ -14375,39 +14387,6 @@ async fn main_inner() -> anyhow::Result<()> { AliasActions::Delete { names } => commands::alias::delete(names)?, AliasActions::Import { file } => commands::alias::import(&file)?, }, - // --- Annotations --- - Commands::Annotations { action } => { - cfg.validate_auth()?; - match action { - AnnotationsActions::List { - page_id, - start, - end, - widget_id, - } => { - commands::annotations::list(&cfg, &page_id, start, end, widget_id).await?; - } - AnnotationsActions::GetPage { - page_id, - start, - end, - } => { - commands::annotations::get_page(&cfg, &page_id, start, end).await?; - } - AnnotationsActions::Create { file } => { - commands::annotations::create(&cfg, &file).await?; - } - AnnotationsActions::Update { - annotation_id, - file, - } => { - commands::annotations::update(&cfg, annotation_id, &file).await?; - } - AnnotationsActions::Delete { annotation_id } => { - commands::annotations::delete(&cfg, annotation_id).await?; - } - } - } // --- Api --- Commands::Api { endpoint, From 23748eef02761bc2a74fe3b97aedca3473821bc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 09:49:42 +0000 Subject: [PATCH 14/14] fix(auth): request security_monitoring_findings_write scope at login The new `pup security findings mute` command calls mute_security_findings, which the Datadog API gates behind the security_monitoring_findings_write authorization scope. default_scopes() only requested security_monitoring_findings_read, so OAuth2 users would get a 403 on mute while API/app-key users (with full app-key permissions) succeeded. Add security_monitoring_findings_write to default_scopes() so `pup auth login` requests it. Left out of read_only_scopes() since it is a write scope. Co-Authored-By: Claude --- src/auth/types.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/auth/types.rs b/src/auth/types.rs index f86dfcd..168e779 100644 --- a/src/auth/types.rs +++ b/src/auth/types.rs @@ -209,6 +209,7 @@ pub fn default_scopes() -> Vec<&'static str> { "security_monitoring_filters_read", "security_monitoring_filters_write", "security_monitoring_findings_read", + "security_monitoring_findings_write", "security_monitoring_rules_read", "security_monitoring_rules_write", "security_monitoring_signals_read",