From 19ea3d341d8f4e7dcab8ff5f2c955b21e42692aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 08:44:42 +0000 Subject: [PATCH 1/2] 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 1fd420a5d3f74b286e8b9ab3c843208e6e9aef87 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 09:26:34 +0000 Subject: [PATCH 2/2] 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,