Skip to content
Merged
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions 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 | 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 | ✅ |
Expand Down Expand Up @@ -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)
Expand Down
137 changes: 137 additions & 0 deletions src/commands/on_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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");
Expand Down
27 changes: 27 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[arg(long, help = "Filter by responder user id (client-side)")]
responder: Option<String>,
#[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")]
Expand Down Expand Up @@ -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?;
}
Expand Down
32 changes: 32 additions & 0 deletions src/test_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down