diff --git a/README.md b/README.md index 253722e4..555b7a09 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ list of commands as built. | API Domain | Status | Pup Commands | Notes | |------------|--------|--------------|-------| | Incidents | ✅ | `incidents list`, `incidents get`, `incidents attachments`, `incidents settings`, `incidents handles`, `incidents postmortem-templates` | Incident management with settings, handles, and postmortem templates | -| On-Call (Teams) | ✅ | `on-call teams` (CRUD, memberships with roles) | Full team management system with admin/member roles | +| On-Call | ✅ | `on-call teams` (CRUD, memberships with roles), `on-call pages` (list, get, create) | Team management and on-call page access | | Case Management | ✅ | `cases` (create, search, assign, archive, projects, jira, servicenow, move) | Complete case management with Jira/ServiceNow linking | | Error Tracking | ✅ | `error-tracking issues search`, `error-tracking issues get` | Error issue search and details | | Service Catalog | ✅ | `service-catalog list`, `service-catalog get` | Service registry management | diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index b70acfaa..792cdc0d 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -39,7 +39,7 @@ pup [options] # Nested commands | downtime | list, get, cancel | src/commands/downtime.rs | ✅ | | tags | list, get, add, update, delete | src/commands/tags.rs | ✅ | | events | post, list, search, get | src/commands/events.rs | ✅ | -| on-call | teams (CRUD, memberships) | src/commands/on_call.rs | ✅ | +| on-call | teams (CRUD, memberships), pages (list, get, create) | src/commands/on_call.rs | ✅ | | audit-logs | list, search | src/commands/audit_logs.rs | ✅ | | api-keys | list, get, create, delete | src/commands/api_keys.rs | ✅ | | app-keys | list, get, create, update, delete | src/commands/app_keys.rs | ✅ | @@ -187,7 +187,7 @@ pup infrastructure hosts list ### Operations & Incident Response - **incidents** - Incident management (list, get, attachments, settings, handles, postmortem-templates) -- **on-call** - Team management (create, update, delete teams; manage memberships with roles) +- **on-call** - Team management (create, update, delete teams; manage memberships with roles) and pages (list, get, create) - **cases** - Case management (create, search, assign, archive, unarchive, update, projects, jira, servicenow, move) - **hamr** - High Availability Multi-Region connections - **fleet** - Fleet Automation (agents, deployments, schedules, tracers, clusters, instrumented-pods) diff --git a/src/commands/on_call.rs b/src/commands/on_call.rs index e26cc05f..b2986d12 100644 --- a/src/commands/on_call.rs +++ b/src/commands/on_call.rs @@ -469,6 +469,61 @@ pub async fn pages_get(cfg: &Config, page_id: &str) -> Result<()> { formatter::output(cfg, &resp) } +/// Lists on-call pages, optionally filtered by team handle (server-side) and +/// responder user id (client-side). +/// +/// Uses `raw_client::raw_get` against the unstable endpoint because +/// `datadog-api-client` exposes no list binding, and the stable v2 collection +/// endpoint currently returns an empty body. +pub async fn pages_list( + cfg: &Config, + team: Option<&str>, + responder: Option<&str>, + page_size: u32, +) -> Result<()> { + if !(1..=1000).contains(&page_size) { + anyhow::bail!("invalid page_size: {page_size}. Expected a value from 1 to 1000"); + } + + let page_size = page_size.to_string(); + let team_filter = team.map(|t| format!("team:{t}")); + let mut query = vec![("page[size]", page_size.as_str())]; + if let Some(filter) = team_filter.as_deref() { + query.push(("filter", filter)); + } + + let mut resp = raw_client::raw_get(cfg, "/api/unstable/on-call/pages", &query) + .await + .map_err(|e| anyhow::anyhow!("failed to list pages: {e:?}"))?; + + if let Some(responder) = responder { + filter_pages_by_responder(&mut resp, responder); + } + + formatter::output(cfg, &resp) +} + +fn filter_pages_by_responder(resp: &mut serde_json::Value, responder: &str) { + if let Some(pages) = resp + .get_mut("data") + .and_then(serde_json::Value::as_array_mut) + { + pages.retain(|page| page_has_responder(page, responder)); + } +} + +fn page_has_responder(page: &serde_json::Value, responder: &str) -> bool { + page.get("relationships") + .and_then(|relationships| relationships.get("responders")) + .and_then(|responders| responders.get("data")) + .and_then(serde_json::Value::as_array) + .is_some_and(|responders| { + responders.iter().any(|responder_ref| { + responder_ref.get("id").and_then(serde_json::Value::as_str) == Some(responder) + }) + }) +} + #[cfg(test)] mod tests { use crate::test_support::*; @@ -865,6 +920,88 @@ mod tests { cleanup_env(); } + #[tokio::test] + async fn test_on_call_pages_list_by_team() { + let _lock = lock_env().await; + let mut s = mockito::Server::new_async().await; + let cfg = test_config(&s.url()); + let mock = s + .mock("GET", "/api/unstable/on-call/pages") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("page[size]".into(), "42".into()), + mockito::Matcher::UrlEncoded("filter".into(), "team:core-platform".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"data": []}"#) + .create_async() + .await; + let result = super::pages_list(&cfg, Some("core-platform"), None, 42).await; + assert!(result.is_ok(), "pages_list failed: {:?}", result.err()); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn test_on_call_pages_list_rejects_invalid_page_size() { + let _lock = lock_env().await; + let cfg = test_config("http://unused.local"); + let result = super::pages_list(&cfg, None, None, 0).await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid page_size")); + cleanup_env(); + } + + #[test] + fn test_page_has_responder_matches_and_rejects() { + let page = serde_json::json!({ + "relationships": { + "responders": { + "data": [ + { "id": "user-1", "type": "users" }, + { "id": "user-2", "type": "users" } + ] + } + } + }); + + assert!(super::page_has_responder(&page, "user-2")); + assert!(!super::page_has_responder(&page, "user-3")); + } + + #[test] + fn test_filter_pages_by_responder() { + let mut resp = serde_json::json!({ + "data": [ + { + "id": "page-1", + "relationships": { + "responders": { + "data": [{ "id": "user-1", "type": "users" }] + } + } + }, + { + "id": "page-2", + "relationships": { + "responders": { + "data": [{ "id": "user-2", "type": "users" }] + } + } + }, + { "id": "page-3" } + ] + }); + + super::filter_pages_by_responder(&mut resp, "user-2"); + let pages = resp["data"].as_array().unwrap(); + assert_eq!(pages.len(), 1); + assert_eq!(pages[0]["id"], "page-2"); + } + #[tokio::test] async fn test_memberships_list_invalid_sort() { let cfg = test_config("http://unused.local"); diff --git a/src/main.rs b/src/main.rs index 6964cb1f..7acb4557 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6648,6 +6648,20 @@ enum OnCallNotificationRulesActions { #[derive(Subcommand)] enum OnCallPagesActions { + /// List on-call pages, optionally filtered by team handle and/or responder user id + List { + #[arg(long, help = "Filter by team handle (server-side)")] + team: Option, + #[arg(long, help = "Filter by responder user id (client-side)")] + responder: Option, + #[arg( + long, + default_value_t = 1000, + value_parser = clap::value_parser!(u32).range(1..=1000), + help = "Results per page (1-1000; endpoint pagination is unsupported)" + )] + page_size: u32, + }, /// Create an on-call page from a JSON file Create { #[arg(long, help = "Path to JSON file")] @@ -14050,6 +14064,19 @@ async fn main_inner() -> anyhow::Result<()> { } }, OnCallActions::Pages { action } => match action { + OnCallPagesActions::List { + team, + responder, + page_size, + } => { + commands::on_call::pages_list( + &cfg, + team.as_deref(), + responder.as_deref(), + page_size, + ) + .await?; + } OnCallPagesActions::Create { file } => { commands::on_call::pages_create(&cfg, &file).await?; } diff --git a/src/test_commands.rs b/src/test_commands.rs index 489dc161..a50d2175 100644 --- a/src/test_commands.rs +++ b/src/test_commands.rs @@ -87,6 +87,38 @@ fn test_read_only_guard_nested_read() { assert!(!crate::is_write_command_name(&leaf)); } +#[test] +fn test_read_only_guard_on_call_pages_list() { + let matches = crate::Cli::command() + .try_get_matches_from([ + "pup", + "on-call", + "pages", + "list", + "--team", + "core-platform", + "--responder", + "user-1", + ]) + .unwrap(); + let leaf = crate::get_leaf_subcommand_name(&matches).unwrap(); + assert_eq!(leaf, "list"); + assert!(!crate::is_write_command_name(&leaf)); +} + +#[test] +fn test_on_call_pages_list_rejects_invalid_page_size() { + let result = crate::Cli::command().try_get_matches_from([ + "pup", + "on-call", + "pages", + "list", + "--page-size", + "0", + ]); + assert!(result.is_err()); +} + #[test] fn test_read_only_guard_nested_write() { let matches = crate::Cli::command()