Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pup <domain> <subgroup> <action> [options] # Nested commands
| downtime | list, get, cancel | src/commands/downtime.rs | ✅ |
| tags | list, get, add, update, delete | src/commands/tags.rs | ✅ |
| events | 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 | ✅ |
Expand Down
95 changes: 95 additions & 0 deletions src/commands/on_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,57 @@ 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/or
/// responder user id (client-side).
///
/// Uses `client::raw_get` against the `unstable` endpoint: `datadog-api-client`
/// exposes no list binding, and the stable `/api/v2/on-call/pages` GET returns an
/// empty collection. The endpoint supports a `filter=team:<handle>` search but has
/// no responder facet, so `--responder` is applied client-side against each page's
/// `relationships.responders`.
pub async fn pages_list(
cfg: &Config,
team: Option<&str>,
responder: Option<&str>,
page_size: u32,
) -> Result<()> {
let size = page_size.to_string();
let team_filter = team.map(|t| format!("team:{t}"));
let mut query: Vec<(&str, &str)> = vec![("page[size]", size.as_str())];
if let Some(f) = team_filter.as_deref() {
query.push(("filter", f));
}

let mut resp = client::raw_get(cfg, "/api/unstable/on-call/pages", &query)
.await
.map_err(|e| anyhow::anyhow!("failed to list pages: {e:?}"))?;

// The endpoint has no server-side responder facet, so filter client-side.
if let Some(rid) = responder {
if let Some(pages) = resp
.get_mut("data")
.and_then(serde_json::Value::as_array_mut)
{
pages.retain(|p| page_has_responder(p, rid));
}
}

formatter::output(cfg, &resp)
}

/// Returns true if `page`'s `relationships.responders` includes user id `rid`.
fn page_has_responder(page: &serde_json::Value, rid: &str) -> bool {
page.get("relationships")
.and_then(|r| r.get("responders"))
.and_then(|r| r.get("data"))
.and_then(serde_json::Value::as_array)
.is_some_and(|users| {
users
.iter()
.any(|u| u.get("id").and_then(serde_json::Value::as_str) == Some(rid))
})
}

#[cfg(test)]
mod tests {
use crate::test_support::*;
Expand Down Expand Up @@ -956,4 +1007,48 @@ mod tests {
.to_string()
.contains("invalid --role value"));
}

#[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());
mock_all(&mut s, r#"{"data": []}"#).await;
let result = super::pages_list(&cfg, Some("the-shadow-collective"), None, 1000).await;
assert!(result.is_ok());
cleanup_env();
}

#[tokio::test]
async fn test_on_call_pages_list_bad_url_errors() {
// Unroutable base URL exercises the error path of the raw GET.
let cfg = test_config("http://127.0.0.1:1");
let result = super::pages_list(&cfg, None, None, 100).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("failed to list pages"));
}

#[test]
fn test_page_has_responder_matches_and_rejects() {
let page = serde_json::json!({
"relationships": { "responders": { "data": [
{ "id": "u-1", "type": "users" },
{ "id": "u-2", "type": "users" }
]}}
});
assert!(super::page_has_responder(&page, "u-2"));
assert!(!super::page_has_responder(&page, "u-3"));
}

#[test]
fn test_page_has_responder_missing_fields() {
assert!(!super::page_has_responder(&serde_json::json!({}), "u-1"));
assert!(!super::page_has_responder(
&serde_json::json!({ "relationships": { "responders": { "data": [] } } }),
"u-1"
));
}
}
26 changes: 26 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6582,6 +6582,19 @@ enum OnCallPagesActions {
},
/// Get an on-call page by ID
Get { page_id: String },
/// List on-call pages, optionally filtered by team handle and/or responder id
List {
#[arg(long, help = "Filter by team handle (server-side)")]
team: Option<String>,
#[arg(long, help = "Filter by responder user id (client-side)")]
responder: Option<String>,
#[arg(
long,
default_value_t = 1000,
help = "Results per page (max 1000; endpoint pagination is unsupported)"
)]
page_size: u32,
},
}

#[derive(Subcommand)]
Expand Down Expand Up @@ -13849,6 +13862,19 @@ async fn main_inner() -> anyhow::Result<()> {
OnCallPagesActions::Get { page_id } => {
commands::on_call::pages_get(&cfg, &page_id).await?;
}
OnCallPagesActions::List {
team,
responder,
page_size,
} => {
commands::on_call::pages_list(
&cfg,
team.as_deref(),
responder.as_deref(),
page_size,
)
.await?;
}
},
}
}
Expand Down