Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions crates/lineark-codegen/src/emit_mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = Vec::new();

for pf in &payload_obj.fields {
Expand All @@ -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)));
}
}
}
Expand All @@ -129,35 +131,45 @@ 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 entity_field_lit = entity_field_name.as_str();

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();

// List entity fields (e.g. `issues: [Issue!]!`) use Vec<T>;
// singular entity fields (e.g. `issue: Issue!`) use T.
// Both go through execute_mutation — Vec<T> implements GraphQLFields
// via blanket impl, and serde handles JSON array deserialization.
let (return_type, execute_type) = if is_list {
(quote! { Vec<T> }, quote! { Vec<T> })
} else {
(quote! { T }, quote! { T })
};

let standalone_fn = quote! {
#doc
pub async fn #method_name<T: serde::de::DeserializeOwned + crate::field_selection::GraphQLFields<FullType = super::types::#entity_type_ident>>(
client: &Client, #(#params),*
) -> Result<T, LinearError> {
) -> Result<#return_type, LinearError> {
let variables = serde_json::json!({ #(#variables_json),* });
let query = String::from(#query_prefix) + &T::selection() + #query_suffix;
client.execute_mutation::<T>(&query, variables, #data_path, #entity_field_lit).await
client.execute_mutation::<#execute_type>(&query, variables, #data_path, #entity_field_lit).await
}
};

let client_method = quote! {
#doc
pub async fn #method_name<T: serde::de::DeserializeOwned + crate::field_selection::GraphQLFields<FullType = super::types::#entity_type_ident>>(
&self, #(#params),*
) -> Result<T, LinearError> {
) -> Result<#return_type, LinearError> {
crate::generated::mutations::#method_name::<T>(self, #(#call_args),*).await
}
};
Expand Down Expand Up @@ -298,6 +310,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() {
Expand Down
21 changes: 20 additions & 1 deletion crates/lineark-sdk/src/field_selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ impl<T: GraphQLFields> GraphQLFields for Option<T> {
}
}

// Batch mutations: Vec<T> delegates to T's selection.
// This allows mutations returning lists (e.g. `issueBatchUpdate`) to use
// `execute_mutation::<Vec<T>>()` — the selection set is the same as for T.
impl<T: GraphQLFields> GraphQLFields for Vec<T> {
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
Expand Down Expand Up @@ -101,12 +111,21 @@ mod tests {
);
}

#[test]
fn vec_delegates_selection_to_inner_type() {
assert_eq!(
<Vec<FakeIssue> as GraphQLFields>::selection(),
"id title url"
);
}

#[test]
fn option_preserves_full_type() {
// Compile-time proof: Option<FakeIssue>::FullType == FakeFullType
// Compile-time proof: Option/Vec<FakeIssue>::FullType == FakeFullType
fn assert_full_type<T: GraphQLFields<FullType = FakeFullType>>() {}
assert_full_type::<FakeIssue>();
assert_full_type::<Option<FakeIssue>>();
assert_full_type::<Vec<FakeIssue>>();
}

#[test]
Expand Down
13 changes: 13 additions & 0 deletions crates/lineark-sdk/src/generated/client_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,19 @@ impl Client {
) -> Result<T, LinearError> {
crate::generated::mutations::issue_update::<T>(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<FullType = super::types::Issue>,
>(
&self,
input: IssueUpdateInput,
ids: Vec<String>,
) -> Result<Vec<T>, LinearError> {
crate::generated::mutations::issue_batch_update::<T>(self, input, ids).await
}
/// Archives an issue.
///
/// Full type: [`Issue`](super::types::Issue)
Expand Down
19 changes: 19 additions & 0 deletions crates/lineark-sdk/src/generated/mutations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,25 @@ pub async fn issue_update<
.execute_mutation::<T>(&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<FullType = super::types::Issue>,
>(
client: &Client,
input: IssueUpdateInput,
ids: Vec<String>,
) -> Result<Vec<T>, 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_mutation::<Vec<T>>(&query, variables, "issueBatchUpdate", "issues")
.await
}
/// Archives an issue.
///
/// Full type: [`Issue`](super::types::Issue)
Expand Down
58 changes: 58 additions & 0 deletions crates/lineark-sdk/tests/online.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Issue>(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::<Issue>(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::<Issue>(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 ────────────────────────────────────────────────────────
Expand Down
139 changes: 139 additions & 0 deletions crates/lineark/src/commands/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,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<String>,
/// New status name (resolved against the first issue's team workflow states).
#[arg(short = 's', long)]
status: Option<String>,
/// 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<i64>,
/// Comma-separated label names or UUIDs. Behavior depends on --label-by.
#[arg(long, value_delimiter = ',')]
labels: Option<Vec<String>>,
/// 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<String>,
/// Project name or UUID.
#[arg(long)]
project: Option<String>,
/// Cycle name, number, or UUID (resolved within the first issue's team).
#[arg(long)]
cycle: Option<String>,
},
/// Update an existing issue. Returns the updated issue.
///
/// Examples:
Expand Down Expand Up @@ -678,6 +713,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::<IssueSummary>(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,
Expand Down
Loading