Skip to content

Commit 4bfca5a

Browse files
fix(pagination): address pagination audit — wire dropped flags, validate limits, fix panics
Addresses all issues identified in the endpoint pagination audit: **Silently dropped CLI flags (.. destructure pattern)** - cases: wire --page-number into SearchCasesOptionalParams - on-call: wire --page-number and --sort into GetTeamMembershipsOptionalParams - security: wire --sort into SecurityMonitoringSignalsSort (with bail! for invalid values) - cicd pipelines: wire --branch and --pipeline-name as quoted query filters - cicd events: wire --sort with explicit bail! (no silent fallback) - error-tracking: wire --from, --to, --order-by; rename _limit → limit - investigations: wire --monitor-id as filter_monitor_id when non-zero - metrics: wire --tag-filter into ListActiveMetricsOptionalParams **Silent limit clamping replaced with bail!** - monitors list: replace clamp(1,1000) with !(1..=1000).contains bail!; add page param - monitors search: wire --page, --per-page, --sort - traces: replace limit.min(1000) with bail! for out-of-range values - workflows: replace limit.clamp(1,100) with bail! for out-of-range values **Panic risk (.unwrap()) replaced with ok_or_else** - cicd: DateTime::from_timestamp_millis in pipelines_list and events_search - rum: DateTime::from_timestamp_millis in sessions_search and sessions_list **Additional fixes from review** - on-call: move sort/role validation before resolve_team_id (fail-fast, no wasted API call) - on-call: replace dead `_ => ADMIN` catch-all in memberships_add/update with bail! - on-call: extract parse_team_role helper to avoid duplication - on-call: resolve_team_id uses ok_or_else instead of unwrap_or_default on resp.data - on-call: case-insensitive handle comparison in resolve_team_id - error-tracking: println! → eprintln! for empty-result message - cicd: quote branch/pipeline-name values in query to handle spaces; escape internal quotes - security/cicd: remove silent sort fallback catch-all, add explicit asc/desc aliases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0f0ef07 commit 4bfca5a

12 files changed

Lines changed: 521 additions & 109 deletions

File tree

src/commands/cases.rs

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,16 @@ fn make_api(cfg: &Config) -> CaseManagementAPI {
2929
// Core case operations
3030
// ---------------------------------------------------------------------------
3131

32-
pub async fn search(cfg: &Config, query: Option<String>, page_size: i64) -> Result<()> {
32+
pub async fn search(
33+
cfg: &Config,
34+
query: Option<String>,
35+
page_size: i64,
36+
page_number: i64,
37+
) -> Result<()> {
3338
let api = make_api(cfg);
34-
let mut params = SearchCasesOptionalParams::default().page_size(page_size);
39+
let mut params = SearchCasesOptionalParams::default()
40+
.page_size(page_size)
41+
.page_number(page_number);
3542
if let Some(q) = query {
3643
params = params.filter(q);
3744
}
@@ -372,7 +379,7 @@ mod tests {
372379
let mut s = mockito::Server::new_async().await;
373380
let cfg = test_config(&s.url());
374381
mock_all(&mut s, r#"{"data": []}"#).await;
375-
let _ = super::search(&cfg, None, 10).await;
382+
let _ = super::search(&cfg, None, 10, 0).await;
376383
cleanup_env();
377384
}
378385

src/commands/cicd.rs

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,37 @@ pub async fn pipelines_list(
2525
from: String,
2626
to: String,
2727
limit: i32,
28+
branch: Option<String>,
29+
pipeline_name: Option<String>,
2830
) -> Result<()> {
2931
let api = crate::make_api!(CIVisibilityPipelinesAPI, cfg);
3032

3133
let from_ms = util::parse_time_to_unix_millis(&from)?;
3234
let to_ms = util::parse_time_to_unix_millis(&to)?;
3335
let from_str = chrono::DateTime::from_timestamp_millis(from_ms)
34-
.unwrap()
36+
.ok_or_else(|| anyhow::anyhow!("--from value {from_ms}ms is out of representable range"))?
3537
.to_rfc3339();
3638
let to_str = chrono::DateTime::from_timestamp_millis(to_ms)
37-
.unwrap()
39+
.ok_or_else(|| anyhow::anyhow!("--to value {to_ms}ms is out of representable range"))?
3840
.to_rfc3339();
3941

40-
let mut filter = CIAppPipelinesQueryFilter::new().from(from_str).to(to_str);
42+
let mut query_parts: Vec<String> = Vec::new();
4143
if let Some(q) = query {
42-
filter = filter.query(q);
44+
query_parts.push(q);
45+
}
46+
if let Some(b) = branch {
47+
query_parts.push(format!("@git.branch:\"{}\"", b.replace('"', "\\\"")));
48+
}
49+
if let Some(p) = pipeline_name {
50+
query_parts.push(format!(
51+
"@ci.pipeline.name:\"{}\"",
52+
p.replace('"', "\\\"")
53+
));
54+
}
55+
56+
let mut filter = CIAppPipelinesQueryFilter::new().from(from_str).to(to_str);
57+
if !query_parts.is_empty() {
58+
filter = filter.query(query_parts.join(" "));
4359
}
4460

4561
let body = CIAppPipelineEventsRequest::new()
@@ -88,18 +104,27 @@ pub async fn events_search(
88104
from: String,
89105
to: String,
90106
limit: i32,
107+
sort: String,
91108
) -> Result<()> {
92109
let api = crate::make_api!(CIVisibilityPipelinesAPI, cfg);
93110

94111
let from_ms = util::parse_time_to_unix_millis(&from)?;
95112
let to_ms = util::parse_time_to_unix_millis(&to)?;
96113
let from_str = chrono::DateTime::from_timestamp_millis(from_ms)
97-
.unwrap()
114+
.ok_or_else(|| anyhow::anyhow!("--from value {from_ms}ms is out of representable range"))?
98115
.to_rfc3339();
99116
let to_str = chrono::DateTime::from_timestamp_millis(to_ms)
100-
.unwrap()
117+
.ok_or_else(|| anyhow::anyhow!("--to value {to_ms}ms is out of representable range"))?
101118
.to_rfc3339();
102119

120+
let sort_val = match sort.as_str() {
121+
"asc" | "timestamp" => CIAppSort::TIMESTAMP_ASCENDING,
122+
"desc" | "-timestamp" => CIAppSort::TIMESTAMP_DESCENDING,
123+
other => anyhow::bail!(
124+
"invalid --sort value: {other:?}\nExpected: asc (ascending) or desc (descending)"
125+
),
126+
};
127+
103128
let filter = CIAppPipelinesQueryFilter::new()
104129
.from(from_str)
105130
.to(to_str)
@@ -108,7 +133,7 @@ pub async fn events_search(
108133
let body = CIAppPipelineEventsRequest::new()
109134
.filter(filter)
110135
.page(CIAppQueryPageOptions::new().limit(limit))
111-
.sort(CIAppSort::TIMESTAMP_DESCENDING);
136+
.sort(sort_val);
112137

113138
let params = SearchCIAppPipelineEventsOptionalParams::default().body(body);
114139
let resp = api
@@ -124,10 +149,10 @@ pub async fn events_aggregate(cfg: &Config, query: String, from: String, to: Str
124149
let from_ms = util::parse_time_to_unix_millis(&from)?;
125150
let to_ms = util::parse_time_to_unix_millis(&to)?;
126151
let from_str = chrono::DateTime::from_timestamp_millis(from_ms)
127-
.unwrap()
152+
.ok_or_else(|| anyhow::anyhow!("--from value {from_ms}ms is out of representable range"))?
128153
.to_rfc3339();
129154
let to_str = chrono::DateTime::from_timestamp_millis(to_ms)
130-
.unwrap()
155+
.ok_or_else(|| anyhow::anyhow!("--to value {to_ms}ms is out of representable range"))?
131156
.to_rfc3339();
132157

133158
let filter = CIAppPipelinesQueryFilter::new()
@@ -157,10 +182,10 @@ pub async fn tests_search(
157182
let from_ms = util::parse_time_to_unix_millis(&from)?;
158183
let to_ms = util::parse_time_to_unix_millis(&to)?;
159184
let from_str = chrono::DateTime::from_timestamp_millis(from_ms)
160-
.unwrap()
185+
.ok_or_else(|| anyhow::anyhow!("--from value {from_ms}ms is out of representable range"))?
161186
.to_rfc3339();
162187
let to_str = chrono::DateTime::from_timestamp_millis(to_ms)
163-
.unwrap()
188+
.ok_or_else(|| anyhow::anyhow!("--to value {to_ms}ms is out of representable range"))?
164189
.to_rfc3339();
165190

166191
let filter = CIAppTestsQueryFilter::new()
@@ -187,10 +212,10 @@ pub async fn tests_aggregate(cfg: &Config, query: String, from: String, to: Stri
187212
let from_ms = util::parse_time_to_unix_millis(&from)?;
188213
let to_ms = util::parse_time_to_unix_millis(&to)?;
189214
let from_str = chrono::DateTime::from_timestamp_millis(from_ms)
190-
.unwrap()
215+
.ok_or_else(|| anyhow::anyhow!("--from value {from_ms}ms is out of representable range"))?
191216
.to_rfc3339();
192217
let to_str = chrono::DateTime::from_timestamp_millis(to_ms)
193-
.unwrap()
218+
.ok_or_else(|| anyhow::anyhow!("--to value {to_ms}ms is out of representable range"))?
194219
.to_rfc3339();
195220

196221
let filter = CIAppTestsQueryFilter::new()
@@ -314,7 +339,7 @@ mod tests {
314339
let mut s = mockito::Server::new_async().await;
315340
let cfg = test_config(&s.url());
316341
mock_all(&mut s, r#"{"data": []}"#).await;
317-
let _ = super::pipelines_list(&cfg, None, "1h".into(), "now".into(), 10).await;
342+
let _ = super::pipelines_list(&cfg, None, "1h".into(), "now".into(), 10, None, None).await;
318343
cleanup_env();
319344
}
320345

@@ -358,4 +383,17 @@ mod tests {
358383
.contains("invalid sort value"));
359384
cleanup_env();
360385
}
386+
387+
#[tokio::test]
388+
async fn test_cicd_events_search_invalid_sort() {
389+
let cfg = test_config("http://unused.local");
390+
let result =
391+
super::events_search(&cfg, "*".into(), "1h".into(), "now".into(), 10, "bogus".into())
392+
.await;
393+
assert!(result.is_err());
394+
assert!(result
395+
.unwrap_err()
396+
.to_string()
397+
.contains("invalid --sort value"));
398+
}
361399
}

src/commands/error_tracking.rs

Lines changed: 87 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,46 @@
11
use anyhow::Result;
2-
use chrono::Utc;
32
use datadog_api_client::datadogV2::api_error_tracking::{
43
ErrorTrackingAPI, GetIssueOptionalParams, SearchIssuesOptionalParams,
54
};
65
use datadog_api_client::datadogV2::model::{
76
IssuesSearchRequest, IssuesSearchRequestData, IssuesSearchRequestDataAttributes,
8-
IssuesSearchRequestDataAttributesPersona, IssuesSearchRequestDataAttributesTrack,
9-
IssuesSearchRequestDataType,
7+
IssuesSearchRequestDataAttributesOrderBy, IssuesSearchRequestDataAttributesPersona,
8+
IssuesSearchRequestDataAttributesTrack, IssuesSearchRequestDataType,
109
};
1110

1211
use crate::config::Config;
1312
use crate::formatter;
13+
use crate::util;
1414

15+
#[allow(clippy::too_many_arguments)]
1516
pub async fn issues_search(
1617
cfg: &Config,
1718
query: Option<String>,
18-
_limit: i32,
19+
limit: i32,
20+
from: String,
21+
to: String,
22+
order_by: String,
1923
track: Option<String>,
2024
persona: Option<String>,
2125
) -> Result<()> {
2226
let api = crate::make_api!(ErrorTrackingAPI, cfg);
2327

24-
let now = Utc::now().timestamp_millis();
25-
let one_day_ago = now - 86_400_000; // 24 hours in millis
28+
let from_ms = util::parse_time_to_unix_millis(&from)?;
29+
let to_ms = util::parse_time_to_unix_millis(&to)?;
30+
31+
let order_by_val = match order_by.to_uppercase().as_str() {
32+
"TOTAL_COUNT" => IssuesSearchRequestDataAttributesOrderBy::TOTAL_COUNT,
33+
"FIRST_SEEN" => IssuesSearchRequestDataAttributesOrderBy::FIRST_SEEN,
34+
"IMPACTED_SESSIONS" => IssuesSearchRequestDataAttributesOrderBy::IMPACTED_SESSIONS,
35+
"PRIORITY" => IssuesSearchRequestDataAttributesOrderBy::PRIORITY,
36+
other => anyhow::bail!(
37+
"invalid --order-by value: {other:?}\nExpected: TOTAL_COUNT, FIRST_SEEN, IMPACTED_SESSIONS, PRIORITY"
38+
),
39+
};
2640

2741
let query_str = query.unwrap_or_else(|| "*".to_string());
28-
let mut attrs = IssuesSearchRequestDataAttributes::new(one_day_ago, query_str, now);
42+
let mut attrs =
43+
IssuesSearchRequestDataAttributes::new(from_ms, query_str, to_ms).order_by(order_by_val);
2944
if let Some(ref t) = track {
3045
let track_value = match t.to_lowercase().as_str() {
3146
"trace" => IssuesSearchRequestDataAttributesTrack::TRACE,
@@ -55,17 +70,22 @@ pub async fn issues_search(
5570
let body = IssuesSearchRequest::new(data);
5671
let params = SearchIssuesOptionalParams::default();
5772

58-
let resp = api
73+
let mut resp = api
5974
.search_issues(body, params)
6075
.await
6176
.map_err(|e| anyhow::anyhow!("failed to search issues: {e:?}"))?;
62-
let val = serde_json::to_value(&resp)?;
63-
if let Some(data) = val.get("data") {
64-
if data.as_array().is_some_and(|a| a.is_empty()) {
65-
println!("No error tracking issues found matching the specified criteria.");
66-
return Ok(());
77+
78+
if resp.data.as_ref().is_some_and(|d| d.is_empty()) {
79+
eprintln!("No error tracking issues found matching the specified criteria.");
80+
return Ok(());
81+
}
82+
83+
if limit > 0 {
84+
if let Some(data) = resp.data.as_mut() {
85+
data.truncate(limit as usize);
6786
}
6887
}
88+
6989
formatter::output(cfg, &resp)
7090
}
7191

@@ -90,7 +110,17 @@ mod tests {
90110
let mut s = mockito::Server::new_async().await;
91111
let cfg = test_config(&s.url());
92112
mock_all(&mut s, r#"{"data": []}"#).await;
93-
let _ = super::issues_search(&cfg, None, 10, Some("trace".into()), None).await;
113+
let _ = super::issues_search(
114+
&cfg,
115+
None,
116+
10,
117+
"1d".into(),
118+
"now".into(),
119+
"TOTAL_COUNT".into(),
120+
Some("trace".into()),
121+
None,
122+
)
123+
.await;
94124
cleanup_env();
95125
}
96126

@@ -100,7 +130,17 @@ mod tests {
100130
let mut s = mockito::Server::new_async().await;
101131
let cfg = test_config(&s.url());
102132
mock_all(&mut s, r#"{"data": []}"#).await;
103-
let _ = super::issues_search(&cfg, None, 10, None, Some("BROWSER".into())).await;
133+
let _ = super::issues_search(
134+
&cfg,
135+
None,
136+
10,
137+
"1d".into(),
138+
"now".into(),
139+
"TOTAL_COUNT".into(),
140+
None,
141+
Some("BROWSER".into()),
142+
)
143+
.await;
104144
cleanup_env();
105145
}
106146

@@ -110,10 +150,41 @@ mod tests {
110150
let mut s = mockito::Server::new_async().await;
111151
let cfg = test_config(&s.url());
112152
mock_all(&mut s, r#"{"data": []}"#).await;
113-
let _ = super::issues_search(&cfg, None, 10, Some("RUM".into()), None).await;
153+
let _ = super::issues_search(
154+
&cfg,
155+
None,
156+
10,
157+
"1d".into(),
158+
"now".into(),
159+
"TOTAL_COUNT".into(),
160+
Some("RUM".into()),
161+
None,
162+
)
163+
.await;
114164
cleanup_env();
115165
}
116166

167+
#[tokio::test]
168+
async fn test_error_tracking_issues_search_invalid_order_by() {
169+
let cfg = test_config("http://unused.local");
170+
let result = super::issues_search(
171+
&cfg,
172+
None,
173+
10,
174+
"1d".into(),
175+
"now".into(),
176+
"INVALID".into(),
177+
Some("trace".into()),
178+
None,
179+
)
180+
.await;
181+
assert!(result.is_err());
182+
assert!(result
183+
.unwrap_err()
184+
.to_string()
185+
.contains("invalid --order-by value"));
186+
}
187+
117188
#[test]
118189
fn test_error_tracking_clap_mutual_exclusivity() {
119190
let result = crate::Cli::command().try_get_matches_from([

src/commands/investigations.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@ fn make_api(cfg: &Config) -> BitsAIAPI {
99
crate::make_api!(BitsAIAPI, cfg)
1010
}
1111

12-
pub async fn list(cfg: &Config, page_limit: i64, page_offset: i64) -> Result<()> {
12+
pub async fn list(cfg: &Config, page_limit: i64, page_offset: i64, monitor_id: i64) -> Result<()> {
1313
let api = make_api(cfg);
14-
let params = ListInvestigationsOptionalParams::default()
14+
let mut params = ListInvestigationsOptionalParams::default()
1515
.page_limit(page_limit)
1616
.page_offset(page_offset);
17+
if monitor_id != 0 {
18+
params = params.filter_monitor_id(monitor_id);
19+
}
1720
let resp = api
1821
.list_investigations(params)
1922
.await
@@ -52,7 +55,7 @@ mod tests {
5255
let mut s = mockito::Server::new_async().await;
5356
let cfg = test_config(&s.url());
5457
mock_all(&mut s, r#"{"data": []}"#).await;
55-
let _ = super::list(&cfg, 10, 0).await;
58+
let _ = super::list(&cfg, 10, 0, 0).await;
5659
cleanup_env();
5760
}
5861

src/commands/metrics.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,19 @@ use crate::config::Config;
4040
use crate::formatter;
4141
use crate::util;
4242

43-
pub async fn list(cfg: &Config, filter: Option<String>, from: String) -> Result<()> {
43+
pub async fn list(
44+
cfg: &Config,
45+
filter: Option<String>,
46+
from: String,
47+
tag_filter: Option<String>,
48+
) -> Result<()> {
4449
let api = crate::make_api!(MetricsV1API, cfg);
4550

4651
let from_ts = util::parse_time_to_unix(&from)?;
47-
let params = ListActiveMetricsOptionalParams::default();
52+
let mut params = ListActiveMetricsOptionalParams::default();
53+
if let Some(tf) = tag_filter {
54+
params = params.tag_filter(tf);
55+
}
4856

4957
let resp = api
5058
.list_active_metrics(from_ts, params)
@@ -199,7 +207,7 @@ mod tests {
199207
)
200208
.await;
201209

202-
let result = super::list(&cfg, None, "1h".into()).await;
210+
let result = super::list(&cfg, None, "1h".into(), None).await;
203211
assert!(result.is_ok(), "metrics list failed: {:?}", result.err());
204212
cleanup_env();
205213
}

0 commit comments

Comments
 (0)