diff --git a/README.md b/README.md index 1c0a113..de0aca9 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ list of commands as built. | HAMR | ✅ | `hamr connections get`, `hamr connections create` | **New** — High Availability Multi-Region connections | | Investigations | ✅ | `investigations list`, `investigations get`, `investigations trigger` | Bits AI SRE investigation management | | Change Management | ✅ | `change-management create`, `change-management get`, `change-management update`, `change-management create-branch`, `change-management decisions` | Change request management with decisions and branching | +| Change Stories | ✅ | `change-stories list` | Change events for a service (deployments, feature flags, config, k8s, watchdog) over a time window | | Incident Services/Teams | ✅ | `incidents services`, `incidents teams` | Service and team CRUD scoped to incident management | | Live Debugger | ✅ | `debugger probes list`, `debugger probes get`, `debugger probes create`, `debugger probes delete`, `debugger probes watch` | Remote log probe management for Live Debugger | | Software Catalog | ✅ | `software-catalog entities list`, `software-catalog entities upsert`, `software-catalog kinds list`, `software-catalog relations list` | Software Catalog entity and kind management (next-gen catalog) | diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index bfd1a51..0a0c97c 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -82,6 +82,7 @@ pup [options] # Nested commands | workflows | get, create, update, delete, run, instances (list, get, cancel), connections (get, create, update, delete) | src/commands/workflows.rs | ✅ | | investigations | list, get, trigger | src/commands/investigations.rs | ✅ | | change-requests | create, get, update, create-branch, decisions (update, delete) | src/commands/change_management.rs | ✅ | +| change-stories | list | src/commands/change_stories.rs | ✅ | | app-builder | list, get, create, update, delete, delete-batch, publish, unpublish | src/commands/app_builder.rs | ✅ | **Note:** RUM command is fully operational. Apps and sessions work completely. Metrics and retention-filters support list/get operations (create/update/delete operations pending due to complex API type structures). @@ -192,6 +193,7 @@ pup infrastructure hosts list - **workflows** - Workflow Automation (get, create, update, delete, run, instances, connections) - **investigations** - Bits AI SRE investigations (list, get, trigger) - **change-requests** - Change request management (create, get, update, create-branch, decisions) +- **change-stories** - Change events for a service (deployments, feature flags, config, k8s, watchdog) over time window ### Organization & Access - **users** - User management (list, get, roles) diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index a3f1df7..f9a4085 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -198,6 +198,40 @@ pup dbm samples search \ --limit=25 ``` +## Change Stories + +### List Change Stories for a Service +```bash +# All change events in the last hour (relative time) +pup change-stories list --service api-gateway --from 1h --to now + +# All change events using an absolute window with unix timestamps +pup change-stories list --service api-gateway --from 1713132000 --to 1713135600 + +# Narrow to deployments in production using an absolute window +pup change-stories list \ + --service api-gateway --env prod \ + --from 2024-01-15T00:00:00Z --to 2024-01-15T01:00:00Z \ + --story-types deployment + +# Multiple story types +pup change-stories list \ + --service api-gateway \ + --from 2024-01-15T00:00:00Z --to 2024-01-15T01:00:00Z \ + --story-types deployment --story-types kubernetes + +# Region-scoped filter (--filter-tags is key:value) +pup change-stories list \ + --service api-gateway \ + --from 2024-01-15T00:00:00Z --to 2024-01-15T01:00:00Z \ + --filter-tags datacenter:us1.prod.dog + +# Trim response to ~4000 tokens (server default is 10000) +pup change-stories list \ + --service api-gateway --from 30min --to now \ + --token-limit 4000 +``` + ## SLOs ### List SLOs diff --git a/src/commands/change_stories.rs b/src/commands/change_stories.rs new file mode 100644 index 0000000..447f4a4 --- /dev/null +++ b/src/commands/change_stories.rs @@ -0,0 +1,342 @@ +use anyhow::Result; + +use crate::client; +use crate::config::Config; +use crate::formatter; +use crate::util; + +#[allow(clippy::too_many_arguments)] +pub async fn list( + cfg: &Config, + service: String, + env: Option, + from: String, + to: String, + story_types: Vec, + filter_tags: Option, + token_limit: Option, +) -> Result<()> { + let from_ms = util::parse_time_to_unix_millis(&from) + .map_err(|e| anyhow::anyhow!("invalid --from: {e}"))?; + let to_ms = + util::parse_time_to_unix_millis(&to).map_err(|e| anyhow::anyhow!("invalid --to: {e}"))?; + let from_ms_str = from_ms.to_string(); + let to_ms_str = to_ms.to_string(); + + let mut query: Vec<(&str, String)> = vec![ + ("service_name", service.clone()), + ("start_ts", from_ms_str), + ("end_ts", to_ms_str), + ]; + if let Some(e) = &env { + if !e.is_empty() { + query.push(("env", e.clone())); + } + } + if let Some(ft) = &filter_tags { + if !ft.is_empty() { + query.push(("filter_tags", ft.clone())); + } + } + for st in &story_types { + query.push(("story_types", st.clone())); + } + if let Some(tl) = token_limit { + query.push(("token_limit", tl.to_string())); + } + + let q_refs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let data = client::raw_get(cfg, "/api/unstable/change-stories/cli", &q_refs).await?; + + let count = data + .get("stories") + .and_then(|v| v.as_array()) + .map(|a| a.len()); + let truncated = data + .get("truncated") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let meta = formatter::Metadata { + count, + truncated, + command: Some("change-stories list".into()), + next_action: None, + }; + + formatter::format_and_print(&data, &cfg.output_format, cfg.agent_mode, Some(&meta)) +} + +#[cfg(test)] +mod tests { + use crate::test_support::*; + + const ISO_FROM: &str = "2024-01-15T00:00:00Z"; + const ISO_TO: &str = "2024-01-15T01:00:00Z"; + + #[tokio::test] + async fn test_list_happy_path() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let mock = server + .mock("GET", "/api/unstable/change-stories/cli") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("service_name".into(), "web".into()), + mockito::Matcher::Regex("start_ts=".into()), + mockito::Matcher::Regex("end_ts=".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"stories":[],"truncated":false}"#) + .create_async() + .await; + + let result = super::list( + &cfg, + "web".into(), + None, + ISO_FROM.into(), + ISO_TO.into(), + vec![], + None, + None, + ) + .await; + assert!(result.is_ok(), "list failed: {:?}", result.err()); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn test_list_with_all_filters() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let mock = server + .mock("GET", "/api/unstable/change-stories/cli") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("service_name".into(), "api".into()), + mockito::Matcher::UrlEncoded("env".into(), "prod".into()), + mockito::Matcher::UrlEncoded("filter_tags".into(), "version:1.2.3".into()), + mockito::Matcher::UrlEncoded("token_limit".into(), "4000".into()), + mockito::Matcher::UrlEncoded("story_types".into(), "deployment".into()), + mockito::Matcher::Regex("start_ts=".into()), + mockito::Matcher::Regex("end_ts=".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"stories":[{"id":"s1"}],"truncated":true}"#) + .create_async() + .await; + + let result = super::list( + &cfg, + "api".into(), + Some("prod".into()), + ISO_FROM.into(), + ISO_TO.into(), + vec!["deployment".into()], + Some("version:1.2.3".into()), + Some(4000), + ) + .await; + assert!(result.is_ok(), "list failed: {:?}", result.err()); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn test_list_repeats_story_types() { + // The gorilla-schema decoder on the server expects `story_types` to be + // repeated once per value; assert reqwest emits the same key twice + // rather than a comma-joined value. + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let mock = server + .mock("GET", "/api/unstable/change-stories/cli") + .match_query(mockito::Matcher::Regex( + r"story_types=deployment.*story_types=feature_flag".into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"stories":[]}"#) + .create_async() + .await; + + let result = super::list( + &cfg, + "api".into(), + None, + ISO_FROM.into(), + ISO_TO.into(), + vec!["deployment".into(), "feature_flag".into()], + None, + None, + ) + .await; + assert!(result.is_ok(), "list failed: {:?}", result.err()); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn test_list_agent_envelope() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let mut cfg = test_config(&server.url()); + cfg.agent_mode = true; + + let mock = server + .mock("GET", "/api/unstable/change-stories/cli") + .match_query(mockito::Matcher::Any) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"stories":[{"id":"s1"},{"id":"s2"}],"truncated":false}"#) + .create_async() + .await; + + let result = super::list( + &cfg, + "web".into(), + None, + ISO_FROM.into(), + ISO_TO.into(), + vec![], + None, + None, + ) + .await; + assert!( + result.is_ok(), + "list in agent mode failed: {:?}", + result.err() + ); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn test_list_accepts_relative_from() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + let mock = server + .mock("GET", "/api/unstable/change-stories/cli") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("service_name".into(), "web".into()), + mockito::Matcher::Regex("start_ts=".into()), + mockito::Matcher::Regex("end_ts=".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"stories":[],"truncated":false}"#) + .create_async() + .await; + + let result = super::list( + &cfg, + "web".into(), + None, + "1h".into(), + "now".into(), + vec![], + None, + None, + ) + .await; + assert!( + result.is_ok(), + "relative --from should be accepted: {:?}", + result.err() + ); + mock.assert_async().await; + cleanup_env(); + } + + #[tokio::test] + async fn test_list_http_error() { + let _lock = lock_env().await; + let mut server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + server + .mock("GET", "/api/unstable/change-stories/cli") + .match_query(mockito::Matcher::Any) + .with_status(400) + .with_header("content-type", "application/json") + .with_body(r#"{"errors":["bad start_ts"]}"#) + .create_async() + .await; + + let result = super::list( + &cfg, + "web".into(), + None, + ISO_FROM.into(), + ISO_TO.into(), + vec![], + None, + None, + ) + .await; + assert!(result.is_err(), "expected error on HTTP 400"); + cleanup_env(); + } + + #[tokio::test] + async fn test_list_rejects_invalid_from() { + let _lock = lock_env().await; + let server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + // Should fail at parse, before any HTTP call. + let result = super::list( + &cfg, + "web".into(), + None, + "not-a-timestamp".into(), + ISO_TO.into(), + vec![], + None, + None, + ) + .await; + assert!(result.is_err(), "expected error on invalid --from"); + assert!( + result.unwrap_err().to_string().contains("--from"), + "error should name --from flag" + ); + cleanup_env(); + } + + #[tokio::test] + async fn test_list_rejects_invalid_to() { + let _lock = lock_env().await; + let server = mockito::Server::new_async().await; + let cfg = test_config(&server.url()); + + // Should fail at parse, before any HTTP call. + let result = super::list( + &cfg, + "web".into(), + None, + ISO_FROM.into(), + "not-a-timestamp".into(), + vec![], + None, + None, + ) + .await; + assert!(result.is_err(), "expected error on invalid --to"); + assert!( + result.unwrap_err().to_string().contains("--to"), + "error should name --to flag" + ); + cleanup_env(); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ef94112..8622813 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -14,6 +14,7 @@ pub mod authn_mappings; pub mod bits; pub mod cases; pub mod change_management; +pub mod change_stories; pub mod cicd; pub mod cloud; pub mod cloud_auth; diff --git a/src/main.rs b/src/main.rs index 0e2d7db..6453afc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -700,6 +700,29 @@ enum Commands { #[command(subcommand)] action: ChangeManagementActions, }, + /// Retrieve change events for an APM service over a time range. + /// Use to correlate changes with anomalies, performance issues or incidents. + /// + /// Tracked change types: deployment (code/version), feature_flag, traffic_anomaly, + /// watchdog (Datadog anomaly detection), kubernetes (k8s deployment manifest updates), + /// scale (manual k8s scale events), crashloopbackoff (k8s crashloops), + /// database (DB schema or setting changes), schema (data stream schemas only; not DB/API schema), + /// configuration (limited to specific tracked sources — absence does not imply no config changed). + /// + /// COMMANDS: + /// list List change stories for a service over a time window + /// + /// EXAMPLES: + /// pup change-stories list --service api --from 2024-01-15T00:00:00Z --to 2024-01-15T01:00:00Z + /// pup change-stories list --service api --env prod --story-types deployment --from 1h --to now + /// + /// AUTHENTICATION: + /// Requires either OAuth2 authentication (pup auth login) or API keys. + #[command(name = "change-stories", verbatim_doc_comment)] + ChangeStories { + #[command(subcommand)] + action: ChangeStoriesActions, + }, /// Manage CI/CD visibility /// /// Manage Datadog CI/CD visibility for pipeline and test monitoring. @@ -5015,6 +5038,44 @@ enum ChangeRequestDecisionActions { }, } +// ---- Change Stories ---- +#[derive(Subcommand)] +enum ChangeStoriesActions { + /// List change stories for a service over a time window + List { + #[arg(long, help = "APM service name (required) - no wildcards (*)")] + service: String, + #[arg( + long, + help = "Start time: 1h, 5min, 2hours, RFC3339, Unix timestamp, or 'now'" + )] + from: String, + #[arg( + long, + help = "End time: 1h, 5min, 2hours, RFC3339, Unix timestamp, or 'now'" + )] + to: String, + #[arg( + long, + help = "Environment filter (e.g. prod). Omit to include all environments" + )] + env: Option, + #[arg( + long = "story-types", + value_delimiter = ',', + help = "Filter by type(s), comma-separated or repeated. Allowed: deployment, feature_flag, configuration, database, kubernetes, scale, crashloopbackoff, traffic_anomaly, schema, watchdog. Omit for all." + )] + story_types: Vec, + #[arg( + long = "filter-tags", + help = "Primary tag (key:value, e.g. datacenter:us1.prod.dog)" + )] + filter_tags: Option, + #[arg(long = "token-limit", help = "Max response tokens (default: 10000)")] + token_limit: Option, + }, +} + // ---- Cloud Authentication ---- #[derive(Subcommand)] enum CloudAuthActions { @@ -12255,6 +12316,33 @@ async fn main_inner() -> anyhow::Result<()> { }, } } + // --- Change Stories --- + Commands::ChangeStories { action } => { + cfg.validate_auth()?; + match action { + ChangeStoriesActions::List { + service, + env, + from, + to, + story_types, + filter_tags, + token_limit, + } => { + commands::change_stories::list( + &cfg, + service, + env, + from, + to, + story_types, + filter_tags, + token_limit, + ) + .await?; + } + } + } // --- Cloud --- Commands::Cloud { action } => { cfg.validate_auth()?;