From 5038b23b74307983dc6aa8babd954c74339192f6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 06:29:24 +0000 Subject: [PATCH 1/3] fix: use per-page fetch for multi-status queries instead of exhausting all pages When multiple --status values are provided without --all or other client-side filters, the CLI was fetching every bug across all pages for each status before paginating client-side. This caused hangs on repos with thousands of bugs. Now multi-status queries without client-side filters issue one page-sized server request per status and merge the results, matching the fast single-status path. Co-Authored-By: Sachin Iyer --- src/commands/bugs.rs | 53 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index a235e8a..32c9489 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -403,6 +403,33 @@ async fn fetch_all_bugs_multi_status( Ok(combined) } +/// Fetch a single page of bugs for each of `statuses`, concatenated in order. +/// Unlike `fetch_all_bugs_multi_status` this does NOT exhaust every page — +/// it issues one server-side paginated request per status and merges the +/// results. This keeps multi-status queries without `--all` fast even on +/// repos with thousands of bugs. +async fn fetch_page_multi_status( + client: &ApiClient, + repo_id: &RepoId, + statuses: &[BugReviewState], + limit: u32, + page: u32, + scan_id: Option<&ListPublicBugsWorkflowRequestId>, +) -> Result<(Vec, usize)> { + let offset = page_to_offset(page, limit); + let mut combined = Vec::new(); + let mut total: usize = 0; + for status in dedupe_statuses(statuses) { + let response = client + .list_bugs(repo_id, status, limit, offset, scan_id) + .await + .context("Failed to fetch bugs from repository")?; + total += usize::try_from(response.total.max(0)).unwrap_or(0); + combined.extend(response.bugs); + } + Ok((combined, total)) +} + pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { let client = cli.create_client()?; @@ -439,16 +466,17 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { let since_ms = resolve_time_flag("--since", since.as_deref(), now)?; let until_ms = resolve_time_flag("--until", until.as_deref(), now)?; - // The bugs API takes a single status per request. When the user - // asks for several statuses (or `--all`, or any client-side - // filter), fan out and combine — that also forces the all-fetch - // path so client-side filters and pagination see the merged set. + // The bugs API takes a single status per request. When the + // user asks for client-side filters (`--all`, `--vulns`, + // `--introduced-by`, `--since`, `--until`) we must fetch every + // bug to apply them. Multi-status alone does NOT require a full + // fetch — we can issue one page-sized request per status. let needs_full_fetch = *all || *vulns || !introduced_by.is_empty() || since_ms.is_some() - || until_ms.is_some() - || status.len() > 1; + || until_ms.is_some(); + let multi_status = status.len() > 1; if needs_full_fetch { let all_bugs = fetch_all_bugs_multi_status( @@ -495,6 +523,19 @@ pub async fn handle(command: &BugCommands, cli: &crate::Cli) -> Result<()> { } let page_items = paginate_items(&filtered, *page, *limit); output_list(&page_items, total, *page, *limit, format) + } else if multi_status { + // Multiple statuses but no client-side filters: fetch one + // page per status and merge, avoiding a full exhaust. + let (bugs, total) = fetch_page_multi_status( + &client, + &resolved_repo_id, + status, + *limit, + *page, + scan_id.as_ref(), + ) + .await?; + output_list(&bugs, total, *page, *limit, format) } else { // Single-status, no other filters: keep the original // single-page server fetch — cheaper and lets the API drive From 1c8a94de1912075c3c535ce2a3a553c099163658 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 06:35:14 +0000 Subject: [PATCH 2/3] fix: distribute limit across statuses to preserve page-size contract Addresses Devin Review feedback: the previous version fetched limit items per status, so the merged result could contain up to limit * num_statuses items. Now the caller's limit is split evenly across deduped statuses (with remainder distributed to the first statuses), and each status gets its own proportional offset. Co-Authored-By: Sachin Iyer --- src/commands/bugs.rs | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index 32c9489..f531fc8 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -403,11 +403,15 @@ async fn fetch_all_bugs_multi_status( Ok(combined) } -/// Fetch a single page of bugs for each of `statuses`, concatenated in order. +/// Fetch a single page of bugs across multiple `statuses`, concatenated in +/// order. The caller's `limit` is distributed evenly across statuses so the +/// merged result never exceeds `limit` items, preserving the page-size +/// contract. Each status gets its own proportional offset derived from +/// `page` and its share of the limit. +/// /// Unlike `fetch_all_bugs_multi_status` this does NOT exhaust every page — -/// it issues one server-side paginated request per status and merges the -/// results. This keeps multi-status queries without `--all` fast even on -/// repos with thousands of bugs. +/// it issues one bounded request per status and merges the results, keeping +/// multi-status queries fast even on repos with thousands of bugs. async fn fetch_page_multi_status( client: &ApiClient, repo_id: &RepoId, @@ -416,12 +420,19 @@ async fn fetch_page_multi_status( page: u32, scan_id: Option<&ListPublicBugsWorkflowRequestId>, ) -> Result<(Vec, usize)> { - let offset = page_to_offset(page, limit); + let unique = dedupe_statuses(statuses); + let n = u32::try_from(unique.len()).unwrap_or(1).max(1); + let per_status_limit = limit / n; + let remainder = limit % n; + let mut combined = Vec::new(); let mut total: usize = 0; - for status in dedupe_statuses(statuses) { + for (i, status) in unique.into_iter().enumerate() { + let idx = u32::try_from(i).unwrap_or(0); + let sl = per_status_limit + u32::from(idx < remainder); + let offset = page_to_offset(page, sl); let response = client - .list_bugs(repo_id, status, limit, offset, scan_id) + .list_bugs(repo_id, status, sl, offset, scan_id) .await .context("Failed to fetch bugs from repository")?; total += usize::try_from(response.total.max(0)).unwrap_or(0); From e97c5b24cca8e982e5f1d6306ac3c533978babfc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 06:49:30 +0000 Subject: [PATCH 3/3] fix: use over-fetch-and-truncate for multi-status page queries Addresses two issues identified by cubic: 1. Per-status limit could become 0 when limit < num_statuses, potentially causing the API to return unbounded results. 2. Even distribution of limit across statuses produced incorrect pagination when status counts were uneven (e.g. 100 pending + 10 resolved with limit=50 made page 4 unreachable). The new approach fetches up to limit items per status using a shared offset, then truncates the merged result to limit items. This gives every status a fair chance to contribute results while preserving the page-size contract. Co-Authored-By: Sachin Iyer --- src/commands/bugs.rs | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/commands/bugs.rs b/src/commands/bugs.rs index f531fc8..7d739ca 100644 --- a/src/commands/bugs.rs +++ b/src/commands/bugs.rs @@ -404,10 +404,13 @@ async fn fetch_all_bugs_multi_status( } /// Fetch a single page of bugs across multiple `statuses`, concatenated in -/// order. The caller's `limit` is distributed evenly across statuses so the -/// merged result never exceeds `limit` items, preserving the page-size -/// contract. Each status gets its own proportional offset derived from -/// `page` and its share of the limit. +/// order and then truncated to `limit` items. +/// +/// Each status is queried with the full `limit` and a shared `offset` +/// derived from `page` so that every status gets a fair chance to +/// contribute results regardless of how many bugs it contains. The merged +/// list is then truncated to at most `limit` items, preserving the +/// page-size contract. /// /// Unlike `fetch_all_bugs_multi_status` this does NOT exhaust every page — /// it issues one bounded request per status and merges the results, keeping @@ -420,24 +423,19 @@ async fn fetch_page_multi_status( page: u32, scan_id: Option<&ListPublicBugsWorkflowRequestId>, ) -> Result<(Vec, usize)> { - let unique = dedupe_statuses(statuses); - let n = u32::try_from(unique.len()).unwrap_or(1).max(1); - let per_status_limit = limit / n; - let remainder = limit % n; - + let offset = page_to_offset(page, limit); let mut combined = Vec::new(); let mut total: usize = 0; - for (i, status) in unique.into_iter().enumerate() { - let idx = u32::try_from(i).unwrap_or(0); - let sl = per_status_limit + u32::from(idx < remainder); - let offset = page_to_offset(page, sl); + for status in dedupe_statuses(statuses) { let response = client - .list_bugs(repo_id, status, sl, offset, scan_id) + .list_bugs(repo_id, status, limit, offset, scan_id) .await .context("Failed to fetch bugs from repository")?; total += usize::try_from(response.total.max(0)).unwrap_or(0); combined.extend(response.bugs); } + let limit_usize = usize::try_from(limit).unwrap_or(usize::MAX); + combined.truncate(limit_usize); Ok((combined, total)) }