Skip to content

Commit 763f97e

Browse files
committed
feat(on-call): add pages list
1 parent 001239b commit 763f97e

5 files changed

Lines changed: 199 additions & 3 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ list of commands as built.
123123
| API Domain | Status | Pup Commands | Notes |
124124
|------------|--------|--------------|-------|
125125
| Incidents || `incidents list`, `incidents get`, `incidents attachments`, `incidents settings`, `incidents handles`, `incidents postmortem-templates` | Incident management with settings, handles, and postmortem templates |
126-
| On-Call (Teams) || `on-call teams` (CRUD, memberships with roles) | Full team management system with admin/member roles |
126+
| On-Call || `on-call teams` (CRUD, memberships with roles), `on-call pages` (list, get, create) | Team management and on-call page access |
127127
| Case Management || `cases` (create, search, assign, archive, projects, jira, servicenow, move) | Complete case management with Jira/ServiceNow linking |
128128
| Error Tracking || `error-tracking issues search`, `error-tracking issues get` | Error issue search and details |
129129
| Service Catalog || `service-catalog list`, `service-catalog get` | Service registry management |

docs/COMMANDS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pup <domain> <subgroup> <action> [options] # Nested commands
3939
| downtime | list, get, cancel | src/commands/downtime.rs ||
4040
| tags | list, get, add, update, delete | src/commands/tags.rs ||
4141
| events | post, list, search, get | src/commands/events.rs ||
42-
| on-call | teams (CRUD, memberships) | src/commands/on_call.rs ||
42+
| on-call | teams (CRUD, memberships), pages (list, get, create) | src/commands/on_call.rs ||
4343
| audit-logs | list, search | src/commands/audit_logs.rs ||
4444
| api-keys | list, get, create, delete | src/commands/api_keys.rs ||
4545
| app-keys | list, get, create, update, delete | src/commands/app_keys.rs ||
@@ -187,7 +187,7 @@ pup infrastructure hosts list
187187

188188
### Operations & Incident Response
189189
- **incidents** - Incident management (list, get, attachments, settings, handles, postmortem-templates)
190-
- **on-call** - Team management (create, update, delete teams; manage memberships with roles)
190+
- **on-call** - Team management (create, update, delete teams; manage memberships with roles) and pages (list, get, create)
191191
- **cases** - Case management (create, search, assign, archive, unarchive, update, projects, jira, servicenow, move)
192192
- **hamr** - High Availability Multi-Region connections
193193
- **fleet** - Fleet Automation (agents, deployments, schedules, tracers, clusters, instrumented-pods)

src/commands/on_call.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,61 @@ pub async fn pages_get(cfg: &Config, page_id: &str) -> Result<()> {
469469
formatter::output(cfg, &resp)
470470
}
471471

472+
/// Lists on-call pages, optionally filtered by team handle (server-side) and
473+
/// responder user id (client-side).
474+
///
475+
/// Uses `raw_client::raw_get` against the unstable endpoint because
476+
/// `datadog-api-client` exposes no list binding, and the stable v2 collection
477+
/// endpoint currently returns an empty body.
478+
pub async fn pages_list(
479+
cfg: &Config,
480+
team: Option<&str>,
481+
responder: Option<&str>,
482+
page_size: u32,
483+
) -> Result<()> {
484+
if !(1..=1000).contains(&page_size) {
485+
anyhow::bail!("invalid page_size: {page_size}. Expected a value from 1 to 1000");
486+
}
487+
488+
let page_size = page_size.to_string();
489+
let team_filter = team.map(|t| format!("team:{t}"));
490+
let mut query = vec![("page[size]", page_size.as_str())];
491+
if let Some(filter) = team_filter.as_deref() {
492+
query.push(("filter", filter));
493+
}
494+
495+
let mut resp = raw_client::raw_get(cfg, "/api/unstable/on-call/pages", &query)
496+
.await
497+
.map_err(|e| anyhow::anyhow!("failed to list pages: {e:?}"))?;
498+
499+
if let Some(responder) = responder {
500+
filter_pages_by_responder(&mut resp, responder);
501+
}
502+
503+
formatter::output(cfg, &resp)
504+
}
505+
506+
fn filter_pages_by_responder(resp: &mut serde_json::Value, responder: &str) {
507+
if let Some(pages) = resp
508+
.get_mut("data")
509+
.and_then(serde_json::Value::as_array_mut)
510+
{
511+
pages.retain(|page| page_has_responder(page, responder));
512+
}
513+
}
514+
515+
fn page_has_responder(page: &serde_json::Value, responder: &str) -> bool {
516+
page.get("relationships")
517+
.and_then(|relationships| relationships.get("responders"))
518+
.and_then(|responders| responders.get("data"))
519+
.and_then(serde_json::Value::as_array)
520+
.is_some_and(|responders| {
521+
responders.iter().any(|responder_ref| {
522+
responder_ref.get("id").and_then(serde_json::Value::as_str) == Some(responder)
523+
})
524+
})
525+
}
526+
472527
#[cfg(test)]
473528
mod tests {
474529
use crate::test_support::*;
@@ -865,6 +920,88 @@ mod tests {
865920
cleanup_env();
866921
}
867922

923+
#[tokio::test]
924+
async fn test_on_call_pages_list_by_team() {
925+
let _lock = lock_env().await;
926+
let mut s = mockito::Server::new_async().await;
927+
let cfg = test_config(&s.url());
928+
let mock = s
929+
.mock("GET", "/api/unstable/on-call/pages")
930+
.match_query(mockito::Matcher::AllOf(vec![
931+
mockito::Matcher::UrlEncoded("page[size]".into(), "42".into()),
932+
mockito::Matcher::UrlEncoded("filter".into(), "team:core-platform".into()),
933+
]))
934+
.with_status(200)
935+
.with_header("content-type", "application/json")
936+
.with_body(r#"{"data": []}"#)
937+
.create_async()
938+
.await;
939+
let result = super::pages_list(&cfg, Some("core-platform"), None, 42).await;
940+
assert!(result.is_ok(), "pages_list failed: {:?}", result.err());
941+
mock.assert_async().await;
942+
cleanup_env();
943+
}
944+
945+
#[tokio::test]
946+
async fn test_on_call_pages_list_rejects_invalid_page_size() {
947+
let _lock = lock_env().await;
948+
let cfg = test_config("http://unused.local");
949+
let result = super::pages_list(&cfg, None, None, 0).await;
950+
assert!(result.is_err());
951+
assert!(result
952+
.unwrap_err()
953+
.to_string()
954+
.contains("invalid page_size"));
955+
cleanup_env();
956+
}
957+
958+
#[test]
959+
fn test_page_has_responder_matches_and_rejects() {
960+
let page = serde_json::json!({
961+
"relationships": {
962+
"responders": {
963+
"data": [
964+
{ "id": "user-1", "type": "users" },
965+
{ "id": "user-2", "type": "users" }
966+
]
967+
}
968+
}
969+
});
970+
971+
assert!(super::page_has_responder(&page, "user-2"));
972+
assert!(!super::page_has_responder(&page, "user-3"));
973+
}
974+
975+
#[test]
976+
fn test_filter_pages_by_responder() {
977+
let mut resp = serde_json::json!({
978+
"data": [
979+
{
980+
"id": "page-1",
981+
"relationships": {
982+
"responders": {
983+
"data": [{ "id": "user-1", "type": "users" }]
984+
}
985+
}
986+
},
987+
{
988+
"id": "page-2",
989+
"relationships": {
990+
"responders": {
991+
"data": [{ "id": "user-2", "type": "users" }]
992+
}
993+
}
994+
},
995+
{ "id": "page-3" }
996+
]
997+
});
998+
999+
super::filter_pages_by_responder(&mut resp, "user-2");
1000+
let pages = resp["data"].as_array().unwrap();
1001+
assert_eq!(pages.len(), 1);
1002+
assert_eq!(pages[0]["id"], "page-2");
1003+
}
1004+
8681005
#[tokio::test]
8691006
async fn test_memberships_list_invalid_sort() {
8701007
let cfg = test_config("http://unused.local");

src/main.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6648,6 +6648,20 @@ enum OnCallNotificationRulesActions {
66486648

66496649
#[derive(Subcommand)]
66506650
enum OnCallPagesActions {
6651+
/// List on-call pages, optionally filtered by team handle and/or responder user id
6652+
List {
6653+
#[arg(long, help = "Filter by team handle (server-side)")]
6654+
team: Option<String>,
6655+
#[arg(long, help = "Filter by responder user id (client-side)")]
6656+
responder: Option<String>,
6657+
#[arg(
6658+
long,
6659+
default_value_t = 1000,
6660+
value_parser = clap::value_parser!(u32).range(1..=1000),
6661+
help = "Results per page (1-1000; endpoint pagination is unsupported)"
6662+
)]
6663+
page_size: u32,
6664+
},
66516665
/// Create an on-call page from a JSON file
66526666
Create {
66536667
#[arg(long, help = "Path to JSON file")]
@@ -14050,6 +14064,19 @@ async fn main_inner() -> anyhow::Result<()> {
1405014064
}
1405114065
},
1405214066
OnCallActions::Pages { action } => match action {
14067+
OnCallPagesActions::List {
14068+
team,
14069+
responder,
14070+
page_size,
14071+
} => {
14072+
commands::on_call::pages_list(
14073+
&cfg,
14074+
team.as_deref(),
14075+
responder.as_deref(),
14076+
page_size,
14077+
)
14078+
.await?;
14079+
}
1405314080
OnCallPagesActions::Create { file } => {
1405414081
commands::on_call::pages_create(&cfg, &file).await?;
1405514082
}

src/test_commands.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,38 @@ fn test_read_only_guard_nested_read() {
8787
assert!(!crate::is_write_command_name(&leaf));
8888
}
8989

90+
#[test]
91+
fn test_read_only_guard_on_call_pages_list() {
92+
let matches = crate::Cli::command()
93+
.try_get_matches_from([
94+
"pup",
95+
"on-call",
96+
"pages",
97+
"list",
98+
"--team",
99+
"core-platform",
100+
"--responder",
101+
"user-1",
102+
])
103+
.unwrap();
104+
let leaf = crate::get_leaf_subcommand_name(&matches).unwrap();
105+
assert_eq!(leaf, "list");
106+
assert!(!crate::is_write_command_name(&leaf));
107+
}
108+
109+
#[test]
110+
fn test_on_call_pages_list_rejects_invalid_page_size() {
111+
let result = crate::Cli::command().try_get_matches_from([
112+
"pup",
113+
"on-call",
114+
"pages",
115+
"list",
116+
"--page-size",
117+
"0",
118+
]);
119+
assert!(result.is_err());
120+
}
121+
90122
#[test]
91123
fn test_read_only_guard_nested_write() {
92124
let matches = crate::Cli::command()

0 commit comments

Comments
 (0)