From 477336754446f8716ada0a9c15ff82d7c0e781fd Mon Sep 17 00:00:00 2001 From: Geoffrey Sechter Date: Sun, 1 Mar 2026 19:21:47 -0700 Subject: [PATCH 01/14] feat: issue label read, create, update, and delete commands --- .../lineark-sdk/src/generated/client_impl.rs | 43 +++++ crates/lineark-sdk/src/generated/mutations.rs | 58 ++++++ crates/lineark-sdk/src/generated/queries.rs | 17 ++ crates/lineark-sdk/tests/online.rs | 72 ++++++++ crates/lineark/src/commands/labels.rs | 171 +++++++++++++++++- crates/lineark/src/commands/usage.rs | 8 + crates/lineark/tests/offline.rs | 67 +++++++ crates/lineark/tests/online.rs | 124 +++++++++++++ schema/operations.toml | 4 + 9 files changed, 563 insertions(+), 1 deletion(-) diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 1a13719..e26de1e 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -124,6 +124,17 @@ impl Client { pub fn issue_labels(&self) -> IssueLabelsQueryBuilder<'_, T> { crate::generated::queries::issue_labels(self) } + /// One specific label. + /// + /// Full type: [`IssueLabel`](super::types::IssueLabel) + pub async fn issue_label< + T: DeserializeOwned + GraphQLFields, + >( + &self, + id: String, + ) -> Result { + crate::generated::queries::issue_label::(self, id).await + } /// All documents in the workspace. /// /// Full type: [`Document`](super::types::Document) @@ -403,6 +414,38 @@ impl Client { ) -> Result { crate::generated::mutations::issue_relation_delete(self, id).await } + /// Creates a new label. + /// + /// Full type: [`IssueLabel`](super::types::IssueLabel) + pub async fn issue_label_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + replace_team_labels: Option, + input: IssueLabelCreateInput, + ) -> Result { + crate::generated::mutations::issue_label_create::(self, replace_team_labels, input).await + } + /// Updates a label. + /// + /// Full type: [`IssueLabel`](super::types::IssueLabel) + pub async fn issue_label_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, + >( + &self, + replace_team_labels: Option, + input: IssueLabelUpdateInput, + id: String, + ) -> Result { + crate::generated::mutations::issue_label_update::(self, replace_team_labels, input, id) + .await + } + /// Deletes an issue label. + pub async fn issue_label_delete(&self, id: String) -> Result { + crate::generated::mutations::issue_label_delete(self, id).await + } /// Creates a new document. /// /// Full type: [`Document`](super::types::Document) diff --git a/crates/lineark-sdk/src/generated/mutations.rs b/crates/lineark-sdk/src/generated/mutations.rs index 97c4ec2..fc17006 100644 --- a/crates/lineark-sdk/src/generated/mutations.rs +++ b/crates/lineark-sdk/src/generated/mutations.rs @@ -420,6 +420,64 @@ pub async fn issue_relation_delete( .execute::(&query, variables, "issueRelationDelete") .await } +/// Creates a new label. +/// +/// Full type: [`IssueLabel`](super::types::IssueLabel) +pub async fn issue_label_create< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + replace_team_labels: Option, + input: IssueLabelCreateInput, +) -> Result { + let variables = serde_json::json!( + { "replaceTeamLabels" : replace_team_labels, "input" : input } + ); + let query = String::from( + "mutation IssueLabelCreate($replaceTeamLabels: Boolean, $input: IssueLabelCreateInput!) { issueLabelCreate(replaceTeamLabels: $replaceTeamLabels, input: $input) { success issueLabel { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "issueLabelCreate", "issueLabel") + .await +} +/// Updates a label. +/// +/// Full type: [`IssueLabel`](super::types::IssueLabel) +pub async fn issue_label_update< + T: serde::de::DeserializeOwned + + crate::field_selection::GraphQLFields, +>( + client: &Client, + replace_team_labels: Option, + input: IssueLabelUpdateInput, + id: String, +) -> Result { + let variables = serde_json::json!( + { "replaceTeamLabels" : replace_team_labels, "input" : input, "id" : id } + ); + let query = String::from( + "mutation IssueLabelUpdate($replaceTeamLabels: Boolean, $input: IssueLabelUpdateInput!, $id: String!) { issueLabelUpdate(replaceTeamLabels: $replaceTeamLabels, input: $input, id: $id) { success issueLabel { ", + ) + &T::selection() + " } } }"; + client + .execute_mutation::(&query, variables, "issueLabelUpdate", "issueLabel") + .await +} +/// Deletes an issue label. +pub async fn issue_label_delete( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let response_parts: Vec = vec!["success".to_string(), "entityId".to_string()]; + let query = + String::from("mutation IssueLabelDelete($id: String!) { issueLabelDelete(id: $id) { ") + + &response_parts.join(" ") + + " } }"; + client + .execute::(&query, variables, "issueLabelDelete") + .await +} /// Creates a new document. /// /// Full type: [`Document`](super::types::Document) diff --git a/crates/lineark-sdk/src/generated/queries.rs b/crates/lineark-sdk/src/generated/queries.rs index 542e21a..61b786f 100644 --- a/crates/lineark-sdk/src/generated/queries.rs +++ b/crates/lineark-sdk/src/generated/queries.rs @@ -1258,6 +1258,23 @@ pub fn issue_labels<'a, T>(client: &'a Client) -> IssueLabelsQueryBuilder<'a, T> _marker: std::marker::PhantomData, } } +/// One specific label. +/// +/// Full type: [`IssueLabel`](super::types::IssueLabel) +pub async fn issue_label< + T: DeserializeOwned + GraphQLFields, +>( + client: &Client, + id: String, +) -> Result { + let variables = serde_json::json!({ "id" : id }); + let selection = T::selection(); + let query = format!( + "query {}({}) {{ {}({}) {{ {} }} }}", + "IssueLabel", "$id: String!", "issueLabel", "id: $id", selection + ); + client.execute::(&query, variables, "issueLabel").await +} /// All documents in the workspace. /// /// Full type: [`Document`](super::types::Document) diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index e5c4270..e336c2f 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -95,6 +95,27 @@ impl Drop for DocumentGuard { } } +/// RAII guard — deletes an issue label on drop. +struct LabelGuard { + token: String, + id: String, +} + +impl Drop for LabelGuard { + fn drop(&mut self) { + let token = self.token.clone(); + let id = self.id.clone(); + let _ = std::thread::spawn(move || { + tokio::runtime::Runtime::new().unwrap().block_on(async { + if let Ok(client) = Client::from_token(token) { + let _ = client.issue_label_delete(id).await; + } + }); + }) + .join(); + } +} + test_with::tokio_runner!(online); #[test_with::module] @@ -213,6 +234,57 @@ mod online { } } + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn issue_label_create_update_and_delete() { + use lineark_sdk::generated::inputs::{IssueLabelCreateInput, IssueLabelUpdateInput}; + + let client = test_client(); + + // Create a workspace-level label with a unique name. + let unique = format!( + "[test] sdk-label {}", + &uuid::Uuid::new_v4().to_string()[..8] + ); + let input = IssueLabelCreateInput { + name: Some(unique.clone()), + color: Some("#eb5757".to_string()), + ..Default::default() + }; + let label = client + .issue_label_create::(None, input) + .await + .unwrap(); + let label_id = label.id.clone().unwrap(); + let _label_guard = LabelGuard { + token: test_token(), + id: label_id.clone(), + }; + assert!(!label_id.is_empty()); + assert_eq!(label.name, Some(unique)); + assert_eq!(label.color, Some("#eb5757".to_string())); + + // Update the label's color. + let update_input = IssueLabelUpdateInput { + color: Some("#4ea7fc".to_string()), + ..Default::default() + }; + let updated = client + .issue_label_update::(None, update_input, label_id.clone()) + .await + .unwrap(); + assert!(updated.id.is_some()); + + // Verify the update by reading the label. + let fetched = client + .issue_label::(label_id.clone()) + .await + .unwrap(); + assert_eq!(fetched.color, Some("#4ea7fc".to_string())); + + // Delete the label. + client.issue_label_delete(label_id).await.unwrap(); + } + // ── Cycles ────────────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] diff --git a/crates/lineark/src/commands/labels.rs b/crates/lineark/src/commands/labels.rs index f72fdf9..a63e526 100644 --- a/crates/lineark/src/commands/labels.rs +++ b/crates/lineark/src/commands/labels.rs @@ -1,5 +1,7 @@ use clap::Args; -use lineark_sdk::generated::inputs::IssueLabelFilter; +use lineark_sdk::generated::inputs::{ + IssueLabelCreateInput, IssueLabelFilter, IssueLabelUpdateInput, +}; use lineark_sdk::generated::types::IssueLabel; use lineark_sdk::{Client, GraphQLFields}; use serde::{Deserialize, Serialize}; @@ -16,6 +18,7 @@ pub struct LabelsCmd { } #[derive(Debug, clap::Subcommand)] +#[allow(clippy::large_enum_variant)] pub enum LabelsAction { /// List all issue labels. Use --team to filter by team. List { @@ -23,6 +26,65 @@ pub enum LabelsAction { #[arg(long)] team: Option, }, + /// Show full details for a single label. + /// + /// Examples: + /// lineark labels read LABEL-UUID + Read { + /// Label UUID. + id: String, + }, + /// Create a new issue label. + /// + /// Examples: + /// lineark labels create "Bug" --color "#eb5757" + /// lineark labels create "Feature" --team ENG --color "#4ea7fc" --description "Feature requests" + /// lineark labels create "Sub-label" --parent PARENT-UUID --color "#000000" + Create { + /// Label name. + name: String, + /// Team key, name, or UUID. Omit for a workspace-wide label. + #[arg(long)] + team: Option, + /// Label color (hex string, e.g. "#eb5757"). + #[arg(long)] + color: Option, + /// Label description. + #[arg(long)] + description: Option, + /// Parent label UUID (makes this a sub-label). + #[arg(long)] + parent: Option, + }, + /// Update an existing issue label. + /// + /// Examples: + /// lineark labels update LABEL-UUID --name "Renamed" + /// lineark labels update LABEL-UUID --color "#00ff00" --description "Updated" + Update { + /// Label UUID. + id: String, + /// New label name. + #[arg(long)] + name: Option, + /// New label color (hex string). + #[arg(long)] + color: Option, + /// New label description. + #[arg(long)] + description: Option, + /// New parent label UUID. + #[arg(long)] + parent: Option, + }, + /// Delete an issue label. + /// + /// Examples: + /// lineark labels delete LABEL-UUID + Delete { + /// Label UUID. + id: String, + }, } /// Lean label type that includes the parent team. @@ -52,6 +114,42 @@ pub struct LabelRow { pub team: String, } +/// Full label detail for `labels read`. +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = IssueLabel)] +#[serde(rename_all = "camelCase", default)] +struct LabelDetail { + pub id: Option, + pub name: Option, + pub description: Option, + pub color: Option, + pub is_group: Option, + pub created_at: Option>, + pub updated_at: Option>, + #[graphql(nested)] + pub team: Option, + #[graphql(nested)] + pub parent: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = IssueLabel)] +#[serde(rename_all = "camelCase", default)] +struct LabelParentRef { + pub id: Option, + pub name: Option, +} + +/// Lean result type for label mutations. +#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = IssueLabel)] +#[serde(rename_all = "camelCase", default)] +struct LabelRef { + pub id: Option, + pub name: Option, + pub color: Option, +} + pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Result<()> { match cmd.action { LabelsAction::List { team } => { @@ -85,6 +183,77 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res output::print_table(&rows, format); } + LabelsAction::Read { id } => { + let label = client + .issue_label::(id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + output::print_one(&label, format); + } + LabelsAction::Create { + name, + team, + color, + description, + parent, + } => { + let team_id = match team { + Some(ref t) => Some(resolve_team_id(client, t).await?), + None => None, + }; + + let input = IssueLabelCreateInput { + name: Some(name), + color, + description, + parent_id: parent, + team_id, + ..Default::default() + }; + + let label = client + .issue_label_create::(None, input) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&label, format); + } + LabelsAction::Update { + id, + name, + color, + description, + parent, + } => { + if name.is_none() && color.is_none() && description.is_none() && parent.is_none() { + return Err(anyhow::anyhow!( + "No update fields provided. Use --name, --color, --description, or --parent." + )); + } + + let input = IssueLabelUpdateInput { + name, + color, + description, + parent_id: parent, + ..Default::default() + }; + + let label = client + .issue_label_update::(None, input, id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&label, format); + } + LabelsAction::Delete { id } => { + let result = client + .issue_label_delete(id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + + output::print_one(&result, format); + } } Ok(()) } diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index ccb11c8..d4f1614 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -49,6 +49,14 @@ COMMANDS: [-p 0-4] [--content TEXT] Priority, markdown content [--icon ICON] [--color COLOR] Icon, color lineark labels list [--team KEY] List issue labels (includes team key) + lineark labels read Full label detail + lineark labels create Create a label (workspace-wide if no --team) + [--team KEY] [--color HEX] Team, color + [--description TEXT] [--parent ID] Description, parent label + lineark labels update Update a label + [--name TEXT] [--color HEX] Name, color + [--description TEXT] [--parent ID] Description, parent label + lineark labels delete Delete a label lineark cycles list [-l N] [--team KEY] List cycles [--active] Only the active cycle [--around-active N] Active ± N neighbors diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 313fd32..3946541 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -324,6 +324,73 @@ fn labels_list_help_shows_team_flag() { .stdout(predicate::str::contains("--team")); } +#[test] +fn labels_help_shows_subcommands() { + lineark() + .args(["labels", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("list")) + .stdout(predicate::str::contains("read")) + .stdout(predicate::str::contains("create")) + .stdout(predicate::str::contains("update")) + .stdout(predicate::str::contains("delete")); +} + +#[test] +fn labels_create_help_shows_flags() { + lineark() + .args(["labels", "create", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--color")) + .stdout(predicate::str::contains("--team")) + .stdout(predicate::str::contains("--description")) + .stdout(predicate::str::contains("--parent")); +} + +#[test] +fn labels_update_help_shows_flags() { + lineark() + .args(["labels", "update", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--name")) + .stdout(predicate::str::contains("--color")) + .stdout(predicate::str::contains("--description")) + .stdout(predicate::str::contains("--parent")); +} + +#[test] +fn labels_update_no_flags_prints_error() { + lineark() + .args(["--api-token", "fake-token", "labels", "update", "some-uuid"]) + .assert() + .failure() + .stderr(predicate::str::contains("No update fields provided")); +} + +#[test] +fn labels_delete_help_shows_description() { + lineark() + .args(["labels", "delete", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("")); +} + +#[test] +fn usage_includes_labels_crud() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("labels read")) + .stdout(predicate::str::contains("labels create")) + .stdout(predicate::str::contains("labels update")) + .stdout(predicate::str::contains("labels delete")); +} + // ── Auth error handling ───────────────────────────────────────────────────── #[test] diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 9509f76..ca720f9 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -132,6 +132,24 @@ impl Drop for ProjectGuard { } } +/// RAII guard — deletes an issue label on drop. +struct LabelGuard { + token: String, + id: String, +} + +impl Drop for LabelGuard { + fn drop(&mut self) { + let Ok(client) = Client::from_token(self.token.clone()) else { + return; + }; + let id = self.id.clone(); + let _ = tokio::runtime::Runtime::new() + .unwrap() + .block_on(async { client.issue_label_delete(id).await }); + } +} + test_with::runner!(online); #[test_with::module] @@ -245,6 +263,112 @@ mod online { } } + #[test_with::runtime_ignore_if(no_online_test_token)] + fn labels_create_update_and_delete() { + let token = api_token(); + + // Create a workspace-level label. + let unique_name = format!("[test] lbl-crud {}", &uuid::Uuid::new_v4().to_string()[..8]); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "create", + &unique_name, + "--color", + "#eb5757", + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels create should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let created: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let label_id = created["id"] + .as_str() + .expect("created label should have id") + .to_string(); + let _label_guard = LabelGuard { + token: token.clone(), + id: label_id.clone(), + }; + assert_eq!(created["name"].as_str(), Some(unique_name.as_str())); + assert_eq!(created["color"].as_str(), Some("#eb5757")); + + // Read the label back. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "read", + &label_id, + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels read should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let detail: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(detail["id"].as_str(), Some(label_id.as_str())); + assert_eq!(detail["name"].as_str(), Some(unique_name.as_str())); + + // Update the label color. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "update", + &label_id, + "--color", + "#4ea7fc", + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels update should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let updated: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(updated["color"].as_str(), Some("#4ea7fc")); + + // Delete the label via CLI. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "delete", + &label_id, + ]) + .output() + .expect("failed to execute lineark"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels delete should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + } + // ── Issues ──────────────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] diff --git a/schema/operations.toml b/schema/operations.toml index bc16bda..8689042 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -10,6 +10,7 @@ projects = true project = true cycles = true cycle = true +issueLabel = true issueLabels = true searchIssues = true workflowStates = true @@ -51,5 +52,8 @@ projectDelete = true teamCreate = true teamUpdate = true teamDelete = true +issueLabelCreate = true +issueLabelUpdate = true +issueLabelDelete = true teamMembershipCreate = true teamMembershipDelete = true From e77d50ed968974cd4670f5a82a99099e3dc123ba Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 7 Mar 2026 15:34:20 -0300 Subject: [PATCH 02/14] fix: remove labels read, show label names in issue output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove `labels read` command and `issueLabel` query (low value — you'd only know the UUID after listing, and listing already shows everything) - Add labels to IssueSummary, SearchSummary, and IssueDetail as name-only (no UUIDs) via nested LabelConnection - Add labels column to issue list/search table output - Update --labels doc to say "names" not "names or UUIDs" - Remove LabelDetail and LabelParentRef types (no longer needed) --- .../lineark-sdk/src/generated/client_impl.rs | 11 ----- crates/lineark-sdk/src/generated/queries.rs | 17 ------- crates/lineark/src/commands/issues.rs | 47 +++++++++++++++++-- crates/lineark/src/commands/labels.rs | 41 ---------------- crates/lineark/src/commands/usage.rs | 1 - crates/lineark/tests/offline.rs | 3 +- schema/operations.toml | 1 - 7 files changed, 43 insertions(+), 78 deletions(-) diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 1501ef3..08d155c 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -135,17 +135,6 @@ impl Client { pub fn issue_labels(&self) -> IssueLabelsQueryBuilder<'_, T> { crate::generated::queries::issue_labels(self) } - /// One specific label. - /// - /// Full type: [`IssueLabel`](super::types::IssueLabel) - pub async fn issue_label< - T: DeserializeOwned + GraphQLFields, - >( - &self, - id: String, - ) -> Result { - crate::generated::queries::issue_label::(self, id).await - } /// All documents in the workspace. /// /// Full type: [`Document`](super::types::Document) diff --git a/crates/lineark-sdk/src/generated/queries.rs b/crates/lineark-sdk/src/generated/queries.rs index 4f7bc19..304d1dd 100644 --- a/crates/lineark-sdk/src/generated/queries.rs +++ b/crates/lineark-sdk/src/generated/queries.rs @@ -1281,23 +1281,6 @@ pub fn issue_labels<'a, T>(client: &'a Client) -> IssueLabelsQueryBuilder<'a, T> _marker: std::marker::PhantomData, } } -/// One specific label. -/// -/// Full type: [`IssueLabel`](super::types::IssueLabel) -pub async fn issue_label< - T: DeserializeOwned + GraphQLFields, ->( - client: &Client, - id: String, -) -> Result { - let variables = serde_json::json!({ "id" : id }); - let selection = T::selection(); - let query = format!( - "query {}({}) {{ {}({}) {{ {} }} }}", - "IssueLabel", "$id: String!", "issueLabel", "id: $id", selection - ); - client.execute::(&query, variables, "issueLabel").await -} /// All documents in the workspace. /// /// Full type: [`Document`](super::types::Document) diff --git a/crates/lineark/src/commands/issues.rs b/crates/lineark/src/commands/issues.rs index bcee59e..10f6fb8 100644 --- a/crates/lineark/src/commands/issues.rs +++ b/crates/lineark/src/commands/issues.rs @@ -3,8 +3,8 @@ use lineark_sdk::generated::inputs::{ IssueCreateInput, IssueFilter, IssueUpdateInput, WorkflowStateFilter, }; use lineark_sdk::generated::types::{ - Comment, CommentConnection, Issue, IssueConnection, IssueRelation, IssueRelationConnection, - IssueSearchResult, User, WorkflowState, + Comment, CommentConnection, Issue, IssueConnection, IssueLabel, IssueLabelConnection, + IssueRelation, IssueRelationConnection, IssueSearchResult, User, WorkflowState, }; use lineark_sdk::{Client, GraphQLFields}; use serde::{Deserialize, Serialize}; @@ -84,7 +84,7 @@ pub enum IssuesAction { /// Assignee: user name, display name, UUID, or `me`. #[arg(long)] assignee: Option, - /// Comma-separated label names or UUIDs. + /// Comma-separated label names. #[arg(long, value_delimiter = ',')] labels: Option>, /// Priority: 0=none, 1=urgent, 2=high, 3=medium, 4=low. @@ -158,7 +158,7 @@ pub enum IssuesAction { /// 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. + /// Comma-separated label names. Behavior depends on --label-by. #[arg(long, value_delimiter = ',')] labels: Option>, /// How to apply --labels: "replacing" (default), "adding", or "removing". @@ -195,7 +195,7 @@ pub enum IssuesAction { /// Estimate points (valid values depend on the team's estimation scale). #[arg(short = 'e', long)] estimate: Option, - /// Comma-separated label names or UUIDs. Behavior depends on --label-by. + /// Comma-separated label names. Behavior depends on --label-by. #[arg(long, value_delimiter = ',')] labels: Option>, /// How to apply --labels: "replacing" (default), "adding", or "removing". @@ -253,10 +253,24 @@ struct IssueRow { assignee: String, team: String, estimate: String, + labels: String, #[tabled(skip)] url: String, } +fn format_labels(labels: &Option) -> String { + labels + .as_ref() + .map(|lc| { + lc.nodes + .iter() + .filter_map(|l| l.name.clone()) + .collect::>() + .join(", ") + }) + .unwrap_or_default() +} + impl From<&IssueSummary> for IssueRow { fn from(i: &IssueSummary) -> Self { Self { @@ -279,6 +293,7 @@ impl From<&IssueSummary> for IssueRow { .and_then(|t| t.key.clone()) .unwrap_or_default(), estimate: format_estimate(i.estimate), + labels: format_labels(&i.labels), url: i.url.clone().unwrap_or_default(), } } @@ -306,6 +321,7 @@ impl From<&SearchSummary> for IssueRow { .and_then(|t| t.key.clone()) .unwrap_or_default(), estimate: format_estimate(i.estimate), + labels: format_labels(&i.labels), url: i.url.clone().unwrap_or_default(), } } @@ -331,6 +347,8 @@ pub struct IssueSummary { pub assignee: Option, #[graphql(nested)] pub team: Option, + #[graphql(nested)] + pub labels: Option, } /// Lean search result type for `issues search` with nested fields. @@ -351,6 +369,8 @@ pub struct SearchSummary { pub assignee: Option, #[graphql(nested)] pub team: Option, + #[graphql(nested)] + pub labels: Option, } // ── IssueDetail — custom type for `issues read` with nested data ───────── @@ -379,6 +399,8 @@ pub struct IssueDetail { #[graphql(nested)] pub team: Option, #[graphql(nested)] + pub labels: Option, + #[graphql(nested)] pub relations: Option, #[graphql(nested)] pub inverse_relations: Option, @@ -414,6 +436,21 @@ pub struct TeamRef { pub key: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, Default, GraphQLFields)] +#[graphql(full_type = IssueLabelConnection)] +#[serde(rename_all = "camelCase", default)] +pub struct LabelConnection { + #[graphql(nested)] + pub nodes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, GraphQLFields)] +#[graphql(full_type = IssueLabel)] +#[serde(rename_all = "camelCase", default)] +pub struct LabelNameRef { + pub name: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, GraphQLFields)] #[graphql(full_type = IssueRelationConnection)] #[serde(rename_all = "camelCase", default)] diff --git a/crates/lineark/src/commands/labels.rs b/crates/lineark/src/commands/labels.rs index a63e526..ed72c1d 100644 --- a/crates/lineark/src/commands/labels.rs +++ b/crates/lineark/src/commands/labels.rs @@ -26,14 +26,6 @@ pub enum LabelsAction { #[arg(long)] team: Option, }, - /// Show full details for a single label. - /// - /// Examples: - /// lineark labels read LABEL-UUID - Read { - /// Label UUID. - id: String, - }, /// Create a new issue label. /// /// Examples: @@ -114,32 +106,6 @@ pub struct LabelRow { pub team: String, } -/// Full label detail for `labels read`. -#[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] -#[graphql(full_type = IssueLabel)] -#[serde(rename_all = "camelCase", default)] -struct LabelDetail { - pub id: Option, - pub name: Option, - pub description: Option, - pub color: Option, - pub is_group: Option, - pub created_at: Option>, - pub updated_at: Option>, - #[graphql(nested)] - pub team: Option, - #[graphql(nested)] - pub parent: Option, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] -#[graphql(full_type = IssueLabel)] -#[serde(rename_all = "camelCase", default)] -struct LabelParentRef { - pub id: Option, - pub name: Option, -} - /// Lean result type for label mutations. #[derive(Debug, Default, Serialize, Deserialize, GraphQLFields)] #[graphql(full_type = IssueLabel)] @@ -183,13 +149,6 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res output::print_table(&rows, format); } - LabelsAction::Read { id } => { - let label = client - .issue_label::(id) - .await - .map_err(|e| anyhow::anyhow!("{}", e))?; - output::print_one(&label, format); - } LabelsAction::Create { name, team, diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index 61da0ba..847fc8c 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -49,7 +49,6 @@ COMMANDS: [-p 0-4] [--content TEXT] Priority, markdown content [--icon ICON] [--color COLOR] Icon, color lineark labels list [--team KEY] List issue labels (includes team key) - lineark labels read Full label detail lineark labels create Create a label (workspace-wide if no --team) [--team KEY] [--color HEX] Team, color [--description TEXT] [--parent ID] Description, parent label diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 6371a1e..68fe480 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -331,7 +331,6 @@ fn labels_help_shows_subcommands() { .assert() .success() .stdout(predicate::str::contains("list")) - .stdout(predicate::str::contains("read")) .stdout(predicate::str::contains("create")) .stdout(predicate::str::contains("update")) .stdout(predicate::str::contains("delete")); @@ -385,7 +384,7 @@ fn usage_includes_labels_crud() { .arg("usage") .assert() .success() - .stdout(predicate::str::contains("labels read")) + .stdout(predicate::str::contains("labels list")) .stdout(predicate::str::contains("labels create")) .stdout(predicate::str::contains("labels update")) .stdout(predicate::str::contains("labels delete")); diff --git a/schema/operations.toml b/schema/operations.toml index 3d56c32..95fe199 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -10,7 +10,6 @@ projects = true project = true cycles = true cycle = true -issueLabel = true issueLabels = true searchIssues = true issueVcsBranchSearch = true From 0f8e0663c2cc1595a401a048237825be8a900e2e Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 7 Mar 2026 15:41:25 -0300 Subject: [PATCH 03/14] fix: update tests for labels read removal --- crates/lineark-sdk/tests/online.rs | 8 +------- crates/lineark/tests/online.rs | 23 ----------------------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index bc7a193..d6dd339 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -288,13 +288,7 @@ mod online { .await .unwrap(); assert!(updated.id.is_some()); - - // Verify the update by reading the label. - let fetched = client - .issue_label::(label_id.clone()) - .await - .unwrap(); - assert_eq!(fetched.color, Some("#4ea7fc".to_string())); + assert_eq!(updated.color, Some("#4ea7fc".to_string())); // Delete the label. client.issue_label_delete(label_id).await.unwrap(); diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index b6204bd..0f36ec6 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -324,29 +324,6 @@ mod online { assert_eq!(created["name"].as_str(), Some(unique_name.as_str())); assert_eq!(created["color"].as_str(), Some("#eb5757")); - // Read the label back. - let output = lineark() - .args([ - "--api-token", - &token, - "--format", - "json", - "labels", - "read", - &label_id, - ]) - .output() - .expect("failed to execute lineark"); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - output.status.success(), - "labels read should succeed.\nstdout: {stdout}\nstderr: {stderr}" - ); - let detail: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - assert_eq!(detail["id"].as_str(), Some(label_id.as_str())); - assert_eq!(detail["name"].as_str(), Some(unique_name.as_str())); - // Update the label color. let output = lineark() .args([ From 7903f6073352cca3697c3cfa60c1682b3e14140a Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 7 Mar 2026 16:05:52 -0300 Subject: [PATCH 04/14] fix: resolve_label_ids searches workspace labels when team-scoped When creating an issue with --team and --labels, resolve_label_ids now fetches both team-scoped and workspace-wide labels. Previously workspace labels were invisible when a team filter was active. Adds online test for labels with spaces in names. --- crates/lineark/src/commands/helpers.rs | 42 +++++++++-- crates/lineark/tests/online.rs | 96 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 6 deletions(-) diff --git a/crates/lineark/src/commands/helpers.rs b/crates/lineark/src/commands/helpers.rs index 13afef2..657cd61 100644 --- a/crates/lineark/src/commands/helpers.rs +++ b/crates/lineark/src/commands/helpers.rs @@ -240,22 +240,52 @@ pub async fn resolve_label_ids( return Ok(names_or_ids.to_vec()); } - // Fetch labels, optionally filtered by team. - let mut builder = client.issue_labels::().first(250); + // Fetch labels. When a team is provided, fetch both team-scoped and + // workspace-wide labels so that workspace labels can be used on any team's issues. + let mut all_labels: Vec = Vec::new(); + if let Some(tid) = team_id { + // Team-scoped labels first. let filter: lineark_sdk::generated::inputs::IssueLabelFilter = serde_json::from_value(serde_json::json!({ "team": { "id": { "eq": tid } } })) .expect("valid IssueLabelFilter"); - builder = builder.filter(filter); + let conn = client + .issue_labels::() + .first(250) + .filter(filter) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + all_labels.extend(conn.nodes); + + // Workspace-wide labels (no team). + let ws_filter: lineark_sdk::generated::inputs::IssueLabelFilter = + serde_json::from_value(serde_json::json!({ "team": { "null": true } })) + .expect("valid IssueLabelFilter"); + let ws_conn = client + .issue_labels::() + .first(250) + .filter(ws_filter) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + all_labels.extend(ws_conn.nodes); + } else { + let conn = client + .issue_labels::() + .first(250) + .send() + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + all_labels = conn.nodes; } - let conn = builder.send().await.map_err(|e| anyhow::anyhow!("{}", e))?; let mut resolved = Vec::with_capacity(names_or_ids.len()); for item in names_or_ids { if uuid::Uuid::parse_str(item).is_ok() { resolved.push(item.clone()); } else { - let found = conn.nodes.iter().find(|l| { + let found = all_labels.iter().find(|l| { l.name .as_deref() .is_some_and(|n| n.eq_ignore_ascii_case(item)) @@ -264,7 +294,7 @@ pub async fn resolve_label_ids( Some(label) => resolved.push(label.id.clone().unwrap_or_default()), None => { let available: Vec = - conn.nodes.iter().filter_map(|l| l.name.clone()).collect(); + all_labels.iter().filter_map(|l| l.name.clone()).collect(); return Err(anyhow::anyhow!( "Label '{}' not found. Available: {}", item, diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 0f36ec6..490bb78 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -369,6 +369,102 @@ mod online { ); } + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_create_with_spaced_label_and_read_back() { + let token = api_token(); + let (_team_key, team_id, _team_guard) = create_test_team(); + + // Create a label with a space in the name. + let uid = &uuid::Uuid::new_v4().to_string()[..8]; + let label_name = format!("[test] Tech Debt {uid}"); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "create", + &label_name, + "--color", + "#eb5757", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels create should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let created: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let label_id = created["id"].as_str().unwrap().to_string(); + let _label_guard = LabelGuard { + token: token.clone(), + id: label_id, + }; + + // Create an issue with the spaced label. + let issue_title = format!("[test] spaced label {uid}"); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "create", + &issue_title, + "--team", + &team_id, + "--labels", + &label_name, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "issues create with spaced label should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let issue: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let issue_id = issue["id"].as_str().unwrap().to_string(); + let _issue_guard = IssueGuard { + token: token.clone(), + id: issue_id.clone(), + }; + + // Read the issue back and verify label appears by name. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "read", + &issue_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "issues read should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let detail: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let labels = detail["labels"]["nodes"] + .as_array() + .expect("labels.nodes should be an array"); + let label_names: Vec<&str> = labels.iter().filter_map(|l| l["name"].as_str()).collect(); + assert!( + label_names.contains(&label_name.as_str()), + "issue should have the spaced label '{label_name}', got: {label_names:?}" + ); + } + // ── Issues ──────────────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] From 42c7d6526f043599b3592107241f3706b391f78e Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 7 Mar 2026 16:42:37 -0300 Subject: [PATCH 05/14] feat: show parent in labels list, add --clear-parent to labels update - labels list now shows the parent label name column - labels update supports --clear-parent to remove parent relationship - labels create/update with --parent auto-promotes the parent to a group (Linear requires is_group=true before a label can have children) - Adds online test for parent set/list/clear lifecycle --- crates/lineark/src/commands/labels.rs | 85 +++++++++++++++-- crates/lineark/src/commands/usage.rs | 3 +- crates/lineark/tests/online.rs | 131 ++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 8 deletions(-) diff --git a/crates/lineark/src/commands/labels.rs b/crates/lineark/src/commands/labels.rs index ed72c1d..bde9770 100644 --- a/crates/lineark/src/commands/labels.rs +++ b/crates/lineark/src/commands/labels.rs @@ -68,6 +68,9 @@ pub enum LabelsAction { /// New parent label UUID. #[arg(long)] parent: Option, + /// Remove the parent label relationship. + #[arg(long, default_value = "false", conflicts_with = "parent")] + clear_parent: bool, }, /// Delete an issue label. /// @@ -79,7 +82,7 @@ pub enum LabelsAction { }, } -/// Lean label type that includes the parent team. +/// Lean label type that includes team and parent. #[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] #[graphql(full_type = IssueLabel)] #[serde(rename_all = "camelCase", default)] @@ -89,6 +92,8 @@ struct LabelSummary { pub color: Option, #[graphql(nested)] pub team: Option, + #[graphql(nested)] + pub parent: Option>, } #[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] @@ -98,12 +103,20 @@ struct LabelTeamRef { pub key: Option, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] +#[graphql(full_type = IssueLabel)] +#[serde(rename_all = "camelCase", default)] +struct LabelParentRef { + pub name: Option, +} + #[derive(Debug, Serialize, Tabled)] pub struct LabelRow { pub id: String, pub name: String, pub color: String, pub team: String, + pub parent: String, } /// Lean result type for label mutations. @@ -144,6 +157,11 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res .as_ref() .and_then(|t| t.key.clone()) .unwrap_or_default(), + parent: l + .parent + .as_ref() + .and_then(|p| p.name.clone()) + .unwrap_or_default(), }) .collect(); @@ -161,6 +179,19 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res None => None, }; + // If --parent is set, ensure the parent label is marked as a group + // (Linear requires this before a label can have children). + if let Some(ref pid) = parent { + let group_input = IssueLabelUpdateInput { + is_group: Some(true), + ..Default::default() + }; + client + .issue_label_update::(None, group_input, pid.clone()) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + } + let input = IssueLabelCreateInput { name: Some(name), color, @@ -183,13 +214,31 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res color, description, parent, + clear_parent, } => { - if name.is_none() && color.is_none() && description.is_none() && parent.is_none() { + if name.is_none() + && color.is_none() + && description.is_none() + && parent.is_none() + && !clear_parent + { return Err(anyhow::anyhow!( - "No update fields provided. Use --name, --color, --description, or --parent." + "No update fields provided. Use --name, --color, --description, --parent, or --clear-parent." )); } + // If --parent is set, ensure the parent label is marked as a group. + if let Some(ref pid) = parent { + let group_input = IssueLabelUpdateInput { + is_group: Some(true), + ..Default::default() + }; + client + .issue_label_update::(None, group_input, pid.clone()) + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + } + let input = IssueLabelUpdateInput { name, color, @@ -198,10 +247,32 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res ..Default::default() }; - let label = client - .issue_label_update::(None, input, id) - .await - .map_err(|e| anyhow::anyhow!("{}", e))?; + // When --clear-parent is used, send `parentId: null` to the API. + // The generated input uses skip_serializing_if so None omits the field. + let label = if clear_parent { + let mut input_val = serde_json::to_value(&input)?; + input_val + .as_object_mut() + .unwrap() + .insert("parentId".to_string(), serde_json::Value::Null); + let variables = serde_json::json!({ "input": input_val, "id": id }); + let sel = ::selection(); + let query = format!( + "mutation($input: IssueLabelUpdateInput!, $id: String!) {{ issueLabelUpdate(input: $input, id: $id) {{ success issueLabel {{ {sel} }} }} }}" + ); + let payload: serde_json::Value = client + .execute(&query, variables, "issueLabelUpdate") + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + serde_json::from_value::( + payload.get("issueLabel").cloned().unwrap_or_default(), + )? + } else { + client + .issue_label_update::(None, input, id) + .await + .map_err(|e| anyhow::anyhow!("{}", e))? + }; output::print_one(&label, format); } diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index 847fc8c..249253f 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -48,13 +48,14 @@ COMMANDS: [--start-date DATE] [--target-date DATE] Dates (YYYY-MM-DD) [-p 0-4] [--content TEXT] Priority, markdown content [--icon ICON] [--color COLOR] Icon, color - lineark labels list [--team KEY] List issue labels (includes team key) + lineark labels list [--team KEY] List labels (team, parent, color) lineark labels create Create a label (workspace-wide if no --team) [--team KEY] [--color HEX] Team, color [--description TEXT] [--parent ID] Description, parent label lineark labels update Update a label [--name TEXT] [--color HEX] Name, color [--description TEXT] [--parent ID] Description, parent label + [--clear-parent] Remove parent label lineark labels delete Delete a label lineark cycles list [-l N] [--team KEY] List cycles [--active] Only the active cycle diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 490bb78..49c1e57 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -369,6 +369,137 @@ mod online { ); } + #[test_with::runtime_ignore_if(no_online_test_token)] + fn labels_parent_set_list_and_clear() { + let token = api_token(); + let uid = &uuid::Uuid::new_v4().to_string()[..8]; + + // Create a parent label. + let parent_name = format!("[test] Parent {uid}"); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "create", + &parent_name, + "--color", + "#000000", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "parent create failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let parent: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let parent_id = parent["id"].as_str().unwrap().to_string(); + let _parent_guard = LabelGuard { + token: token.clone(), + id: parent_id.clone(), + }; + + // Create a child label with --parent. + let child_name = format!("[test] Child {uid}"); + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "create", + &child_name, + "--color", + "#ffffff", + "--parent", + &parent_id, + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "child create failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let child: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let child_id = child["id"].as_str().unwrap().to_string(); + let _child_guard = LabelGuard { + token: token.clone(), + id: child_id.clone(), + }; + + // List labels and verify the child shows the parent name. + let output = lineark() + .args(["--api-token", &token, "--format", "json", "labels", "list"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels list failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let labels: Vec = serde_json::from_str(&stdout).unwrap(); + let child_row = labels + .iter() + .find(|l| l["id"].as_str() == Some(&child_id)) + .expect("child label should appear in list"); + assert_eq!( + child_row["parent"].as_str(), + Some(parent_name.as_str()), + "child should show parent name in list" + ); + + // Clear the parent with --clear-parent. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "update", + &child_id, + "--clear-parent", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "clear-parent failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + + // List again and verify parent is now empty. + let output = lineark() + .args(["--api-token", &token, "--format", "json", "labels", "list"]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "labels list failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let labels: Vec = serde_json::from_str(&stdout).unwrap(); + let child_row = labels + .iter() + .find(|l| l["id"].as_str() == Some(&child_id)) + .expect("child label should appear in list after clear-parent"); + assert_eq!( + child_row["parent"].as_str(), + Some(""), + "parent should be empty after --clear-parent" + ); + } + #[test_with::runtime_ignore_if(no_online_test_token)] fn issues_create_with_spaced_label_and_read_back() { let token = api_token(); From 873b54cc50678dbba41a789f26298db91cb8d725 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 7 Mar 2026 16:56:57 -0300 Subject: [PATCH 06/14] feat: explicit --group flag for label groups, no auto-promotion - labels create: add --group flag to create a group label - labels update: add --group/--group false to promote/demote (demoting fails if label still has children) - labels list: show group column ("yes" or empty) - Remove auto-promotion of parent labels to groups - Test covers full lifecycle: create group, add child, list, clear parent, demote group --- crates/lineark/src/commands/labels.rs | 57 +++++++++---------- crates/lineark/src/commands/usage.rs | 10 ++-- crates/lineark/tests/online.rs | 79 +++++++++++++++++++++------ 3 files changed, 94 insertions(+), 52 deletions(-) diff --git a/crates/lineark/src/commands/labels.rs b/crates/lineark/src/commands/labels.rs index bde9770..9903463 100644 --- a/crates/lineark/src/commands/labels.rs +++ b/crates/lineark/src/commands/labels.rs @@ -31,7 +31,8 @@ pub enum LabelsAction { /// Examples: /// lineark labels create "Bug" --color "#eb5757" /// lineark labels create "Feature" --team ENG --color "#4ea7fc" --description "Feature requests" - /// lineark labels create "Sub-label" --parent PARENT-UUID --color "#000000" + /// lineark labels create "Category" --group --color "#000000" + /// lineark labels create "Sub-label" --parent PARENT-UUID --color "#ffffff" Create { /// Label name. name: String, @@ -44,15 +45,20 @@ pub enum LabelsAction { /// Label description. #[arg(long)] description: Option, - /// Parent label UUID (makes this a sub-label). + /// Parent label UUID (makes this a sub-label; parent must be a group). #[arg(long)] parent: Option, + /// Create as a group label (required before other labels can use it as --parent). + #[arg(long, default_value = "false")] + group: bool, }, /// Update an existing issue label. /// /// Examples: /// lineark labels update LABEL-UUID --name "Renamed" /// lineark labels update LABEL-UUID --color "#00ff00" --description "Updated" + /// lineark labels update LABEL-UUID --group # promote to group + /// lineark labels update LABEL-UUID --no-group # demote (must have no children) Update { /// Label UUID. id: String, @@ -65,12 +71,16 @@ pub enum LabelsAction { /// New label description. #[arg(long)] description: Option, - /// New parent label UUID. + /// New parent label UUID (parent must be a group). #[arg(long)] parent: Option, /// Remove the parent label relationship. #[arg(long, default_value = "false", conflicts_with = "parent")] clear_parent: bool, + /// Promote to group (--group) or demote to plain label (--no-group). + /// Demoting fails if the label still has children. + #[arg(long, action = clap::ArgAction::Set, default_missing_value = "true", num_args = 0..=1)] + group: Option, }, /// Delete an issue label. /// @@ -82,7 +92,7 @@ pub enum LabelsAction { }, } -/// Lean label type that includes team and parent. +/// Lean label type that includes team, parent, and group status. #[derive(Debug, Clone, Default, Serialize, Deserialize, GraphQLFields)] #[graphql(full_type = IssueLabel)] #[serde(rename_all = "camelCase", default)] @@ -90,6 +100,7 @@ struct LabelSummary { pub id: Option, pub name: Option, pub color: Option, + pub is_group: Option, #[graphql(nested)] pub team: Option, #[graphql(nested)] @@ -115,6 +126,7 @@ pub struct LabelRow { pub id: String, pub name: String, pub color: String, + pub group: String, pub team: String, pub parent: String, } @@ -152,6 +164,11 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res id: l.id.clone().unwrap_or_default(), name: l.name.clone().unwrap_or_default(), color: l.color.clone().unwrap_or_default(), + group: if l.is_group.unwrap_or(false) { + "yes".to_string() + } else { + String::new() + }, team: l .team .as_ref() @@ -173,31 +190,20 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res color, description, parent, + group, } => { let team_id = match team { Some(ref t) => Some(resolve_team_id(client, t).await?), None => None, }; - // If --parent is set, ensure the parent label is marked as a group - // (Linear requires this before a label can have children). - if let Some(ref pid) = parent { - let group_input = IssueLabelUpdateInput { - is_group: Some(true), - ..Default::default() - }; - client - .issue_label_update::(None, group_input, pid.clone()) - .await - .map_err(|e| anyhow::anyhow!("{}", e))?; - } - let input = IssueLabelCreateInput { name: Some(name), color, description, parent_id: parent, team_id, + is_group: if group { Some(true) } else { None }, ..Default::default() }; @@ -215,35 +221,26 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res description, parent, clear_parent, + group, } => { if name.is_none() && color.is_none() && description.is_none() && parent.is_none() && !clear_parent + && group.is_none() { return Err(anyhow::anyhow!( - "No update fields provided. Use --name, --color, --description, --parent, or --clear-parent." + "No update fields provided. Use --name, --color, --description, --parent, --clear-parent, --group, or --no-group." )); } - // If --parent is set, ensure the parent label is marked as a group. - if let Some(ref pid) = parent { - let group_input = IssueLabelUpdateInput { - is_group: Some(true), - ..Default::default() - }; - client - .issue_label_update::(None, group_input, pid.clone()) - .await - .map_err(|e| anyhow::anyhow!("{}", e))?; - } - let input = IssueLabelUpdateInput { name, color, description, parent_id: parent, + is_group: group, ..Default::default() }; diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index 249253f..019685d 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -48,14 +48,14 @@ COMMANDS: [--start-date DATE] [--target-date DATE] Dates (YYYY-MM-DD) [-p 0-4] [--content TEXT] Priority, markdown content [--icon ICON] [--color COLOR] Icon, color - lineark labels list [--team KEY] List labels (team, parent, color) + lineark labels list [--team KEY] List labels (group, team, parent, color) lineark labels create Create a label (workspace-wide if no --team) - [--team KEY] [--color HEX] Team, color - [--description TEXT] [--parent ID] Description, parent label + [--team KEY] [--color HEX] [--group] Team, color, group flag + [--description TEXT] [--parent ID] Description, parent (must be group) lineark labels update Update a label [--name TEXT] [--color HEX] Name, color - [--description TEXT] [--parent ID] Description, parent label - [--clear-parent] Remove parent label + [--description TEXT] [--parent ID] Description, parent (must be group) + [--clear-parent] [--group] [--no-group] Clear parent, promote/demote group lineark labels delete Delete a label lineark cycles list [-l N] [--team KEY] List cycles [--active] Only the active cycle diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 49c1e57..3768150 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -370,12 +370,12 @@ mod online { } #[test_with::runtime_ignore_if(no_online_test_token)] - fn labels_parent_set_list_and_clear() { + fn labels_group_lifecycle() { let token = api_token(); let uid = &uuid::Uuid::new_v4().to_string()[..8]; - // Create a parent label. - let parent_name = format!("[test] Parent {uid}"); + // 1. Create a group label with --group. + let group_name = format!("[test] Group {uid}"); let output = lineark() .args([ "--api-token", @@ -384,9 +384,10 @@ mod online { "json", "labels", "create", - &parent_name, + &group_name, "--color", "#000000", + "--group", ]) .output() .unwrap(); @@ -394,16 +395,16 @@ mod online { let stderr = String::from_utf8_lossy(&output.stderr); assert!( output.status.success(), - "parent create failed.\nstdout: {stdout}\nstderr: {stderr}" + "group create failed.\nstdout: {stdout}\nstderr: {stderr}" ); - let parent: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let parent_id = parent["id"].as_str().unwrap().to_string(); - let _parent_guard = LabelGuard { + let group: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let group_id = group["id"].as_str().unwrap().to_string(); + let _group_guard = LabelGuard { token: token.clone(), - id: parent_id.clone(), + id: group_id.clone(), }; - // Create a child label with --parent. + // 2. Create a child label under the group. let child_name = format!("[test] Child {uid}"); let output = lineark() .args([ @@ -417,7 +418,7 @@ mod online { "--color", "#ffffff", "--parent", - &parent_id, + &group_id, ]) .output() .unwrap(); @@ -434,7 +435,7 @@ mod online { id: child_id.clone(), }; - // List labels and verify the child shows the parent name. + // 3. List labels — child should show parent name, group should show "yes". let output = lineark() .args(["--api-token", &token, "--format", "json", "labels", "list"]) .output() @@ -446,17 +447,28 @@ mod online { "labels list failed.\nstdout: {stdout}\nstderr: {stderr}" ); let labels: Vec = serde_json::from_str(&stdout).unwrap(); + + let group_row = labels + .iter() + .find(|l| l["id"].as_str() == Some(&group_id)) + .expect("group should appear in list"); + assert_eq!( + group_row["group"].as_str(), + Some("yes"), + "group label should show 'yes' in group column" + ); + let child_row = labels .iter() .find(|l| l["id"].as_str() == Some(&child_id)) - .expect("child label should appear in list"); + .expect("child should appear in list"); assert_eq!( child_row["parent"].as_str(), - Some(parent_name.as_str()), + Some(group_name.as_str()), "child should show parent name in list" ); - // Clear the parent with --clear-parent. + // 4. Clear the child's parent with --clear-parent. let output = lineark() .args([ "--api-token", @@ -477,7 +489,29 @@ mod online { "clear-parent failed.\nstdout: {stdout}\nstderr: {stderr}" ); - // List again and verify parent is now empty. + // 5. Demote the group back to a plain label with --no-group. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "labels", + "update", + &group_id, + "--group", + "false", + ]) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "demote group failed.\nstdout: {stdout}\nstderr: {stderr}" + ); + + // 6. List again — group column should be empty, parent should be empty. let output = lineark() .args(["--api-token", &token, "--format", "json", "labels", "list"]) .output() @@ -489,10 +523,21 @@ mod online { "labels list failed.\nstdout: {stdout}\nstderr: {stderr}" ); let labels: Vec = serde_json::from_str(&stdout).unwrap(); + + let group_row = labels + .iter() + .find(|l| l["id"].as_str() == Some(&group_id)) + .expect("group should still appear in list"); + assert_eq!( + group_row["group"].as_str(), + Some(""), + "group column should be empty after --no-group" + ); + let child_row = labels .iter() .find(|l| l["id"].as_str() == Some(&child_id)) - .expect("child label should appear in list after clear-parent"); + .expect("child should appear in list"); assert_eq!( child_row["parent"].as_str(), Some(""), From 42a8916e436ea4c1aa3c9df5358d8a6919f49059 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 7 Mar 2026 17:35:37 -0300 Subject: [PATCH 07/14] refactor: improve labels list output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove id column (labels are referenced by name, not UUID) - Rename group → is_label_group, parent → parent_label for clarity - Sort output: groups first with children underneath, then standalone labels --- crates/lineark/src/commands/labels.rs | 94 ++++++++++++++++++++------- crates/lineark/tests/online.rs | 24 +++---- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/crates/lineark/src/commands/labels.rs b/crates/lineark/src/commands/labels.rs index 9903463..e777e4b 100644 --- a/crates/lineark/src/commands/labels.rs +++ b/crates/lineark/src/commands/labels.rs @@ -123,12 +123,11 @@ struct LabelParentRef { #[derive(Debug, Serialize, Tabled)] pub struct LabelRow { - pub id: String, pub name: String, pub color: String, - pub group: String, + pub is_label_group: String, pub team: String, - pub parent: String, + pub parent_label: String, } /// Lean result type for label mutations. @@ -141,6 +140,28 @@ struct LabelRef { pub color: Option, } +fn label_to_row(l: &LabelSummary) -> LabelRow { + LabelRow { + name: l.name.clone().unwrap_or_default(), + color: l.color.clone().unwrap_or_default(), + is_label_group: if l.is_group.unwrap_or(false) { + "yes".to_string() + } else { + String::new() + }, + team: l + .team + .as_ref() + .and_then(|t| t.key.clone()) + .unwrap_or_default(), + parent_label: l + .parent + .as_ref() + .and_then(|p| p.name.clone()) + .unwrap_or_default(), + } +} + pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Result<()> { match cmd.action { LabelsAction::List { team } => { @@ -157,30 +178,53 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res let conn = query.send().await.map_err(|e| anyhow::anyhow!("{}", e))?; - let rows: Vec = conn - .nodes + // Sort: groups first (with their children right after), then ungrouped labels. + let labels = &conn.nodes; + let mut rows: Vec = Vec::with_capacity(labels.len()); + + // Collect group labels and their children. + let mut used_ids: std::collections::HashSet = std::collections::HashSet::new(); + for g in labels.iter().filter(|l| l.is_group.unwrap_or(false)) { + let gid = g.id.clone().unwrap_or_default(); + let gname = g.name.clone().unwrap_or_default(); + used_ids.insert(gid.clone()); + rows.push(label_to_row(g)); + // Children of this group, sorted by name. + let mut children: Vec<&LabelSummary> = labels + .iter() + .filter(|l| { + l.parent + .as_ref() + .and_then(|p| p.name.as_deref()) + .is_some_and(|n| n == gname) + }) + .collect(); + children.sort_by(|a, b| { + a.name + .as_deref() + .unwrap_or("") + .cmp(b.name.as_deref().unwrap_or("")) + }); + for c in children { + used_ids.insert(c.id.clone().unwrap_or_default()); + rows.push(label_to_row(c)); + } + } + + // Remaining ungrouped labels (no parent, not a group). + let mut rest: Vec<&LabelSummary> = labels .iter() - .map(|l| LabelRow { - id: l.id.clone().unwrap_or_default(), - name: l.name.clone().unwrap_or_default(), - color: l.color.clone().unwrap_or_default(), - group: if l.is_group.unwrap_or(false) { - "yes".to_string() - } else { - String::new() - }, - team: l - .team - .as_ref() - .and_then(|t| t.key.clone()) - .unwrap_or_default(), - parent: l - .parent - .as_ref() - .and_then(|p| p.name.clone()) - .unwrap_or_default(), - }) + .filter(|l| !used_ids.contains(l.id.as_deref().unwrap_or(""))) .collect(); + rest.sort_by(|a, b| { + a.name + .as_deref() + .unwrap_or("") + .cmp(b.name.as_deref().unwrap_or("")) + }); + for l in rest { + rows.push(label_to_row(l)); + } output::print_table(&rows, format); } diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 3768150..6cd9500 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -450,22 +450,22 @@ mod online { let group_row = labels .iter() - .find(|l| l["id"].as_str() == Some(&group_id)) + .find(|l| l["name"].as_str() == Some(group_name.as_str())) .expect("group should appear in list"); assert_eq!( - group_row["group"].as_str(), + group_row["is_label_group"].as_str(), Some("yes"), - "group label should show 'yes' in group column" + "group label should show 'yes' in is_label_group column" ); let child_row = labels .iter() - .find(|l| l["id"].as_str() == Some(&child_id)) + .find(|l| l["name"].as_str() == Some(child_name.as_str())) .expect("child should appear in list"); assert_eq!( - child_row["parent"].as_str(), + child_row["parent_label"].as_str(), Some(group_name.as_str()), - "child should show parent name in list" + "child should show parent_label name in list" ); // 4. Clear the child's parent with --clear-parent. @@ -526,22 +526,22 @@ mod online { let group_row = labels .iter() - .find(|l| l["id"].as_str() == Some(&group_id)) + .find(|l| l["name"].as_str() == Some(group_name.as_str())) .expect("group should still appear in list"); assert_eq!( - group_row["group"].as_str(), + group_row["is_label_group"].as_str(), Some(""), - "group column should be empty after --no-group" + "is_label_group should be empty after --no-group" ); let child_row = labels .iter() - .find(|l| l["id"].as_str() == Some(&child_id)) + .find(|l| l["name"].as_str() == Some(child_name.as_str())) .expect("child should appear in list"); assert_eq!( - child_row["parent"].as_str(), + child_row["parent_label"].as_str(), Some(""), - "parent should be empty after --clear-parent" + "parent_label should be empty after --clear-parent" ); } From a5f7b6f7e5f3489a6311769f612a8ed035b9ea32 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 7 Mar 2026 17:43:45 -0300 Subject: [PATCH 08/14] fix: restore id column in labels list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Label names aren't globally unique — two different teams can have the same label name. UUIDs are needed for --parent, update, and delete. --- crates/lineark/src/commands/labels.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/lineark/src/commands/labels.rs b/crates/lineark/src/commands/labels.rs index e777e4b..aa60f18 100644 --- a/crates/lineark/src/commands/labels.rs +++ b/crates/lineark/src/commands/labels.rs @@ -123,6 +123,7 @@ struct LabelParentRef { #[derive(Debug, Serialize, Tabled)] pub struct LabelRow { + pub id: String, pub name: String, pub color: String, pub is_label_group: String, @@ -142,6 +143,7 @@ struct LabelRef { fn label_to_row(l: &LabelSummary) -> LabelRow { LabelRow { + id: l.id.clone().unwrap_or_default(), name: l.name.clone().unwrap_or_default(), color: l.color.clone().unwrap_or_default(), is_label_group: if l.is_group.unwrap_or(false) { From 054552a1e09576fb6e99feb66650bb1caad937e1 Mon Sep 17 00:00:00 2001 From: Cadu Date: Sat, 7 Mar 2026 17:48:14 -0300 Subject: [PATCH 09/14] docs: update all READMEs with latest commands and SDK methods - SDK: add comment_update/resolve/unresolve, issue_batch_update, issue_vcs_branch_search, issue_label_create/update/delete - CLI: add labels CRUD, issues batch-update/find-branch, comments update/resolve/unresolve, estimate flags - Top-level: split Labels into its own row, update Issues and Comments rows with new subcommands --- README.md | 7 ++++--- crates/lineark-sdk/README.md | 12 ++++++++++-- crates/lineark/README.md | 23 +++++++++++++++++++---- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 7bba754..e3d0947 100644 --- a/README.md +++ b/README.md @@ -70,15 +70,16 @@ That's it. Your agent discovers all commands at runtime by running `lineark usag | Area | Commands | |------|----------| -| **Issues** | `list`, `read`, `search`, `create`, `update`, `archive`, `unarchive`, `delete` | -| **Comments** | `create`, `delete` | +| **Issues** | `list`, `read`, `search`, `find-branch`, `create`, `update`, `batch-update`, `archive`, `unarchive`, `delete` | +| **Comments** | `create`, `update`, `resolve`, `unresolve`, `delete` | | **Relations** | `create` (blocks, blocked-by, related, duplicate, similar), `delete` | +| **Labels** | `list`, `create`, `update`, `delete` (groups, parent labels, team-scoped) | | **Projects** | `list`, `read`, `create` | | **Milestones** | `list`, `read`, `create`, `update`, `delete` | | **Cycles** | `list`, `read` | | **Documents** | `list`, `read`, `create`, `update`, `delete` | | **Teams** | `list`, `read`, `create`, `update`, `delete`, `members add`, `members remove` | -| **Users / Labels** | `list` | +| **Users** | `list` | | **File embeds** | `upload`, `download` | Every command supports `--help` for full details. Most flags accept human-readable names — `--team ENG`, `--assignee "Jane Doe"`, `--labels "Bug,P0"` — no UUIDs required. diff --git a/crates/lineark-sdk/README.md b/crates/lineark-sdk/README.md index 0ee0efa..35f9865 100644 --- a/crates/lineark-sdk/README.md +++ b/crates/lineark-sdk/README.md @@ -164,11 +164,21 @@ let payload = client.issue_create::(IssueCreateInput { |--------|-------------| | `issue_create(input)` | Create an issue | | `issue_update(input, id)` | Update an issue | +| `issue_batch_update(input, ids)` | Batch update multiple issues | | `issue_archive(trash, id)` | Archive an issue | | `issue_unarchive(id)` | Unarchive a previously archived issue | | `issue_delete(permanently, id)` | Delete an issue | +| `issue_vcs_branch_search(branch)` | Find issue by Git branch name | | `comment_create(input)` | Create a comment | +| `comment_update(input, id)` | Update a comment | +| `comment_resolve(input, id)` | Resolve a comment thread | +| `comment_unresolve(id)` | Unresolve a comment thread | | `comment_delete(id)` | Delete a comment | +| `issue_label_create(input)` | Create an issue label | +| `issue_label_update(input, id)` | Update an issue label | +| `issue_label_delete(id)` | Delete an issue label | +| `issue_relation_create(override_created_at, input)` | Create an issue relation | +| `issue_relation_delete(id)` | Delete an issue relation | | `document_create(input)` | Create a document | | `document_update(input, id)` | Update a document | | `document_delete(id)` | Delete a document | @@ -183,8 +193,6 @@ let payload = client.issue_create::(IssueCreateInput { | `team_delete(id)` | Delete a team | | `team_membership_create(input)` | Create a team membership | | `team_membership_delete(also_leave_parent_teams, id)` | Delete a team membership | -| `issue_relation_create(override_created_at, input)` | Create an issue relation | -| `issue_relation_delete(id)` | Delete an issue relation | | `file_upload(meta, public, size, type, name)` | Request a signed upload URL | | `image_upload_from_url(url)` | Upload image from URL | diff --git a/crates/lineark/README.md b/crates/lineark/README.md index 799fdd7..4d64e1e 100644 --- a/crates/lineark/README.md +++ b/crates/lineark/README.md @@ -55,26 +55,41 @@ lineark projects create --team KEY Create a project [--members NAME,...|me] Project members (comma-separated) [--start-date DATE] [--target-date DATE] Priority, content, icon, color [-p 0-4] [--content TEXT] ... See --help for all options -lineark labels list [--team KEY] List issue labels +lineark labels list [--team KEY] List labels (group, team, parent) +lineark labels create Create a label + [--team KEY] [--color HEX] [--group] Team, color, group flag + [--description TEXT] [--parent ID] Description, parent (must be group) +lineark labels update Update a label + [--name TEXT] [--color HEX] Name, color + [--clear-parent] [--group] [--no-group] Clear parent, promote/demote group +lineark labels delete Delete a label lineark cycles list [-l N] [--team KEY] List cycles [--active] [--around-active N] Active cycle / ± N neighbors lineark cycles read [--team KEY] Read cycle (UUID, name, number) lineark issues list [-l N] [--team KEY] Active issues, newest first [--mine] [--show-done] Filter by assignee / state lineark issues read Full issue detail incl. sub-issues & comments +lineark issues find-branch Find issue by Git branch name lineark issues search [-l N] Full-text search [--team KEY] [--assignee NAME-OR-ID|me] Filter by team, assignee, status [--status NAME,...] [--show-done] lineark issues create --team KEY Create an issue - [-p 0-4] [--assignee NAME-OR-ID|me] Priority, assignee, labels, status - [--labels NAME,...] [-s NAME] ... Project, cycle — see --help + [-p 0-4] [-e N] [--assignee NAME-OR-ID|me] Priority, estimate, assignee + [--labels NAME,...] [-s NAME] ... Labels, status — see --help lineark issues update <IDENTIFIER> Update an issue - [-s NAME] [-p 0-4] [--assignee NAME-OR-ID|me] Status, priority, assignee + [-s NAME] [-p 0-4] [-e N] Status, priority, estimate + [--assignee NAME-OR-ID|me] Assignee [--clear-parent] [--project NAME-OR-ID] ... See --help for all options +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 <IDENTIFIER> Archive an issue lineark issues unarchive <IDENTIFIER> Unarchive an issue lineark issues delete <IDENTIFIER> Delete (trash) an issue lineark comments create <ISSUE-ID> --body TEXT Comment on an issue +lineark comments update <COMMENT-UUID> Update a comment + --body TEXT New body in markdown +lineark comments resolve <COMMENT-UUID> Resolve a comment thread +lineark comments unresolve <COMMENT-UUID> Unresolve a comment thread lineark comments delete <ID> Delete a comment lineark relations create <ISSUE> Create an issue relation --blocks <ISSUE> Source blocks target From b2f9e22b8e69056630f8a530286e34e1fa78bd9e Mon Sep 17 00:00:00 2001 From: Cadu <cadu.coelho@gmail.com> Date: Sat, 7 Mar 2026 17:52:10 -0300 Subject: [PATCH 10/14] refactor: rename --group to --make-label-group/--clear-label-group Clearer intent: --make-label-group promotes a label to a group, --clear-label-group demotes it back. No ambiguous boolean parameter. --- crates/lineark/README.md | 6 ++-- crates/lineark/src/commands/labels.rs | 40 +++++++++++++++++---------- crates/lineark/src/commands/usage.rs | 6 ++-- crates/lineark/tests/online.rs | 5 ++-- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/crates/lineark/README.md b/crates/lineark/README.md index 4d64e1e..359932e 100644 --- a/crates/lineark/README.md +++ b/crates/lineark/README.md @@ -57,11 +57,13 @@ lineark projects create <NAME> --team KEY Create a project [-p 0-4] [--content TEXT] ... See --help for all options lineark labels list [--team KEY] List labels (group, team, parent) lineark labels create <NAME> Create a label - [--team KEY] [--color HEX] [--group] Team, color, group flag + [--team KEY] [--color HEX] Team, color [--description TEXT] [--parent ID] Description, parent (must be group) + [--make-label-group] Create as a group label lineark labels update <ID> Update a label [--name TEXT] [--color HEX] Name, color - [--clear-parent] [--group] [--no-group] Clear parent, promote/demote group + [--clear-parent] Remove parent label + [--make-label-group] [--clear-label-group] Promote/demote group lineark labels delete <ID> Delete a label lineark cycles list [-l N] [--team KEY] List cycles [--active] [--around-active N] Active cycle / ± N neighbors diff --git a/crates/lineark/src/commands/labels.rs b/crates/lineark/src/commands/labels.rs index aa60f18..4561e88 100644 --- a/crates/lineark/src/commands/labels.rs +++ b/crates/lineark/src/commands/labels.rs @@ -31,7 +31,7 @@ pub enum LabelsAction { /// Examples: /// lineark labels create "Bug" --color "#eb5757" /// lineark labels create "Feature" --team ENG --color "#4ea7fc" --description "Feature requests" - /// lineark labels create "Category" --group --color "#000000" + /// lineark labels create "Category" --make-label-group --color "#000000" /// lineark labels create "Sub-label" --parent PARENT-UUID --color "#ffffff" Create { /// Label name. @@ -50,15 +50,15 @@ pub enum LabelsAction { parent: Option<String>, /// Create as a group label (required before other labels can use it as --parent). #[arg(long, default_value = "false")] - group: bool, + make_label_group: bool, }, /// Update an existing issue label. /// /// Examples: /// lineark labels update LABEL-UUID --name "Renamed" /// lineark labels update LABEL-UUID --color "#00ff00" --description "Updated" - /// lineark labels update LABEL-UUID --group # promote to group - /// lineark labels update LABEL-UUID --no-group # demote (must have no children) + /// lineark labels update LABEL-UUID --make-label-group # promote to group + /// lineark labels update LABEL-UUID --clear-label-group # demote (must have no children) Update { /// Label UUID. id: String, @@ -77,10 +77,12 @@ pub enum LabelsAction { /// Remove the parent label relationship. #[arg(long, default_value = "false", conflicts_with = "parent")] clear_parent: bool, - /// Promote to group (--group) or demote to plain label (--no-group). - /// Demoting fails if the label still has children. - #[arg(long, action = clap::ArgAction::Set, default_missing_value = "true", num_args = 0..=1)] - group: Option<bool>, + /// Promote this label to a group (required before other labels can use it as --parent). + #[arg(long, default_value = "false", conflicts_with = "clear_label_group")] + make_label_group: bool, + /// Demote this group back to a plain label (fails if it still has children). + #[arg(long, default_value = "false")] + clear_label_group: bool, }, /// Delete an issue label. /// @@ -236,7 +238,7 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res color, description, parent, - group, + make_label_group, } => { let team_id = match team { Some(ref t) => Some(resolve_team_id(client, t).await?), @@ -249,7 +251,7 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res description, parent_id: parent, team_id, - is_group: if group { Some(true) } else { None }, + is_group: if make_label_group { Some(true) } else { None }, ..Default::default() }; @@ -267,26 +269,36 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res description, parent, clear_parent, - group, + make_label_group, + clear_label_group, } => { if name.is_none() && color.is_none() && description.is_none() && parent.is_none() && !clear_parent - && group.is_none() + && !make_label_group + && !clear_label_group { return Err(anyhow::anyhow!( - "No update fields provided. Use --name, --color, --description, --parent, --clear-parent, --group, or --no-group." + "No update fields provided. Use --name, --color, --description, --parent, --clear-parent, --make-label-group, or --clear-label-group." )); } + let is_group = if make_label_group { + Some(true) + } else if clear_label_group { + Some(false) + } else { + None + }; + let input = IssueLabelUpdateInput { name, color, description, parent_id: parent, - is_group: group, + is_group, ..Default::default() }; diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index 019685d..bac10c0 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -50,12 +50,14 @@ COMMANDS: [--icon ICON] [--color COLOR] Icon, color lineark labels list [--team KEY] List labels (group, team, parent, color) lineark labels create <NAME> Create a label (workspace-wide if no --team) - [--team KEY] [--color HEX] [--group] Team, color, group flag + [--team KEY] [--color HEX] Team, color [--description TEXT] [--parent ID] Description, parent (must be group) + [--make-label-group] Create as a group label lineark labels update <ID> Update a label [--name TEXT] [--color HEX] Name, color [--description TEXT] [--parent ID] Description, parent (must be group) - [--clear-parent] [--group] [--no-group] Clear parent, promote/demote group + [--clear-parent] Remove parent label + [--make-label-group] [--clear-label-group] Promote/demote group lineark labels delete <ID> Delete a label lineark cycles list [-l N] [--team KEY] List cycles [--active] Only the active cycle diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 6cd9500..982026c 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -387,7 +387,7 @@ mod online { &group_name, "--color", "#000000", - "--group", + "--make-label-group", ]) .output() .unwrap(); @@ -499,8 +499,7 @@ mod online { "labels", "update", &group_id, - "--group", - "false", + "--clear-label-group", ]) .output() .unwrap(); From 3dbc2c401ac7d3561b31495807c7ba25a12d3352 Mon Sep 17 00:00:00 2001 From: Cadu <cadu.coelho@gmail.com> Date: Sat, 7 Mar 2026 17:55:54 -0300 Subject: [PATCH 11/14] refactor: rename --parent/--clear-parent to --parent-label-group/--clear-parent-label-group All label group flags are now fully explicit and self-documenting: --parent-label-group, --clear-parent-label-group, --make-label-group, --clear-label-group --- crates/lineark/README.md | 6 +++-- crates/lineark/src/commands/labels.rs | 36 +++++++++++++-------------- crates/lineark/src/commands/usage.rs | 9 ++++--- crates/lineark/tests/online.rs | 10 ++++---- 4 files changed, 33 insertions(+), 28 deletions(-) diff --git a/crates/lineark/README.md b/crates/lineark/README.md index 359932e..8d7799a 100644 --- a/crates/lineark/README.md +++ b/crates/lineark/README.md @@ -58,11 +58,13 @@ lineark projects create <NAME> --team KEY Create a project lineark labels list [--team KEY] List labels (group, team, parent) lineark labels create <NAME> Create a label [--team KEY] [--color HEX] Team, color - [--description TEXT] [--parent ID] Description, parent (must be group) + [--description TEXT] Description + [--parent-label-group ID] Nest under a group label [--make-label-group] Create as a group label lineark labels update <ID> Update a label [--name TEXT] [--color HEX] Name, color - [--clear-parent] Remove parent label + [--parent-label-group ID] Nest under a group label + [--clear-parent-label-group] Remove parent group [--make-label-group] [--clear-label-group] Promote/demote group lineark labels delete <ID> Delete a label lineark cycles list [-l N] [--team KEY] List cycles diff --git a/crates/lineark/src/commands/labels.rs b/crates/lineark/src/commands/labels.rs index 4561e88..38fd129 100644 --- a/crates/lineark/src/commands/labels.rs +++ b/crates/lineark/src/commands/labels.rs @@ -32,7 +32,7 @@ pub enum LabelsAction { /// lineark labels create "Bug" --color "#eb5757" /// lineark labels create "Feature" --team ENG --color "#4ea7fc" --description "Feature requests" /// lineark labels create "Category" --make-label-group --color "#000000" - /// lineark labels create "Sub-label" --parent PARENT-UUID --color "#ffffff" + /// lineark labels create "Sub-label" --parent-label-group GROUP-UUID --color "#ffffff" Create { /// Label name. name: String, @@ -45,9 +45,9 @@ pub enum LabelsAction { /// Label description. #[arg(long)] description: Option<String>, - /// Parent label UUID (makes this a sub-label; parent must be a group). + /// Parent label group UUID (makes this a sub-label; parent must be a group). #[arg(long)] - parent: Option<String>, + parent_label_group: Option<String>, /// Create as a group label (required before other labels can use it as --parent). #[arg(long, default_value = "false")] make_label_group: bool, @@ -71,12 +71,12 @@ pub enum LabelsAction { /// New label description. #[arg(long)] description: Option<String>, - /// New parent label UUID (parent must be a group). + /// New parent label group UUID (parent must be a group). #[arg(long)] - parent: Option<String>, - /// Remove the parent label relationship. - #[arg(long, default_value = "false", conflicts_with = "parent")] - clear_parent: bool, + parent_label_group: Option<String>, + /// Remove the parent label group relationship. + #[arg(long, default_value = "false", conflicts_with = "parent_label_group")] + clear_parent_label_group: bool, /// Promote this label to a group (required before other labels can use it as --parent). #[arg(long, default_value = "false", conflicts_with = "clear_label_group")] make_label_group: bool, @@ -237,7 +237,7 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res team, color, description, - parent, + parent_label_group, make_label_group, } => { let team_id = match team { @@ -249,7 +249,7 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res name: Some(name), color, description, - parent_id: parent, + parent_id: parent_label_group, team_id, is_group: if make_label_group { Some(true) } else { None }, ..Default::default() @@ -267,21 +267,21 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res name, color, description, - parent, - clear_parent, + parent_label_group, + clear_parent_label_group, make_label_group, clear_label_group, } => { if name.is_none() && color.is_none() && description.is_none() - && parent.is_none() - && !clear_parent + && parent_label_group.is_none() + && !clear_parent_label_group && !make_label_group && !clear_label_group { return Err(anyhow::anyhow!( - "No update fields provided. Use --name, --color, --description, --parent, --clear-parent, --make-label-group, or --clear-label-group." + "No update fields provided. Use --name, --color, --description, --parent-label-group, --clear-parent-label-group, --make-label-group, or --clear-label-group." )); } @@ -297,14 +297,14 @@ pub async fn run(cmd: LabelsCmd, client: &Client, format: Format) -> anyhow::Res name, color, description, - parent_id: parent, + parent_id: parent_label_group, is_group, ..Default::default() }; - // When --clear-parent is used, send `parentId: null` to the API. + // When --clear-parent-label-group is used, send `parentId: null` to the API. // The generated input uses skip_serializing_if so None omits the field. - let label = if clear_parent { + let label = if clear_parent_label_group { let mut input_val = serde_json::to_value(&input)?; input_val .as_object_mut() diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index bac10c0..394774b 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -51,12 +51,15 @@ COMMANDS: lineark labels list [--team KEY] List labels (group, team, parent, color) lineark labels create <NAME> Create a label (workspace-wide if no --team) [--team KEY] [--color HEX] Team, color - [--description TEXT] [--parent ID] Description, parent (must be group) + [--description TEXT] Description + [--parent-label-group ID] Nest under a group label [--make-label-group] Create as a group label lineark labels update <ID> Update a label [--name TEXT] [--color HEX] Name, color - [--description TEXT] [--parent ID] Description, parent (must be group) - [--clear-parent] Remove parent label + [--description TEXT] Description + [--parent-label-group ID] Nest under a group label + [--parent-label-group ID] Nest under a group label + [--clear-parent-label-group] Remove parent group [--make-label-group] [--clear-label-group] Promote/demote group lineark labels delete <ID> Delete a label lineark cycles list [-l N] [--team KEY] List cycles diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 982026c..58fc2a3 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -417,7 +417,7 @@ mod online { &child_name, "--color", "#ffffff", - "--parent", + "--parent-label-group", &group_id, ]) .output() @@ -478,7 +478,7 @@ mod online { "labels", "update", &child_id, - "--clear-parent", + "--clear-parent-label-group", ]) .output() .unwrap(); @@ -1930,7 +1930,7 @@ mod online { &team_key, "-p", "4", - "--parent", + "--parent-label-group", &parent_id, ]) .output() @@ -2145,7 +2145,7 @@ mod online { &team_key, "-p", "4", - "--parent", + "--parent-label-group", &parent_id, ]) .output() @@ -2168,7 +2168,7 @@ mod online { "issues", "update", &child_id, - "--clear-parent", + "--clear-parent-label-group", ]) .output() .unwrap(); From 418cdb6dc58bc499e36ea2702c799842dcfc7b9f Mon Sep 17 00:00:00 2001 From: Cadu <cadu.coelho@gmail.com> Date: Sat, 7 Mar 2026 17:59:31 -0300 Subject: [PATCH 12/14] fix: remove duplicate --parent-label-group line in usage --- crates/lineark/src/commands/usage.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index 394774b..a6e0cad 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -58,7 +58,6 @@ COMMANDS: [--name TEXT] [--color HEX] Name, color [--description TEXT] Description [--parent-label-group ID] Nest under a group label - [--parent-label-group ID] Nest under a group label [--clear-parent-label-group] Remove parent group [--make-label-group] [--clear-label-group] Promote/demote group lineark labels delete <ID> Delete a label From 42ba6c21609c21272ead871a413213312f69837f Mon Sep 17 00:00:00 2001 From: Cadu <cadu.coelho@gmail.com> Date: Sat, 7 Mar 2026 18:07:47 -0300 Subject: [PATCH 13/14] refactor: flatten labels to string array in issue JSON output labels now serializes as ["Bug", "Feature"] instead of the GraphQL-leaking { nodes: [{ name: "Bug" }, ...] } structure. --- crates/lineark/src/commands/issues.rs | 15 ++++++++++++++- crates/lineark/tests/online.rs | 6 +++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/lineark/src/commands/issues.rs b/crates/lineark/src/commands/issues.rs index 10f6fb8..c1610ca 100644 --- a/crates/lineark/src/commands/issues.rs +++ b/crates/lineark/src/commands/issues.rs @@ -436,7 +436,9 @@ pub struct TeamRef { pub key: Option<String>, } -#[derive(Debug, Clone, Serialize, Deserialize, Default, GraphQLFields)] +/// Wrapper around IssueLabelConnection that serializes as a flat list of names: +/// `["Bug", "Feature"]` instead of `{ "nodes": [{ "name": "Bug" }, ...] }`. +#[derive(Debug, Clone, Deserialize, Default, GraphQLFields)] #[graphql(full_type = IssueLabelConnection)] #[serde(rename_all = "camelCase", default)] pub struct LabelConnection { @@ -444,6 +446,17 @@ pub struct LabelConnection { pub nodes: Vec<LabelNameRef>, } +impl Serialize for LabelConnection { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + let names: Vec<&str> = self + .nodes + .iter() + .filter_map(|l| l.name.as_deref()) + .collect(); + names.serialize(serializer) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, GraphQLFields)] #[graphql(full_type = IssueLabel)] #[serde(rename_all = "camelCase", default)] diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 58fc2a3..1612216 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -630,10 +630,10 @@ mod online { "issues read should succeed.\nstdout: {stdout}\nstderr: {stderr}" ); let detail: serde_json::Value = serde_json::from_str(&stdout).unwrap(); - let labels = detail["labels"]["nodes"] + let labels = detail["labels"] .as_array() - .expect("labels.nodes should be an array"); - let label_names: Vec<&str> = labels.iter().filter_map(|l| l["name"].as_str()).collect(); + .expect("labels should be a flat array of names"); + let label_names: Vec<&str> = labels.iter().filter_map(|l| l.as_str()).collect(); assert!( label_names.contains(&label_name.as_str()), "issue should have the spaced label '{label_name}', got: {label_names:?}" From 1cdbd8a4983c708549436fd3616a48a0db948d96 Mon Sep 17 00:00:00 2001 From: Cadu <cadu.coelho@gmail.com> Date: Sat, 7 Mar 2026 18:54:22 -0300 Subject: [PATCH 14/14] fix: unflake online tests with retry helpers and correct CLI flags - Fix issue tests using --parent-label-group instead of --parent (labels flag was incorrectly applied to issue parent operations during rename) - Add exponential backoff to retry_with_backoff (0, 1, 2, 4, 8, 10s cap) - Add settle() helper for post-creation eventual consistency delays - Add run_lineark_with_retry() for transient "conflict on insert" errors - Apply settle() after create_test_team() for API propagation - Apply run_lineark_with_retry to all project creation calls - Increase retry count for search-dependent unarchive test --- crates/lineark/tests/online.rs | 185 +++++++++++++++++++-------------- 1 file changed, 105 insertions(+), 80 deletions(-) diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 1612216..03420ea 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -51,7 +51,8 @@ fn delete_issue(issue_id: &str) { .block_on(async { client.issue_delete::<Issue>(Some(true), id).await.unwrap() }); } -/// Retry a closure up to `max_attempts` times with backoff. +/// Retry a closure up to `max_attempts` times with exponential backoff. +/// Delays: 0s, 1s, 2s, 4s, 8s, 10s, 10s, ... (capped at 10s). /// Returns `Ok(T)` on the first successful attempt, or `Err(last_error_message)`. fn retry_with_backoff<T, F>(max_attempts: u32, mut f: F) -> Result<T, String> where @@ -61,10 +62,8 @@ where for attempt in 0..max_attempts { let delay = if attempt == 0 { 0 - } else if attempt < 3 { - 1 } else { - 3 + std::cmp::min(1u64 << (attempt - 1), 10) }; if delay > 0 { std::thread::sleep(std::time::Duration::from_secs(delay)); @@ -77,6 +76,39 @@ where Err(last_err) } +/// Wait for the Linear API to propagate recently created resources. +/// Linear is eventually consistent — created resources may not be queryable immediately. +fn settle() { + std::thread::sleep(std::time::Duration::from_secs(2)); +} + +/// Run a lineark CLI command with retry logic. +/// Retries up to 3 times with backoff for transient API errors (e.g., "conflict on insert"). +fn run_lineark_with_retry(args: &[&str]) -> std::process::Output { + for attempt in 0..3u32 { + if attempt > 0 { + std::thread::sleep(std::time::Duration::from_secs(1u64 << attempt)); + } + let output = lineark() + .args(args) + .output() + .expect("failed to execute lineark"); + if output.status.success() { + return output; + } + let stderr = String::from_utf8_lossy(&output.stderr); + // Only retry on transient "conflict on insert" errors from the Linear API. + if !stderr.contains("conflict on insert") { + return output; + } + } + // Final attempt without retry. + lineark() + .args(args) + .output() + .expect("failed to execute lineark") +} + /// RAII guard — permanently deletes a team on drop. /// Ensures cleanup even when the test panics mid-way. struct TeamGuard { @@ -170,6 +202,8 @@ fn create_test_team() -> (String, String, TeamGuard) { token, id: team_id.clone(), }; + // Wait for the team to propagate through Linear's eventually-consistent backend. + settle(); (team_key, team_id, guard) } @@ -1102,13 +1136,16 @@ mod online { .unwrap(); assert!(output.status.success(), "archive should succeed"); + // Wait for the archive to propagate before searching. + settle(); + // Unarchive using the HUMAN identifier (e.g. CAD-1234), not the UUID. // This is the regression case: search_issues must include_archived(true) // for resolve_issue_id to find archived issues. // // Linear's search index is async — the newly created+archived issue may - // not be searchable immediately. Retry with backoff to avoid flakiness. - let stdout = retry_with_backoff(8, || { + // not be searchable immediately. Retry with generous backoff to avoid flakiness. + let stdout = retry_with_backoff(10, || { let output = lineark() .args([ "--api-token", @@ -1930,7 +1967,7 @@ mod online { &team_key, "-p", "4", - "--parent-label-group", + "--parent", &parent_id, ]) .output() @@ -2145,7 +2182,7 @@ mod online { &team_key, "-p", "4", - "--parent-label-group", + "--parent", &parent_id, ]) .output() @@ -2168,7 +2205,7 @@ mod online { "issues", "update", &child_id, - "--clear-parent-label-group", + "--clear-parent", ]) .output() .unwrap(); @@ -2398,25 +2435,22 @@ mod online { let (team_key, _team_id, _team_guard) = create_test_team(); - // Create a project via CLI. - let output = lineark() - .args([ - "--api-token", - &token, - "--format", - "json", - "projects", - "create", - &unique_name, - "--team", - &team_key, - "--description", - "Automated CLI test project — will be deleted.", - "--priority", - "3", - ]) - .output() - .expect("failed to execute lineark"); + // Create a project via CLI (with retry for transient "conflict on insert" errors). + let output = run_lineark_with_retry(&[ + "--api-token", + &token, + "--format", + "json", + "projects", + "create", + &unique_name, + "--team", + &team_key, + "--description", + "Automated CLI test project — will be deleted.", + "--priority", + "3", + ]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -2551,25 +2585,22 @@ mod online { let (team_key, _team_id, _team_guard) = create_test_team(); - // Create a project with --lead me. - let output = lineark() - .args([ - "--api-token", - &token, - "--format", - "json", - "projects", - "create", - &unique_name, - "--team", - &team_key, - "--lead", - "me", - "--description", - "Test project for read command.", - ]) - .output() - .expect("failed to execute lineark"); + // Create a project with --lead me (with retry for transient API errors). + let output = run_lineark_with_retry(&[ + "--api-token", + &token, + "--format", + "json", + "projects", + "create", + &unique_name, + "--team", + &team_key, + "--lead", + "me", + "--description", + "Test project for read command.", + ]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -2671,25 +2702,22 @@ mod online { let (team_key, _team_id, _team_guard) = create_test_team(); - // Create a project with --lead me --members me. - let output = lineark() - .args([ - "--api-token", - &token, - "--format", - "json", - "projects", - "create", - &unique_name, - "--team", - &team_key, - "--lead", - "me", - "--members", - "me", - ]) - .output() - .expect("failed to execute lineark"); + // Create a project with --lead me --members me (with retry for transient API errors). + let output = run_lineark_with_retry(&[ + "--api-token", + &token, + "--format", + "json", + "projects", + "create", + &unique_name, + "--team", + &team_key, + "--lead", + "me", + "--members", + "me", + ]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!( @@ -4067,20 +4095,17 @@ mod online { "[test] CLI project filter {}", &uuid::Uuid::new_v4().to_string()[..8] ); - let output = lineark() - .args([ - "--api-token", - &token, - "--format", - "json", - "projects", - "create", - &project_label, - "--team", - &team_key, - ]) - .output() - .expect("failed to execute lineark"); + let output = run_lineark_with_retry(&[ + "--api-token", + &token, + "--format", + "json", + "projects", + "create", + &project_label, + "--team", + &team_key, + ]); let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); assert!(