From 6a90ba5c5be70092a62889596003d48f0d416e35 Mon Sep 17 00:00:00 2001 From: Geoffrey Sechter Date: Sat, 28 Feb 2026 18:27:23 -0700 Subject: [PATCH 1/2] feat: `issues batch-update` for bulk issue updates First batch mutation in the codebase. Enhances codegen to detect list entity fields in mutation payloads and generate `Result>` return types with a new `execute_batch_mutation` client method. Adds full CLI subcommand with status, priority, assignee, labels, project, and cycle flags. --- crates/lineark-codegen/src/emit_mutations.rs | 99 +++++++--- crates/lineark-sdk/src/client.rs | 43 ++++ .../lineark-sdk/src/generated/client_impl.rs | 13 ++ crates/lineark-sdk/src/generated/mutations.rs | 19 ++ crates/lineark-sdk/tests/online.rs | 65 +++++++ crates/lineark/src/commands/issues.rs | 139 +++++++++++++ crates/lineark/src/commands/usage.rs | 2 + crates/lineark/tests/offline.rs | 58 ++++++ crates/lineark/tests/online.rs | 184 ++++++++++++++++++ schema/operations.toml | 3 + 10 files changed, 597 insertions(+), 28 deletions(-) diff --git a/crates/lineark-codegen/src/emit_mutations.rs b/crates/lineark-codegen/src/emit_mutations.rs index 4a68c11..4301a47 100644 --- a/crates/lineark-codegen/src/emit_mutations.rs +++ b/crates/lineark-codegen/src/emit_mutations.rs @@ -104,7 +104,8 @@ fn emit_mutation( // Find the single entity (Object) field in the payload, if any. // Mutations with an entity field become generic; those without keep Value return. - let mut entity_info: Option<(String, String)> = None; // (field_name, type_name) + // Tuple: (field_name, type_name, is_list). + let mut entity_info: Option<(String, String, bool)> = None; let mut scalar_parts: Vec = Vec::new(); for pf in &payload_obj.fields { @@ -120,7 +121,8 @@ fn emit_mutation( if entity_info.is_none() { if let Some(obj) = object_map.get(base) { if obj.fields.iter().any(|f| f.name == "id") { - entity_info = Some((pf.name.clone(), base.to_string())); + entity_info = + Some((pf.name.clone(), base.to_string(), is_list_type(&pf.ty))); } } } @@ -129,40 +131,72 @@ fn emit_mutation( } } - if let Some((entity_field_name, entity_type_name)) = entity_info { - // ── Generic mutation: returns T, SDK handles success + extraction ── + if let Some((entity_field_name, entity_type_name, is_list)) = entity_info { let entity_type_ident = quote::format_ident!("{}", entity_type_name); let type_hint = format!(" Full type: [`{entity_type_name}`](super::types::{entity_type_name})"); let doc = quote! { #doc #[doc = ""] #[doc = #type_hint] }; - let query_prefix = format!( - "mutation {}({}) {{ {}({}) {{ success {} {{ ", - operation_name, graphql_params, mutation_name, graphql_args, entity_field_name, - ); - let query_suffix = " } } }"; let entity_field_lit = entity_field_name.as_str(); - let standalone_fn = quote! { - #doc - pub async fn #method_name>( - client: &Client, #(#params),* - ) -> Result { - let variables = serde_json::json!({ #(#variables_json),* }); - let query = String::from(#query_prefix) + &T::selection() + #query_suffix; - client.execute_mutation::(&query, variables, #data_path, #entity_field_lit).await - } - }; + if is_list { + // ── Batch mutation: returns Vec ── + let query_prefix = format!( + "mutation {}({}) {{ {}({}) {{ success {} {{ ", + operation_name, graphql_params, mutation_name, graphql_args, entity_field_name, + ); + let query_suffix = " } } }"; + + let standalone_fn = quote! { + #doc + pub async fn #method_name>( + client: &Client, #(#params),* + ) -> Result, LinearError> { + let variables = serde_json::json!({ #(#variables_json),* }); + let query = String::from(#query_prefix) + &T::selection() + #query_suffix; + client.execute_batch_mutation::(&query, variables, #data_path, #entity_field_lit).await + } + }; + + let client_method = quote! { + #doc + pub async fn #method_name>( + &self, #(#params),* + ) -> Result, LinearError> { + crate::generated::mutations::#method_name::(self, #(#call_args),*).await + } + }; - let client_method = quote! { - #doc - pub async fn #method_name>( - &self, #(#params),* - ) -> Result { - crate::generated::mutations::#method_name::(self, #(#call_args),*).await - } - }; + Some((standalone_fn, client_method)) + } else { + // ── Generic mutation: returns T, SDK handles success + extraction ── + let query_prefix = format!( + "mutation {}({}) {{ {}({}) {{ success {} {{ ", + operation_name, graphql_params, mutation_name, graphql_args, entity_field_name, + ); + let query_suffix = " } } }"; + + let standalone_fn = quote! { + #doc + pub async fn #method_name>( + client: &Client, #(#params),* + ) -> Result { + let variables = serde_json::json!({ #(#variables_json),* }); + let query = String::from(#query_prefix) + &T::selection() + #query_suffix; + client.execute_mutation::(&query, variables, #data_path, #entity_field_lit).await + } + }; + + let client_method = quote! { + #doc + pub async fn #method_name>( + &self, #(#params),* + ) -> Result { + crate::generated::mutations::#method_name::(self, #(#call_args),*).await + } + }; - Some((standalone_fn, client_method)) + Some((standalone_fn, client_method)) + } } else { // ── Non-entity mutation (e.g. file_upload): keep Value return ── let mut entity_selection_exprs: Vec = Vec::new(); @@ -298,6 +332,15 @@ fn resolve_mutation_arg_inner( } } +/// Check if a GqlType is a list (possibly wrapped in NonNull). +fn is_list_type(ty: &GqlType) -> bool { + match ty { + GqlType::List(_) => true, + GqlType::NonNull(inner) => is_list_type(inner), + GqlType::Named(_) => false, + } +} + fn capitalize_first(s: &str) -> String { let mut c = s.chars(); match c.next() { diff --git a/crates/lineark-sdk/src/client.rs b/crates/lineark-sdk/src/client.rs index 626b01d..d504a9a 100644 --- a/crates/lineark-sdk/src/client.rs +++ b/crates/lineark-sdk/src/client.rs @@ -245,6 +245,49 @@ impl Client { }) } + /// Execute a batch mutation, check `success`, and extract a list of entities. + /// + /// Similar to [`execute_mutation`](Self::execute_mutation), but for mutations + /// whose payload contains an array of entities (e.g. `issueBatchUpdate`). + pub(crate) async fn execute_batch_mutation( + &self, + query: &str, + variables: serde_json::Value, + data_path: &str, + entity_field: &str, + ) -> Result, LinearError> { + let payload = self + .execute::(query, variables, data_path) + .await?; + + // Check success field. + if payload.get("success").and_then(|v| v.as_bool()) != Some(true) { + return Err(LinearError::Internal(format!( + "Mutation '{}' failed: {}", + data_path, + serde_json::to_string_pretty(&payload).unwrap_or_default() + ))); + } + + // Extract and deserialize the entity array. + let entities = payload + .get(entity_field) + .ok_or_else(|| { + LinearError::MissingData(format!( + "No '{}' field in '{}' payload", + entity_field, data_path + )) + })? + .clone(); + + serde_json::from_value(entities).map_err(|e| { + LinearError::MissingData(format!( + "Failed to deserialize '{}' from '{}': {}", + entity_field, data_path, e + )) + }) + } + /// Access the underlying HTTP client. /// /// Used internally by [`helpers`](crate::helpers) for file download/upload diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 1a13719..76be884 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -344,6 +344,19 @@ impl Client { ) -> Result { crate::generated::mutations::issue_update::(self, input, id).await } + /// Updates multiple issues at once. + /// + /// Full type: [`Issue`](super::types::Issue) + pub async fn issue_batch_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + input: IssueUpdateInput, + ids: Vec, + ) -> Result, LinearError> { + crate::generated::mutations::issue_batch_update::(self, input, ids).await + } /// Archives an issue. /// /// Full type: [`Issue`](super::types::Issue) diff --git a/crates/lineark-sdk/src/generated/mutations.rs b/crates/lineark-sdk/src/generated/mutations.rs index 97c4ec2..dfc373d 100644 --- a/crates/lineark-sdk/src/generated/mutations.rs +++ b/crates/lineark-sdk/src/generated/mutations.rs @@ -325,6 +325,25 @@ pub async fn issue_update< .execute_mutation::(&query, variables, "issueUpdate", "issue") .await } +/// Updates multiple issues at once. +/// +/// Full type: [`Issue`](super::types::Issue) +pub async fn issue_batch_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + input: IssueUpdateInput, + ids: Vec, +) -> Result, LinearError> { + let variables = serde_json::json!({ "input" : input, "ids" : ids }); + let query = String::from( + "mutation IssueBatchUpdate($input: IssueUpdateInput!, $ids: [UUID!]!) { issueBatchUpdate(input: $input, ids: $ids) { success issues { ", + ) + &T::selection() + " } } }"; + client + .execute_batch_mutation::(&query, variables, "issueBatchUpdate", "issues") + .await +} /// Archives an issue. /// /// Full type: [`Issue`](super::types::Issue) diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index e5c4270..ce9c4e9 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -1061,6 +1061,71 @@ mod online { client.team_delete(team_id).await.unwrap(); } + // ── Batch mutations ────────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn issue_batch_update_changes_priority() { + use lineark_sdk::generated::inputs::{IssueCreateInput, IssueUpdateInput}; + + let client = test_client(); + + // Get a team to create issues in. + let teams = client.teams::().first(1).send().await.unwrap(); + let team_id = teams.nodes[0].id.clone().unwrap(); + + // Create two issues. + let input_a = IssueCreateInput { + title: Some("[test] SDK batch_update A".to_string()), + team_id: Some(team_id.clone()), + priority: Some(4), + ..Default::default() + }; + let entity_a = client.issue_create::(input_a).await.unwrap(); + let id_a = entity_a.id.clone().unwrap(); + let _guard_a = IssueGuard { + token: test_token(), + id: id_a.clone(), + }; + + let input_b = IssueCreateInput { + title: Some("[test] SDK batch_update B".to_string()), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }; + let entity_b = client.issue_create::(input_b).await.unwrap(); + let id_b = entity_b.id.clone().unwrap(); + let _guard_b = IssueGuard { + token: test_token(), + id: id_b.clone(), + }; + + // Batch update both issues' priority. + let update_input = IssueUpdateInput { + priority: Some(2), + ..Default::default() + }; + let result = client + .issue_batch_update::(update_input, vec![id_a.clone(), id_b.clone()]) + .await + .unwrap(); + + assert_eq!(result.len(), 2, "batch update should return 2 issues"); + for issue in &result { + assert!(issue.id.is_some()); + } + + // Clean up. + client + .issue_delete::(Some(true), id_a) + .await + .unwrap(); + client + .issue_delete::(Some(true), id_b) + .await + .unwrap(); + } + // ── Error handling ────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] diff --git a/crates/lineark/src/commands/issues.rs b/crates/lineark/src/commands/issues.rs index 3c4874a..c2018a6 100644 --- a/crates/lineark/src/commands/issues.rs +++ b/crates/lineark/src/commands/issues.rs @@ -131,6 +131,41 @@ pub enum IssuesAction { #[arg(long, default_value = "false")] permanently: bool, }, + /// Batch update multiple issues at once. Returns the updated issues. + /// + /// Examples: + /// lineark issues batch-update ENG-1 ENG-2 --priority 2 + /// lineark issues batch-update ENG-1 ENG-2 ENG-3 --status "In Progress" + /// lineark issues batch-update ENG-1 ENG-2 --assignee me --labels Bug --label-by adding + BatchUpdate { + /// Issue identifiers (e.g., ENG-123) or UUIDs. At least one required. + #[arg(required = true, num_args = 1..)] + identifiers: Vec, + /// New status name (resolved against the first issue's team workflow states). + #[arg(short = 's', long)] + status: Option, + /// Priority: 0=none, 1=urgent, 2=high, 3=medium, 4=low. + #[arg(short = 'p', long, value_parser = clap::value_parser!(i64).range(0..=4))] + priority: Option, + /// Comma-separated label names or UUIDs. Behavior depends on --label-by. + #[arg(long, value_delimiter = ',')] + labels: Option>, + /// How to apply --labels: "replacing" (default), "adding", or "removing". + #[arg(long, default_value = "replacing")] + label_by: LabelMode, + /// Remove all labels from the issues. + #[arg(long, default_value = "false")] + clear_labels: bool, + /// Assignee: user name, display name, UUID, or `me`. + #[arg(long)] + assignee: Option, + /// Project name or UUID. + #[arg(long)] + project: Option, + /// Cycle name, number, or UUID (resolved within the first issue's team). + #[arg(long)] + cycle: Option, + }, /// Update an existing issue. Returns the updated issue. /// /// Examples: @@ -632,6 +667,110 @@ pub async fn run(cmd: IssuesCmd, client: &Client, format: Format) -> anyhow::Res output::print_one(&issue, format); } + IssuesAction::BatchUpdate { + identifiers, + status, + priority, + labels, + label_by, + clear_labels, + assignee, + project, + cycle, + } => { + if status.is_none() + && priority.is_none() + && labels.is_none() + && !clear_labels + && assignee.is_none() + && project.is_none() + && cycle.is_none() + { + return Err(anyhow::anyhow!( + "No update fields provided. Use --status, --priority, --assignee, --labels, --project, or --cycle to specify changes." + )); + } + + // Resolve all identifiers to UUIDs. + let mut ids = Vec::with_capacity(identifiers.len()); + for ident in &identifiers { + ids.push(resolve_issue_id(client, ident).await?); + } + + // If we need to resolve status/labels/cycle, get the team from the first issue. + let needs_team = status.is_some() || labels.is_some() || cycle.is_some(); + let team_id = if needs_team { + let detail = read_issue(client, &identifiers[0]).await?; + detail + .team + .as_ref() + .and_then(|t| t.id.clone()) + .ok_or_else(|| { + anyhow::anyhow!("Could not determine team for issue '{}'", identifiers[0]) + })? + } else { + String::new() + }; + + let state_id = match status { + Some(ref name) => Some(resolve_state_id(client, &team_id, name).await?), + None => None, + }; + + let assignee_id = match assignee { + Some(ref a) => Some(resolve_user_id_or_me(client, a).await?), + None => None, + }; + + let project_id = match project { + Some(ref p) => Some(resolve_project_id(client, p).await?), + None => None, + }; + + let cycle_id = match cycle { + Some(ref c) => Some(resolve_cycle_id(client, c, &team_id).await?), + None => None, + }; + + // Build label fields based on mode. + let (label_ids, added_label_ids, removed_label_ids) = if clear_labels { + (Some(vec![]), None, None) + } else if let Some(raw_labels) = labels { + let tid = if needs_team { + Some(team_id.as_str()) + } else { + None + }; + let resolved = resolve_label_ids(client, &raw_labels, tid).await?; + match label_by { + LabelMode::Replacing => (Some(resolved), None, None), + LabelMode::Adding => (None, Some(resolved), None), + LabelMode::Removing => (None, None, Some(resolved)), + } + } else { + (None, None, None) + }; + + let input = IssueUpdateInput { + assignee_id, + priority, + state_id, + label_ids, + added_label_ids, + removed_label_ids, + project_id, + cycle_id, + ..Default::default() + }; + + let issues = client + .issue_batch_update::(input, ids) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + let items: Vec<&IssueSummary> = issues.iter().collect(); + print_issue_list(&items, format); + } IssuesAction::Update { identifier, status, diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index ccb11c8..5c0913f 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -71,6 +71,8 @@ COMMANDS: [--clear-labels] [-t TEXT] [-d TEXT] Title, description [--parent ID] [--clear-parent] Set or remove parent [--project NAME-OR-ID] [--cycle NAME-OR-ID] Project, cycle + lineark issues batch-update ID [ID ...] Batch update multiple issues + [-s NAME] [-p 0-4] [--assignee NAME-OR-ID|me] Status, priority, assignee lineark issues archive Archive an issue lineark issues unarchive Unarchive a previously archived issue lineark issues delete Delete (trash) an issue diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 313fd32..198ad96 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -810,6 +810,64 @@ fn usage_includes_teams_create() { .stdout(predicate::str::contains("teams members remove")); } +// ── Batch update ───────────────────────────────────────────────────────────── + +#[test] +fn issues_batch_update_help_shows_flags() { + lineark() + .args(["issues", "batch-update", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--status")) + .stdout(predicate::str::contains("--priority")) + .stdout(predicate::str::contains("--labels")) + .stdout(predicate::str::contains("--assignee")) + .stdout(predicate::str::contains("--project")) + .stdout(predicate::str::contains("--cycle")); +} + +#[test] +fn issues_batch_update_requires_identifiers() { + lineark() + .args(["--api-token", "fake-token", "issues", "batch-update"]) + .assert() + .failure(); +} + +#[test] +fn issues_batch_update_no_flags_prints_error() { + lineark() + .args([ + "--api-token", + "fake-token", + "issues", + "batch-update", + "ENG-1", + "ENG-2", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("No update fields provided")); +} + +#[test] +fn issues_help_shows_batch_update() { + lineark() + .args(["issues", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("batch-update")); +} + +#[test] +fn usage_includes_batch_update() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("batch-update")); +} + // ── Self command ───────────────────────────────────────────────────────────── #[test] diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 9509f76..b2e6e97 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -3398,4 +3398,188 @@ mod online { let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(result["success"].as_bool(), Some(true)); } + + // ── Batch update ───────────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_batch_update_changes_priority() { + use lineark_sdk::generated::inputs::IssueCreateInput; + use lineark_sdk::generated::types::Team; + + let token = api_token(); + let client = Client::from_token(&token).unwrap(); + + // Get a team to create issues in. + let teams = tokio::runtime::Runtime::new() + .unwrap() + .block_on(async { client.teams::().first(1).send().await.unwrap() }); + let team_id = teams.nodes[0].id.clone().unwrap(); + + // Create two issues via SDK. + let rt = tokio::runtime::Runtime::new().unwrap(); + let issue_a = rt.block_on(async { + client + .issue_create::(IssueCreateInput { + title: Some("[test] CLI batch-update A".to_string()), + team_id: Some(team_id.clone()), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let id_a = issue_a.id.clone().unwrap(); + let _guard_a = IssueGuard { + token: token.clone(), + id: id_a.clone(), + }; + + let issue_b = rt.block_on(async { + client + .issue_create::(IssueCreateInput { + title: Some("[test] CLI batch-update B".to_string()), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let id_b = issue_b.id.clone().unwrap(); + let _guard_b = IssueGuard { + token: token.clone(), + id: id_b.clone(), + }; + + // Run batch-update via CLI. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "batch-update", + &id_a, + &id_b, + "--priority", + "1", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "batch-update should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = result.as_array().expect("should be an array"); + assert_eq!(arr.len(), 2, "should return 2 updated issues"); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_batch_update_changes_status() { + use lineark_sdk::generated::inputs::IssueCreateInput; + use lineark_sdk::generated::types::Team; + + let token = api_token(); + let client = Client::from_token(&token).unwrap(); + + // Get a team. + let teams = tokio::runtime::Runtime::new() + .unwrap() + .block_on(async { client.teams::().first(1).send().await.unwrap() }); + let team_id = teams.nodes[0].id.clone().unwrap(); + + // Create two issues. + let rt = tokio::runtime::Runtime::new().unwrap(); + let issue_a = rt.block_on(async { + client + .issue_create::(IssueCreateInput { + title: Some("[test] CLI batch-update status A".to_string()), + team_id: Some(team_id.clone()), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let id_a = issue_a.id.clone().unwrap(); + let _guard_a = IssueGuard { + token: token.clone(), + id: id_a.clone(), + }; + + let issue_b = rt.block_on(async { + client + .issue_create::(IssueCreateInput { + title: Some("[test] CLI batch-update status B".to_string()), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let id_b = issue_b.id.clone().unwrap(); + let _guard_b = IssueGuard { + token: token.clone(), + id: id_b.clone(), + }; + + // Run batch-update with --status "Done". + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "batch-update", + &id_a, + &id_b, + "--status", + "Done", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "batch-update --status should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = result.as_array().expect("should be an array"); + assert_eq!(arr.len(), 2, "should return 2 updated issues"); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_batch_update_invalid_id_fails() { + let token = api_token(); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "batch-update", + "FAKE-99999", + "--priority", + "2", + ]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "batch-update with invalid ID should fail" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("not found") || stderr.contains("Error"), + "stderr should contain an error message, got: {stderr}" + ); + } } diff --git a/schema/operations.toml b/schema/operations.toml index bc16bda..d49201d 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -29,6 +29,9 @@ issueUpdate = true commentCreate = true commentDelete = true +# Phase 3 — Batch operations +issueBatchUpdate = true + # Phase 3 — Issue lifecycle issueArchive = true issueUnarchive = true From 484dd300468905d8e8e565b0ca3d5d05a5889393 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 7 Mar 2026 12:10:01 -0300 Subject: [PATCH 2/2] fix: replace execute_batch_mutation with blanket impl GraphQLFields for Vec Instead of a separate execute_batch_mutation method and duplicated codegen paths for list entity fields, use the same approach as Option: a blanket impl that delegates selection to the inner type. execute_mutation::> handles JSON array deserialization via serde natively. Also migrates batch-update tests to TeamGuard pattern, fixes usage column alignment, and merges with origin/main. Closes #120 --- crates/lineark-codegen/src/emit_mutations.rs | 92 ++++----- crates/lineark-sdk/src/client.rs | 43 ---- crates/lineark-sdk/src/field_selection.rs | 21 +- crates/lineark-sdk/src/generated/mutations.rs | 2 +- crates/lineark-sdk/tests/online.rs | 58 ++++++ crates/lineark/src/commands/usage.rs | 4 +- crates/lineark/tests/offline.rs | 58 ++++++ crates/lineark/tests/online.rs | 184 ++++++++++++++++++ 8 files changed, 358 insertions(+), 104 deletions(-) diff --git a/crates/lineark-codegen/src/emit_mutations.rs b/crates/lineark-codegen/src/emit_mutations.rs index 4301a47..41948df 100644 --- a/crates/lineark-codegen/src/emit_mutations.rs +++ b/crates/lineark-codegen/src/emit_mutations.rs @@ -138,65 +138,43 @@ fn emit_mutation( let doc = quote! { #doc #[doc = ""] #[doc = #type_hint] }; let entity_field_lit = entity_field_name.as_str(); - if is_list { - // ── Batch mutation: returns Vec ── - let query_prefix = format!( - "mutation {}({}) {{ {}({}) {{ success {} {{ ", - operation_name, graphql_params, mutation_name, graphql_args, entity_field_name, - ); - let query_suffix = " } } }"; - - let standalone_fn = quote! { - #doc - pub async fn #method_name>( - client: &Client, #(#params),* - ) -> Result, LinearError> { - let variables = serde_json::json!({ #(#variables_json),* }); - let query = String::from(#query_prefix) + &T::selection() + #query_suffix; - client.execute_batch_mutation::(&query, variables, #data_path, #entity_field_lit).await - } - }; - - let client_method = quote! { - #doc - pub async fn #method_name>( - &self, #(#params),* - ) -> Result, LinearError> { - crate::generated::mutations::#method_name::(self, #(#call_args),*).await - } - }; - - Some((standalone_fn, client_method)) + let query_prefix = format!( + "mutation {}({}) {{ {}({}) {{ success {} {{ ", + operation_name, graphql_params, mutation_name, graphql_args, entity_field_name, + ); + let query_suffix = " } } }"; + + // List entity fields (e.g. `issues: [Issue!]!`) use Vec; + // singular entity fields (e.g. `issue: Issue!`) use T. + // Both go through execute_mutation — Vec implements GraphQLFields + // via blanket impl, and serde handles JSON array deserialization. + let (return_type, execute_type) = if is_list { + (quote! { Vec }, quote! { Vec }) } else { - // ── Generic mutation: returns T, SDK handles success + extraction ── - let query_prefix = format!( - "mutation {}({}) {{ {}({}) {{ success {} {{ ", - operation_name, graphql_params, mutation_name, graphql_args, entity_field_name, - ); - let query_suffix = " } } }"; - - let standalone_fn = quote! { - #doc - pub async fn #method_name>( - client: &Client, #(#params),* - ) -> Result { - let variables = serde_json::json!({ #(#variables_json),* }); - let query = String::from(#query_prefix) + &T::selection() + #query_suffix; - client.execute_mutation::(&query, variables, #data_path, #entity_field_lit).await - } - }; - - let client_method = quote! { - #doc - pub async fn #method_name>( - &self, #(#params),* - ) -> Result { - crate::generated::mutations::#method_name::(self, #(#call_args),*).await - } - }; + (quote! { T }, quote! { T }) + }; - Some((standalone_fn, client_method)) - } + let standalone_fn = quote! { + #doc + pub async fn #method_name>( + client: &Client, #(#params),* + ) -> Result<#return_type, LinearError> { + let variables = serde_json::json!({ #(#variables_json),* }); + let query = String::from(#query_prefix) + &T::selection() + #query_suffix; + client.execute_mutation::<#execute_type>(&query, variables, #data_path, #entity_field_lit).await + } + }; + + let client_method = quote! { + #doc + pub async fn #method_name>( + &self, #(#params),* + ) -> Result<#return_type, LinearError> { + crate::generated::mutations::#method_name::(self, #(#call_args),*).await + } + }; + + Some((standalone_fn, client_method)) } else { // ── Non-entity mutation (e.g. file_upload): keep Value return ── let mut entity_selection_exprs: Vec = Vec::new(); diff --git a/crates/lineark-sdk/src/client.rs b/crates/lineark-sdk/src/client.rs index d504a9a..626b01d 100644 --- a/crates/lineark-sdk/src/client.rs +++ b/crates/lineark-sdk/src/client.rs @@ -245,49 +245,6 @@ impl Client { }) } - /// Execute a batch mutation, check `success`, and extract a list of entities. - /// - /// Similar to [`execute_mutation`](Self::execute_mutation), but for mutations - /// whose payload contains an array of entities (e.g. `issueBatchUpdate`). - pub(crate) async fn execute_batch_mutation( - &self, - query: &str, - variables: serde_json::Value, - data_path: &str, - entity_field: &str, - ) -> Result, LinearError> { - let payload = self - .execute::(query, variables, data_path) - .await?; - - // Check success field. - if payload.get("success").and_then(|v| v.as_bool()) != Some(true) { - return Err(LinearError::Internal(format!( - "Mutation '{}' failed: {}", - data_path, - serde_json::to_string_pretty(&payload).unwrap_or_default() - ))); - } - - // Extract and deserialize the entity array. - let entities = payload - .get(entity_field) - .ok_or_else(|| { - LinearError::MissingData(format!( - "No '{}' field in '{}' payload", - entity_field, data_path - )) - })? - .clone(); - - serde_json::from_value(entities).map_err(|e| { - LinearError::MissingData(format!( - "Failed to deserialize '{}' from '{}': {}", - entity_field, data_path, e - )) - }) - } - /// Access the underlying HTTP client. /// /// Used internally by [`helpers`](crate::helpers) for file download/upload diff --git a/crates/lineark-sdk/src/field_selection.rs b/crates/lineark-sdk/src/field_selection.rs index b61845a..288be4b 100644 --- a/crates/lineark-sdk/src/field_selection.rs +++ b/crates/lineark-sdk/src/field_selection.rs @@ -55,6 +55,16 @@ impl GraphQLFields for Option { } } +// Batch mutations: Vec delegates to T's selection. +// This allows mutations returning lists (e.g. `issueBatchUpdate`) to use +// `execute_mutation::>()` — the selection set is the same as for T. +impl GraphQLFields for Vec { + type FullType = T::FullType; + fn selection() -> String { + T::selection() + } +} + /// Marker trait for compile-time field type compatibility. /// /// Validates that a full type's field type `Self` is compatible with a custom @@ -101,12 +111,21 @@ mod tests { ); } + #[test] + fn vec_delegates_selection_to_inner_type() { + assert_eq!( + as GraphQLFields>::selection(), + "id title url" + ); + } + #[test] fn option_preserves_full_type() { - // Compile-time proof: Option::FullType == FakeFullType + // Compile-time proof: Option/Vec::FullType == FakeFullType fn assert_full_type>() {} assert_full_type::(); assert_full_type::>(); + assert_full_type::>(); } #[test] diff --git a/crates/lineark-sdk/src/generated/mutations.rs b/crates/lineark-sdk/src/generated/mutations.rs index dfc373d..b8fdc5f 100644 --- a/crates/lineark-sdk/src/generated/mutations.rs +++ b/crates/lineark-sdk/src/generated/mutations.rs @@ -341,7 +341,7 @@ pub async fn issue_batch_update< "mutation IssueBatchUpdate($input: IssueUpdateInput!, $ids: [UUID!]!) { issueBatchUpdate(input: $input, ids: $ids) { success issues { ", ) + &T::selection() + " } } }"; client - .execute_batch_mutation::(&query, variables, "issueBatchUpdate", "issues") + .execute_mutation::>(&query, variables, "issueBatchUpdate", "issues") .await } /// Archives an issue. diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index b83511d..288fce6 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -965,6 +965,64 @@ mod online { ); } + // ── Batch mutations ────────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn issue_batch_update_changes_priority() { + use lineark_sdk::generated::inputs::{IssueCreateInput, IssueUpdateInput}; + + let client = test_client(); + let (team_id, _team_guard) = create_test_team(&client).await; + + // Create two issues. + let input_a = IssueCreateInput { + title: Some(format!( + "[test] SDK batch_update A {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id.clone()), + priority: Some(4), + ..Default::default() + }; + let entity_a = client.issue_create::(input_a).await.unwrap(); + let id_a = entity_a.id.clone().unwrap(); + let _guard_a = IssueGuard { + token: test_token(), + id: id_a.clone(), + }; + + let input_b = IssueCreateInput { + title: Some(format!( + "[test] SDK batch_update B {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }; + let entity_b = client.issue_create::(input_b).await.unwrap(); + let id_b = entity_b.id.clone().unwrap(); + let _guard_b = IssueGuard { + token: test_token(), + id: id_b.clone(), + }; + + // Batch update both issues' priority. + let update_input = IssueUpdateInput { + priority: Some(2), + ..Default::default() + }; + let result = client + .issue_batch_update::(update_input, vec![id_a, id_b]) + .await + .unwrap(); + + assert_eq!(result.len(), 2, "batch update should return 2 issues"); + for issue in &result { + assert!(issue.id.is_some()); + } + } + // ── Error handling ────────────────────────────────────────────────────── // ── Team CRUD ──────────────────────────────────────────────────────── diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index 78b9bad..2faa553 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -74,8 +74,8 @@ COMMANDS: [--clear-labels] [-t TEXT] [-d TEXT] Title, description [--parent ID] [--clear-parent] Set or remove parent [--project NAME-OR-ID] [--cycle NAME-OR-ID] Project, cycle - lineark issues batch-update ID [ID ...] Batch update multiple issues - [-s NAME] [-p 0-4] [--assignee NAME-OR-ID|me] Status, priority, assignee + lineark issues batch-update ID [ID ...] Batch update multiple issues + [-s NAME] [-p 0-4] [--assignee NAME-OR-ID|me] Status, priority, assignee lineark issues archive Archive an issue lineark issues unarchive Unarchive a previously archived issue lineark issues delete Delete (trash) an issue diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 79c1b64..a9eb89b 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -839,6 +839,64 @@ fn usage_includes_find_branch() { .stdout(predicate::str::contains("issues find-branch")); } +// ── Batch update ───────────────────────────────────────────────────────────── + +#[test] +fn issues_batch_update_help_shows_flags() { + lineark() + .args(["issues", "batch-update", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--status")) + .stdout(predicate::str::contains("--priority")) + .stdout(predicate::str::contains("--labels")) + .stdout(predicate::str::contains("--assignee")) + .stdout(predicate::str::contains("--project")) + .stdout(predicate::str::contains("--cycle")); +} + +#[test] +fn issues_batch_update_requires_identifiers() { + lineark() + .args(["--api-token", "fake-token", "issues", "batch-update"]) + .assert() + .failure(); +} + +#[test] +fn issues_batch_update_no_flags_prints_error() { + lineark() + .args([ + "--api-token", + "fake-token", + "issues", + "batch-update", + "ENG-1", + "ENG-2", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("No update fields provided")); +} + +#[test] +fn issues_help_shows_batch_update() { + lineark() + .args(["issues", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("batch-update")); +} + +#[test] +fn usage_includes_batch_update() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("batch-update")); +} + // ── Self command ───────────────────────────────────────────────────────────── #[test] diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index e04a4ed..8db1a3b 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -3753,4 +3753,188 @@ mod online { "issues read output should have an 'estimate' field" ); } + + // ── Batch update ───────────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_batch_update_changes_priority() { + use lineark_sdk::generated::inputs::IssueCreateInput; + + let token = api_token(); + let client = Client::from_token(&token).unwrap(); + let (_team_key, team_id, _team_guard) = create_test_team(); + + // Create two issues via SDK. + let rt = tokio::runtime::Runtime::new().unwrap(); + let issue_a = rt.block_on(async { + client + .issue_create::(IssueCreateInput { + title: Some(format!( + "[test] CLI batch-update A {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id.clone()), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let id_a = issue_a.id.clone().unwrap(); + let _guard_a = IssueGuard { + token: token.clone(), + id: id_a.clone(), + }; + + let issue_b = rt.block_on(async { + client + .issue_create::(IssueCreateInput { + title: Some(format!( + "[test] CLI batch-update B {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let id_b = issue_b.id.clone().unwrap(); + let _guard_b = IssueGuard { + token: token.clone(), + id: id_b.clone(), + }; + + // Run batch-update via CLI. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "batch-update", + &id_a, + &id_b, + "--priority", + "1", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "batch-update should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = result.as_array().expect("should be an array"); + assert_eq!(arr.len(), 2, "should return 2 updated issues"); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_batch_update_changes_status() { + use lineark_sdk::generated::inputs::IssueCreateInput; + + let token = api_token(); + let client = Client::from_token(&token).unwrap(); + let (_team_key, team_id, _team_guard) = create_test_team(); + + // Create two issues. + let rt = tokio::runtime::Runtime::new().unwrap(); + let issue_a = rt.block_on(async { + client + .issue_create::(IssueCreateInput { + title: Some(format!( + "[test] CLI batch-update status A {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id.clone()), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let id_a = issue_a.id.clone().unwrap(); + let _guard_a = IssueGuard { + token: token.clone(), + id: id_a.clone(), + }; + + let issue_b = rt.block_on(async { + client + .issue_create::(IssueCreateInput { + title: Some(format!( + "[test] CLI batch-update status B {}", + &uuid::Uuid::new_v4().to_string()[..8] + )), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }) + .await + .unwrap() + }); + let id_b = issue_b.id.clone().unwrap(); + let _guard_b = IssueGuard { + token: token.clone(), + id: id_b.clone(), + }; + + // Run batch-update with --status "Done". + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "batch-update", + &id_a, + &id_b, + "--status", + "Done", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "batch-update --status should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let arr = result.as_array().expect("should be an array"); + assert_eq!(arr.len(), 2, "should return 2 updated issues"); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_batch_update_invalid_id_fails() { + let token = api_token(); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "batch-update", + "FAKE-99999", + "--priority", + "2", + ]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "batch-update with invalid ID should fail" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("not found") || stderr.contains("Error"), + "stderr should contain an error message, got: {stderr}" + ); + } }