diff --git a/src/commands/initiatives.rs b/src/commands/initiatives.rs index 44a00dc..f4888d8 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,59 @@ 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, $last: Int, $before: String) { + initiative(id: $id) { + projects(first: $first, after: $after, last: $last, before: $before) { nodes { id name state progress } + pageInfo { + hasNextPage + endCursor + hasPreviousPage + startCursor + } } } } "#; - let result = client.query(query, Some(json!({ "id": id }))).await?; - let initiative = &result["data"]["initiative"]; + let mut vars = serde_json::Map::new(); + vars.insert("id".to_string(), json!(id)); - if initiative.is_null() { - anyhow::bail!("Initiative not found: {}", id); - } + let projects = paginate_nodes( + &client, + projects_query, + vars, + &["data", "initiative", "projects", "nodes"], + &["data", "initiative", "projects", "pageInfo"], + &pagination.with_default_all(), + 100, + ) + .await?; + + initiative["projects"] = json!({ "nodes": projects }); - print_json(initiative, output)?; + print_json(&initiative, output)?; Ok(()) } @@ -355,3 +396,4 @@ async fn delete_initiative(id: &str, force: bool) -> Result<()> { Ok(()) } + 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();