From 03d2b5ca0122c0f9af0322ec3b9c494a230740e0 Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Thu, 23 Jul 2026 17:06:11 -0500 Subject: [PATCH 1/3] fix(initiatives): paginate projects in `initiatives get` The `initiative(id:) { projects { nodes } }` selection relies on the Linear API's default connection page size of 50 and requests no `pageInfo`, so `initiatives get` silently truncates an initiative's project list to its first 50 members. An initiative with more than 50 projects reports a partial page as if it were the full set. Fetch the initiative's scalar fields and its projects in two steps: scalars via the original single query, then the `projects` connection via `paginate_nodes` with `first`/`after` cursors and `pageInfo`. A single-initiative detail view should return the full membership, so default to fetching every page while still honoring an explicit `--limit` / `--all`. The emitted JSON shape (`projects.nodes`) is unchanged. Verified against a real initiative with 162 projects: previously returned 50, now returns all 162. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/initiatives.rs | 70 +++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/src/commands/initiatives.rs b/src/commands/initiatives.rs index 44a00dc..7c516f9 100644 --- a/src/commands/initiatives.rs +++ b/src/commands/initiatives.rs @@ -5,7 +5,7 @@ use tabled::{Table, Tabled}; use crate::api::LinearClient; use crate::output::{print_json, print_json_owned, OutputOptions}; -use crate::pagination::PaginationOptions; +use crate::pagination::{paginate_nodes, PaginationOptions}; use crate::text::truncate; use crate::types::Initiative; use crate::DISPLAY_OPTIONS; @@ -79,7 +79,7 @@ pub async fn handle( ) -> Result<()> { match cmd { InitiativeCommands::List => list_initiatives(output, pagination).await, - InitiativeCommands::Get { id } => get_initiative(&id, output).await, + InitiativeCommands::Get { id } => get_initiative(&id, output, pagination).await, InitiativeCommands::Create { name, description, @@ -171,9 +171,17 @@ async fn list_initiatives(output: &OutputOptions, pagination: &PaginationOptions Ok(()) } -async fn get_initiative(id: &str, output: &OutputOptions) -> Result<()> { +async fn get_initiative( + id: &str, + output: &OutputOptions, + pagination: &PaginationOptions, +) -> Result<()> { let client = LinearClient::new()?; + // Fetch the initiative's scalar fields first. The nested `projects` + // connection is fetched separately below because a plain `projects { nodes }` + // selection is silently capped by the API at 50 with no pagination — an + // initiative with more than 50 projects would report a truncated first page. let query = r#" query($id: String!) { initiative(id: $id) { @@ -184,26 +192,66 @@ async fn get_initiative(id: &str, output: &OutputOptions) -> Result<()> { sortOrder createdAt updatedAt - projects { + } + } + "#; + + let result = client.query(query, Some(json!({ "id": id }))).await?; + let initiative = &result["data"]["initiative"]; + + if initiative.is_null() { + anyhow::bail!("Initiative not found: {}", id); + } + let mut initiative = initiative.clone(); + + // Page through every project in the initiative. A single-initiative detail + // view should return the full membership, so default to fetching all pages + // while still honoring an explicit `--limit` / `--all` if the user set one. + let projects_query = r#" + query($id: String!, $first: Int, $after: String) { + initiative(id: $id) { + projects(first: $first, after: $after) { nodes { id name state progress } + pageInfo { + hasNextPage + endCursor + } } } } "#; - let result = client.query(query, Some(json!({ "id": id }))).await?; - let initiative = &result["data"]["initiative"]; - - if initiative.is_null() { - anyhow::bail!("Initiative not found: {}", id); - } + let mut vars = serde_json::Map::new(); + vars.insert("id".to_string(), json!(id)); - print_json(initiative, output)?; + let projects_pagination = if pagination.limit.is_none() && !pagination.all { + PaginationOptions { + all: true, + ..pagination.clone() + } + } else { + pagination.clone() + }; + + let projects = paginate_nodes( + &client, + projects_query, + vars, + &["data", "initiative", "projects", "nodes"], + &["data", "initiative", "projects", "pageInfo"], + &projects_pagination, + 100, + ) + .await?; + + initiative["projects"] = json!({ "nodes": projects }); + + print_json(&initiative, output)?; Ok(()) } From c5cf7f3520402ac839e0bd4d7c40afdc053cc7f3 Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Thu, 23 Jul 2026 17:27:56 -0500 Subject: [PATCH 2/3] test(initiatives): cover projects pagination defaulting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the "default to all pages unless --limit/--all is set" decision into a pure `projects_pagination` helper and unit-test its equivalence classes: unbounded upgrades to all, explicit --limit is preserved, explicit --all passes through, and cursor/page-size fields carry along. The API round-trip and paginate_nodes wiring aren't unit-tested — the crate has no HTTP-mock harness and LINEAR_API_URL is a hardcoded const — so this covers the one piece of new branching logic the fix introduces. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/initiatives.rs | 77 +++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/src/commands/initiatives.rs b/src/commands/initiatives.rs index 7c516f9..e9bf0e4 100644 --- a/src/commands/initiatives.rs +++ b/src/commands/initiatives.rs @@ -229,14 +229,7 @@ async fn get_initiative( let mut vars = serde_json::Map::new(); vars.insert("id".to_string(), json!(id)); - let projects_pagination = if pagination.limit.is_none() && !pagination.all { - PaginationOptions { - all: true, - ..pagination.clone() - } - } else { - pagination.clone() - }; + let projects_pagination = projects_pagination(pagination); let projects = paginate_nodes( &client, @@ -255,6 +248,21 @@ async fn get_initiative( Ok(()) } +/// Pagination options for the nested `projects` connection of a single +/// initiative. A detail view should return the full project membership, so an +/// unbounded request (no `--limit`, no `--all`) is upgraded to fetch every +/// page; an explicit `--limit` / `--all` is passed through untouched. +fn projects_pagination(pagination: &PaginationOptions) -> PaginationOptions { + if pagination.limit.is_none() && !pagination.all { + PaginationOptions { + all: true, + ..pagination.clone() + } + } else { + pagination.clone() + } +} + async fn create_initiative( name: &str, description: Option, @@ -403,3 +411,56 @@ async fn delete_initiative(id: &str, force: bool) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projects_pagination_defaults_to_all_when_unbounded() { + // No --limit and no --all: a detail view must return every project, + // not the API's default first page — this is the truncation bug fix. + let opts = PaginationOptions::default(); + let result = projects_pagination(&opts); + assert!(result.all); + assert!(result.limit.is_none()); + } + + #[test] + fn projects_pagination_preserves_explicit_limit() { + // An explicit --limit is a deliberate cap; don't upgrade it to all. + let opts = PaginationOptions { + limit: Some(10), + ..Default::default() + }; + let result = projects_pagination(&opts); + assert!(!result.all); + assert_eq!(result.limit, Some(10)); + } + + #[test] + fn projects_pagination_preserves_explicit_all() { + // Already all: passed through untouched. + let opts = PaginationOptions { + all: true, + ..Default::default() + }; + let result = projects_pagination(&opts); + assert!(result.all); + assert!(result.limit.is_none()); + } + + #[test] + fn projects_pagination_carries_through_cursor_and_page_size() { + // Other pagination fields ride along when we upgrade to all. + let opts = PaginationOptions { + after: Some("cursor".to_string()), + page_size: Some(25), + ..Default::default() + }; + let result = projects_pagination(&opts); + assert!(result.all); + assert_eq!(result.after.as_deref(), Some("cursor")); + assert_eq!(result.page_size, Some(25)); + } +} From 306b3b7a9469d78c332596680b186d557a9d536c Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Fri, 24 Jul 2026 12:20:34 -0500 Subject: [PATCH 3/3] fix(initiatives): complete projects pagination surface, lift to with_default_all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on PR #39: - Match the canonical nested-connection query shape (as in comments.rs): add `$last`/`$before` variables and `hasPreviousPage`/`startCursor` to pageInfo so `paginate_nodes`' reverse walks (`--before`) work instead of erroring. - Replace the initiatives-local `projects_pagination` helper with a reusable `PaginationOptions::with_default_all()` — the dual of `with_default_limit` — and move its tests alongside the existing pagination tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/initiatives.rs | 77 +++---------------------------------- src/pagination.rs | 59 ++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 72 deletions(-) diff --git a/src/commands/initiatives.rs b/src/commands/initiatives.rs index e9bf0e4..f4888d8 100644 --- a/src/commands/initiatives.rs +++ b/src/commands/initiatives.rs @@ -208,9 +208,9 @@ async fn get_initiative( // view should return the full membership, so default to fetching all pages // while still honoring an explicit `--limit` / `--all` if the user set one. let projects_query = r#" - query($id: String!, $first: Int, $after: String) { + query($id: String!, $first: Int, $after: String, $last: Int, $before: String) { initiative(id: $id) { - projects(first: $first, after: $after) { + projects(first: $first, after: $after, last: $last, before: $before) { nodes { id name @@ -220,6 +220,8 @@ async fn get_initiative( pageInfo { hasNextPage endCursor + hasPreviousPage + startCursor } } } @@ -229,15 +231,13 @@ async fn get_initiative( let mut vars = serde_json::Map::new(); vars.insert("id".to_string(), json!(id)); - let projects_pagination = projects_pagination(pagination); - let projects = paginate_nodes( &client, projects_query, vars, &["data", "initiative", "projects", "nodes"], &["data", "initiative", "projects", "pageInfo"], - &projects_pagination, + &pagination.with_default_all(), 100, ) .await?; @@ -248,21 +248,6 @@ async fn get_initiative( Ok(()) } -/// Pagination options for the nested `projects` connection of a single -/// initiative. A detail view should return the full project membership, so an -/// unbounded request (no `--limit`, no `--all`) is upgraded to fetch every -/// page; an explicit `--limit` / `--all` is passed through untouched. -fn projects_pagination(pagination: &PaginationOptions) -> PaginationOptions { - if pagination.limit.is_none() && !pagination.all { - PaginationOptions { - all: true, - ..pagination.clone() - } - } else { - pagination.clone() - } -} - async fn create_initiative( name: &str, description: Option, @@ -412,55 +397,3 @@ async fn delete_initiative(id: &str, force: bool) -> Result<()> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn projects_pagination_defaults_to_all_when_unbounded() { - // No --limit and no --all: a detail view must return every project, - // not the API's default first page — this is the truncation bug fix. - let opts = PaginationOptions::default(); - let result = projects_pagination(&opts); - assert!(result.all); - assert!(result.limit.is_none()); - } - - #[test] - fn projects_pagination_preserves_explicit_limit() { - // An explicit --limit is a deliberate cap; don't upgrade it to all. - let opts = PaginationOptions { - limit: Some(10), - ..Default::default() - }; - let result = projects_pagination(&opts); - assert!(!result.all); - assert_eq!(result.limit, Some(10)); - } - - #[test] - fn projects_pagination_preserves_explicit_all() { - // Already all: passed through untouched. - let opts = PaginationOptions { - all: true, - ..Default::default() - }; - let result = projects_pagination(&opts); - assert!(result.all); - assert!(result.limit.is_none()); - } - - #[test] - fn projects_pagination_carries_through_cursor_and_page_size() { - // Other pagination fields ride along when we upgrade to all. - let opts = PaginationOptions { - after: Some("cursor".to_string()), - page_size: Some(25), - ..Default::default() - }; - let result = projects_pagination(&opts); - assert!(result.all); - assert_eq!(result.after.as_deref(), Some("cursor")); - assert_eq!(result.page_size, Some(25)); - } -} diff --git a/src/pagination.rs b/src/pagination.rs index 6f77eed..73f4008 100644 --- a/src/pagination.rs +++ b/src/pagination.rs @@ -23,6 +23,18 @@ impl PaginationOptions { options } + /// The dual of [`with_default_limit`]: an unbounded request (no `--limit`, + /// no `--all`) is upgraded to fetch every page. Used by detail views that + /// should return a full nested connection rather than the API's default + /// first page. An explicit `--limit` / `--all` is passed through untouched. + pub fn with_default_all(&self) -> Self { + let mut options = self.clone(); + if !options.all && options.limit.is_none() { + options.all = true; + } + options + } + pub fn effective_page_size(&self, default_page_size: usize) -> usize { self.page_size.unwrap_or(default_page_size).max(1) } @@ -325,6 +337,53 @@ mod tests { assert!(result.limit.is_none()); } + #[test] + fn test_with_default_all_sets_all_when_unbounded() { + // No --limit and no --all: upgrade to fetch every page (detail-view + // default — this is the projects-truncation fix). + let opts = PaginationOptions::default(); + let result = opts.with_default_all(); + assert!(result.all); + assert!(result.limit.is_none()); + } + + #[test] + fn test_with_default_all_preserves_explicit_limit() { + // An explicit --limit is a deliberate cap; don't upgrade it to all. + let opts = PaginationOptions { + limit: Some(10), + ..Default::default() + }; + let result = opts.with_default_all(); + assert!(!result.all); + assert_eq!(result.limit, Some(10)); + } + + #[test] + fn test_with_default_all_preserves_explicit_all() { + let opts = PaginationOptions { + all: true, + ..Default::default() + }; + let result = opts.with_default_all(); + assert!(result.all); + assert!(result.limit.is_none()); + } + + #[test] + fn test_with_default_all_carries_through_cursor_and_page_size() { + // Other pagination fields ride along when we upgrade to all. + let opts = PaginationOptions { + after: Some("cursor".to_string()), + page_size: Some(25), + ..Default::default() + }; + let result = opts.with_default_all(); + assert!(result.all); + assert_eq!(result.after.as_deref(), Some("cursor")); + assert_eq!(result.page_size, Some(25)); + } + #[test] fn test_effective_page_size_default() { let opts = PaginationOptions::default();