From 190f300775861ba4b549600f259f3c4a18ef2c4e Mon Sep 17 00:00:00 2001 From: Geoffrey Sechter Date: Sat, 28 Feb 2026 18:37:31 -0700 Subject: [PATCH 1/2] feat: `issues find-branch` command to find issue by Git branch name Add issueVcsBranchSearch query support: given a Git branch name, find the associated Linear issue. Returns full issue details on match, non-zero exit on no match. Key changes: - Add `execute_optional` to SDK Client for nullable query responses, refactor `execute` to delegate to it - Add `issueVcsBranchSearch` to operations.toml and regenerate SDK - Add `issues find-branch ` CLI subcommand - Add offline tests (help, subcommand listing, usage) - Add online tests for both SDK and CLI (found + not-found cases) --- crates/lineark-sdk/src/client.rs | 43 +++++++--- .../lineark-sdk/src/generated/client_impl.rs | 11 +++ crates/lineark-sdk/src/generated/queries.rs | 23 ++++++ crates/lineark-sdk/tests/online.rs | 78 ++++++++++++++++++ crates/lineark/src/commands/issues.rs | 26 ++++++ crates/lineark/src/commands/usage.rs | 1 + crates/lineark/tests/offline.rs | 29 +++++++ crates/lineark/tests/online.rs | 82 ++++++++++++++++++- schema/operations.toml | 1 + 9 files changed, 283 insertions(+), 11 deletions(-) diff --git a/crates/lineark-sdk/src/client.rs b/crates/lineark-sdk/src/client.rs index 626b01d..08657e7 100644 --- a/crates/lineark-sdk/src/client.rs +++ b/crates/lineark-sdk/src/client.rs @@ -56,13 +56,17 @@ impl Client { Self::from_token(auth::auto_token()?) } - /// Execute a GraphQL query and extract a single object from the response. - pub async fn execute( + /// Execute a GraphQL query and extract an optional object from the response. + /// + /// Returns `Ok(None)` when the API returns `null` for the data path + /// (common for nullable queries like `issueVcsBranchSearch`). + /// Returns `Ok(Some(T))` on success, `Err` on transport or GraphQL errors. + pub async fn execute_optional( &self, query: &str, variables: serde_json::Value, data_path: &str, - ) -> Result { + ) -> Result, LinearError> { let body = serde_json::json!({ "query": query, "variables": variables, @@ -134,18 +138,37 @@ impl Client { .data .ok_or_else(|| LinearError::MissingData("No data in response".to_string()))?; - let value = data - .get(data_path) - .ok_or_else(|| { - LinearError::MissingData(format!("No '{}' in response data", data_path)) - })? - .clone(); + let value = match data.get(data_path) { + Some(v) if v.is_null() => return Ok(None), + Some(v) => v.clone(), + None => { + return Err(LinearError::MissingData(format!( + "No '{}' in response data", + data_path + ))) + } + }; - serde_json::from_value(value).map_err(|e| { + serde_json::from_value(value).map(Some).map_err(|e| { LinearError::MissingData(format!("Failed to deserialize '{}': {}", data_path, e)) }) } + /// Execute a GraphQL query and extract a single object from the response. + /// + /// Delegates to [`execute_optional`](Self::execute_optional), returning an + /// error if the data path resolves to `null`. + pub async fn execute( + &self, + query: &str, + variables: serde_json::Value, + data_path: &str, + ) -> Result { + self.execute_optional(query, variables, data_path) + .await? + .ok_or_else(|| LinearError::MissingData(format!("'{}' returned null", data_path))) + } + /// Execute a GraphQL query and extract a Connection from the response. pub async fn execute_connection( &self, diff --git a/crates/lineark-sdk/src/generated/client_impl.rs b/crates/lineark-sdk/src/generated/client_impl.rs index 1a13719..611b80d 100644 --- a/crates/lineark-sdk/src/generated/client_impl.rs +++ b/crates/lineark-sdk/src/generated/client_impl.rs @@ -101,6 +101,17 @@ impl Client { ) -> Result { crate::generated::queries::issue::(self, id).await } + /// Find issue based on the VCS branch name. + /// + /// Full type: [`Issue`](super::types::Issue) + pub async fn issue_vcs_branch_search< + T: DeserializeOwned + GraphQLFields, + >( + &self, + branch_name: String, + ) -> Result { + crate::generated::queries::issue_vcs_branch_search::(self, branch_name).await + } /// All issue relationships. /// /// Full type: [`IssueRelation`](super::types::IssueRelation) diff --git a/crates/lineark-sdk/src/generated/queries.rs b/crates/lineark-sdk/src/generated/queries.rs index 542e21a..b45a00c 100644 --- a/crates/lineark-sdk/src/generated/queries.rs +++ b/crates/lineark-sdk/src/generated/queries.rs @@ -1208,6 +1208,29 @@ pub async fn issue(&query, variables, "issue").await } +/// Find issue based on the VCS branch name. +/// +/// Full type: [`Issue`](super::types::Issue) +pub async fn issue_vcs_branch_search< + T: DeserializeOwned + GraphQLFields, +>( + client: &Client, + branch_name: String, +) -> Result { + let variables = serde_json::json!({ "branchName" : branch_name }); + let selection = T::selection(); + let query = format!( + "query {}({}) {{ {}({}) {{ {} }} }}", + "IssueVcsBranchSearch", + "$branchName: String!", + "issueVcsBranchSearch", + "branchName: $branchName", + selection + ); + client + .execute::(&query, variables, "issueVcsBranchSearch") + .await +} /// All issue relationships. /// /// Full type: [`IssueRelation`](super::types::IssueRelation) diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index e5c4270..b7c042d 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -1061,6 +1061,84 @@ mod online { client.team_delete(team_id).await.unwrap(); } + // ── Issue VCS Branch Search ───────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn issue_vcs_branch_search_found() { + use lineark_sdk::generated::inputs::IssueCreateInput; + use lineark_sdk::GraphQLFields; + + let client = test_client(); + + // Create an issue so we can look up its branchName. + let teams = client.teams::().first(1).send().await.unwrap(); + let team_id = teams.nodes[0].id.clone().unwrap(); + + let input = IssueCreateInput { + title: Some("[test] SDK issue_vcs_branch_search_found".to_string()), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }; + let entity = client.issue_create::(input).await.unwrap(); + let issue_id = entity.id.clone().unwrap(); + let _issue_guard = IssueGuard { + token: test_token(), + id: issue_id.clone(), + }; + + // Read the issue to get its branchName field. + let branch_name = entity + .branch_name + .clone() + .expect("newly created issue should have a branchName"); + + // Build the query manually using Issue::selection() and call execute_optional. + let selection = Issue::selection(); + let query = format!( + "query IssueVcsBranchSearch($branchName: String!) {{ issueVcsBranchSearch(branchName: $branchName) {{ {} }} }}", + selection + ); + let variables = serde_json::json!({ "branchName": branch_name }); + let result: Option = client + .execute_optional(&query, variables, "issueVcsBranchSearch") + .await + .unwrap(); + + assert!(result.is_some(), "should find issue by branch name"); + let found = result.unwrap(); + assert_eq!(found.id, Some(issue_id.clone())); + + // Clean up. + client + .issue_delete::(Some(true), issue_id) + .await + .unwrap(); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + async fn issue_vcs_branch_search_not_found() { + use lineark_sdk::GraphQLFields; + + let client = test_client(); + + let selection = Issue::selection(); + let query = format!( + "query IssueVcsBranchSearch($branchName: String!) {{ issueVcsBranchSearch(branchName: $branchName) {{ {} }} }}", + selection + ); + let variables = serde_json::json!({ "branchName": "nonexistent-branch-xyz-999" }); + let result: Option = client + .execute_optional(&query, variables, "issueVcsBranchSearch") + .await + .unwrap(); + + assert!( + result.is_none(), + "should return None for nonexistent branch" + ); + } + // ── Error handling ────────────────────────────────────────────────────── #[test_with::runtime_ignore_if(no_online_test_token)] diff --git a/crates/lineark/src/commands/issues.rs b/crates/lineark/src/commands/issues.rs index 3c4874a..b8dce24 100644 --- a/crates/lineark/src/commands/issues.rs +++ b/crates/lineark/src/commands/issues.rs @@ -131,6 +131,11 @@ pub enum IssuesAction { #[arg(long, default_value = "false")] permanently: bool, }, + /// Find the issue associated with a Git branch name. + FindBranch { + /// Git branch name to search for. + branch_name: String, + }, /// Update an existing issue. Returns the updated issue. /// /// Examples: @@ -533,6 +538,27 @@ pub async fn run(cmd: IssuesCmd, client: &Client, format: Format) -> anyhow::Res let items = filter_done_search(&conn.nodes, show_done); print_search_list(&items, format); } + IssuesAction::FindBranch { branch_name } => { + let selection = ::selection(); + let query = format!( + "query IssueVcsBranchSearch($branchName: String!) {{ issueVcsBranchSearch(branchName: $branchName) {{ {} }} }}", + selection + ); + let variables = serde_json::json!({ "branchName": branch_name }); + let result: Option = client + .execute_optional(&query, variables, "issueVcsBranchSearch") + .await + .map_err(|e| anyhow::anyhow!("{}", e))?; + match result { + Some(issue) => output::print_one(&issue, format), + None => { + return Err(anyhow::anyhow!( + "No issue found for branch '{}'", + branch_name + )) + } + } + } IssuesAction::Create { title, team, diff --git a/crates/lineark/src/commands/usage.rs b/crates/lineark/src/commands/usage.rs index ccb11c8..3556aca 100644 --- a/crates/lineark/src/commands/usage.rs +++ b/crates/lineark/src/commands/usage.rs @@ -57,6 +57,7 @@ COMMANDS: [--mine] Only issues assigned to me [--show-done] Include done/canceled issues lineark issues read Full issue detail incl. sub-issues, comments, relations + 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] Comma-separated status names diff --git a/crates/lineark/tests/offline.rs b/crates/lineark/tests/offline.rs index 313fd32..ce312bc 100644 --- a/crates/lineark/tests/offline.rs +++ b/crates/lineark/tests/offline.rs @@ -810,6 +810,35 @@ fn usage_includes_teams_create() { .stdout(predicate::str::contains("teams members remove")); } +// ── Issues find-branch ─────────────────────────────────────────────────────── + +#[test] +fn issues_find_branch_help_shows_description() { + lineark() + .args(["issues", "find-branch", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Git branch name")); +} + +#[test] +fn issues_help_shows_find_branch_subcommand() { + lineark() + .args(["issues", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("find-branch")); +} + +#[test] +fn usage_includes_find_branch() { + lineark() + .arg("usage") + .assert() + .success() + .stdout(predicate::str::contains("issues find-branch")); +} + // ── Self command ───────────────────────────────────────────────────────────── #[test] diff --git a/crates/lineark/tests/online.rs b/crates/lineark/tests/online.rs index 9509f76..2a66f5c 100644 --- a/crates/lineark/tests/online.rs +++ b/crates/lineark/tests/online.rs @@ -5,7 +5,7 @@ use assert_cmd::Command; use lineark_sdk::generated::inputs::ProjectCreateInput; -use lineark_sdk::generated::types::{Issue, IssueRelation, Project}; +use lineark_sdk::generated::types::{Issue, IssueRelation, Project, Team}; use lineark_sdk::Client; use predicates::prelude::*; @@ -3398,4 +3398,84 @@ mod online { let result: serde_json::Value = serde_json::from_str(&stdout).unwrap(); assert_eq!(result["success"].as_bool(), Some(true)); } + + // ── Issues find-branch ────────────────────────────────────────────────── + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_find_branch_returns_issue() { + use lineark_sdk::generated::inputs::IssueCreateInput; + + let token = api_token(); + let client = Client::from_token(&token).unwrap(); + + // Create an issue via SDK to get the branch name. + let rt = tokio::runtime::Runtime::new().unwrap(); + let (issue_id, branch_name) = rt.block_on(async { + let teams = client.teams::().first(1).send().await.unwrap(); + let team_id = teams.nodes[0].id.clone().unwrap(); + + let input = IssueCreateInput { + title: Some("[test] CLI issues_find_branch_returns_issue".to_string()), + team_id: Some(team_id), + priority: Some(4), + ..Default::default() + }; + let entity = client.issue_create::(input).await.unwrap(); + let issue_id = entity.id.clone().unwrap(); + let branch_name = entity + .branch_name + .clone() + .expect("created issue should have a branchName"); + (issue_id, branch_name) + }); + + let _issue_guard = IssueGuard { + token: token.clone(), + id: issue_id.clone(), + }; + + // Run the CLI find-branch command. + let output = lineark() + .args([ + "--api-token", + &token, + "--format", + "json", + "issues", + "find-branch", + &branch_name, + ]) + .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(), + "issues find-branch should succeed.\nstdout: {stdout}\nstderr: {stderr}" + ); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!( + json.get("identifier").is_some(), + "find-branch JSON should contain identifier" + ); + + // Clean up. + delete_issue(&issue_id); + } + + #[test_with::runtime_ignore_if(no_online_test_token)] + fn issues_find_branch_no_match_exits_nonzero() { + let token = api_token(); + lineark() + .args([ + "--api-token", + &token, + "issues", + "find-branch", + "nonexistent-branch-abc-xyz-987654321", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("No issue found")); + } } diff --git a/schema/operations.toml b/schema/operations.toml index bc16bda..7b9ed35 100644 --- a/schema/operations.toml +++ b/schema/operations.toml @@ -12,6 +12,7 @@ cycles = true cycle = true issueLabels = true searchIssues = true +issueVcsBranchSearch = true workflowStates = true # Phase 3 — Rich features From c4a7497157bca67be9ee84ee6e8075992c17d153 Mon Sep 17 00:00:00 2001 From: Cadu Date: Fri, 6 Mar 2026 21:40:26 +0100 Subject: [PATCH 2/2] fix: use generated issue_vcs_branch_search, clean up tests Replace hand-rolled query with the generated function now that #120 added nullable query support. Also: UUID suffix on SDK test names, remove redundant manual issue_delete (RAII guard handles it), reorder FindBranch enum variant next to other read operations. --- crates/lineark-sdk/tests/online.rs | 35 ++++++--------------------- crates/lineark/src/commands/issues.rs | 18 +++++--------- 2 files changed, 13 insertions(+), 40 deletions(-) diff --git a/crates/lineark-sdk/tests/online.rs b/crates/lineark-sdk/tests/online.rs index 206cc2c..30ced71 100644 --- a/crates/lineark-sdk/tests/online.rs +++ b/crates/lineark-sdk/tests/online.rs @@ -1066,7 +1066,6 @@ mod online { #[test_with::runtime_ignore_if(no_online_test_token)] async fn issue_vcs_branch_search_found() { use lineark_sdk::generated::inputs::IssueCreateInput; - use lineark_sdk::GraphQLFields; let client = test_client(); @@ -1074,8 +1073,9 @@ mod online { let teams = client.teams::().first(1).send().await.unwrap(); let team_id = teams.nodes[0].id.clone().unwrap(); + let uid = &uuid::Uuid::new_v4().to_string()[..8]; let input = IssueCreateInput { - title: Some("[test] SDK issue_vcs_branch_search_found".to_string()), + title: Some(format!("[test] SDK branch search {uid}")), team_id: Some(team_id), priority: Some(4), ..Default::default() @@ -1093,43 +1093,22 @@ mod online { .clone() .expect("newly created issue should have a branchName"); - // Build the query manually using Issue::selection() and call execute. - let selection = Issue::selection(); - let query = format!( - "query IssueVcsBranchSearch($branchName: String!) {{ issueVcsBranchSearch(branchName: $branchName) {{ {} }} }}", - selection - ); - let variables = serde_json::json!({ "branchName": branch_name }); - let result: Option = client - .execute(&query, variables, "issueVcsBranchSearch") + let result = client + .issue_vcs_branch_search::(branch_name) .await .unwrap(); assert!(result.is_some(), "should find issue by branch name"); let found = result.unwrap(); - assert_eq!(found.id, Some(issue_id.clone())); - - // Clean up. - client - .issue_delete::(Some(true), issue_id) - .await - .unwrap(); + assert_eq!(found.id, Some(issue_id)); } #[test_with::runtime_ignore_if(no_online_test_token)] async fn issue_vcs_branch_search_not_found() { - use lineark_sdk::GraphQLFields; - let client = test_client(); - let selection = Issue::selection(); - let query = format!( - "query IssueVcsBranchSearch($branchName: String!) {{ issueVcsBranchSearch(branchName: $branchName) {{ {} }} }}", - selection - ); - let variables = serde_json::json!({ "branchName": "nonexistent-branch-xyz-999" }); - let result: Option = client - .execute(&query, variables, "issueVcsBranchSearch") + let result = client + .issue_vcs_branch_search::("nonexistent-branch-xyz-999".to_string()) .await .unwrap(); diff --git a/crates/lineark/src/commands/issues.rs b/crates/lineark/src/commands/issues.rs index 10dc756..fa488f0 100644 --- a/crates/lineark/src/commands/issues.rs +++ b/crates/lineark/src/commands/issues.rs @@ -125,6 +125,11 @@ pub enum IssuesAction { /// Issue identifier (e.g., ENG-123) or UUID. identifier: String, }, + /// Find the issue associated with a Git branch name. + FindBranch { + /// Git branch name to search for. + branch_name: String, + }, /// Delete (trash) an issue. Use --permanently to delete permanently. /// /// Examples: @@ -137,11 +142,6 @@ pub enum IssuesAction { #[arg(long, default_value = "false")] permanently: bool, }, - /// Find the issue associated with a Git branch name. - FindBranch { - /// Git branch name to search for. - branch_name: String, - }, /// Update an existing issue. Returns the updated issue. /// /// Examples: @@ -563,14 +563,8 @@ pub async fn run(cmd: IssuesCmd, client: &Client, format: Format) -> anyhow::Res print_search_list(&items, format); } IssuesAction::FindBranch { branch_name } => { - let selection = ::selection(); - let query = format!( - "query IssueVcsBranchSearch($branchName: String!) {{ issueVcsBranchSearch(branchName: $branchName) {{ {} }} }}", - selection - ); - let variables = serde_json::json!({ "branchName": branch_name }); let result: Option = client - .execute(&query, variables, "issueVcsBranchSearch") + .issue_vcs_branch_search(branch_name.clone()) .await .map_err(|e| anyhow::anyhow!("{}", e))?; match result {