From d2561421d98693cbc6dadecf7b7d7901ad3ab0ed Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Wed, 5 Aug 2026 18:03:57 +0000 Subject: [PATCH 1/9] feat(git): add `git review-url` to resolve an issue's Linear review URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear's review page for a pull request (linear.app//review/) has no public lookup from a GitHub PR URL, and the slug appears nowhere on the issue or its attachments — `issue.attachments` and `attachmentsForURL` return the GitHub URL and GitHub metadata only. The slug lives on `PullRequest.slugId`, and the one path that reaches a `PullRequest` with a personal API key is the agent sessions attached to an issue (`Query.diff` is [Internal] and takes a `Diff` id nothing hands out). So `git review-url ` walks `issue.agentSessions.pullRequests`, pairs each `slugId` with `organization.urlKey`, and prints the review URL — one per line, or `-o json` for the PR number, state, title and GitHub URL alongside it. A pull request linked by more than one session is listed once. The limitation is inherent to the API rather than to this command, so it is stated in `--help`, in the README, and in the error raised when an issue resolves to no slug, which points at the GitHub PR URL instead of failing silently. --- README.md | 6 ++ src/commands/git.rs | 170 +++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 2 +- 3 files changed, 175 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 22aed2b..787c478 100644 --- a/README.md +++ b/README.md @@ -360,8 +360,14 @@ linear-cli g branch LIN-123 # Show branch name linear-cli g create LIN-123 # Create branch (no checkout) linear-cli g commits # Commits with Linear trailers (jj) linear-cli g pr LIN-123 --draft # Create GitHub PR +linear-cli g review-url LIN-123 # Linear review URL for the issue's PR ``` +`review-url` resolves `https://linear.app//review/` from the +pull requests Linear has linked to the issue's agent sessions — the only place the +API exposes a PR's review slug. A pull request opened outside that flow has no +slug to resolve, so the command reports that and you use the GitHub PR URL. + ### Import / Export Round-trip CSV and JSON import/export with field resolution for status, assignee, and labels. diff --git a/src/commands/git.rs b/src/commands/git.rs index 9a23d81..948f5ed 100644 --- a/src/commands/git.rs +++ b/src/commands/git.rs @@ -1,12 +1,13 @@ use anyhow::Result; use clap::{Subcommand, ValueEnum}; use colored::Colorize; -use serde_json::json; +use serde_json::{json, Value}; use std::path::Path; use std::process::Command; use crate::api::LinearClient; use crate::display_options; +use crate::output::{print_json, OutputOptions}; use crate::text::truncate; use crate::vcs::{generate_branch_name, git_branch_exists, run_git_command, validate_branch_name}; @@ -82,6 +83,17 @@ pub enum GitCommands { #[arg(long, value_enum)] vcs: Option, }, + /// Show the Linear review URL for an issue's pull request(s) + #[command(after_help = r#"EXAMPLES: + linear git review-url LIN-123 # Print the review URL(s) + linear g review-url LIN-123 -o json # Include PR number, state, GitHub URL + +NOTE: Linear only exposes a pull request's review slug for PRs it has linked to +an agent session, so a PR opened outside that flow has no review URL to resolve."#)] + ReviewUrl { + /// Issue identifier (e.g., "LIN-123") or ID + issue: String, + }, /// Create a GitHub PR from a Linear issue #[command(after_help = r#"EXAMPLES: linear git pr LIN-123 # Create PR for issue @@ -140,8 +152,9 @@ fn get_vcs(vcs_flag: Option) -> Result { } } -pub async fn handle(cmd: GitCommands) -> Result<()> { +pub async fn handle(cmd: GitCommands, output: &OutputOptions) -> Result<()> { match cmd { + GitCommands::ReviewUrl { issue } => show_review_url(&issue, output).await, GitCommands::Checkout { issue, branch, vcs } => { let vcs = get_vcs(vcs)?; checkout_issue(&issue, branch, vcs).await @@ -167,6 +180,94 @@ pub async fn handle(cmd: GitCommands) -> Result<()> { } } +/// Build the review entries for an issue from a `review-url` query response. +/// +/// `PullRequest.slugId` is the only public field carrying the slug in a review +/// URL, and it is reachable only through the agent sessions attached to an issue, +/// so an issue can legitimately resolve to zero entries. One pull request can be +/// linked by more than one session, hence the de-duplication by slug. +fn review_entries(url_key: &str, issue: &Value) -> Vec { + let mut seen: Vec = Vec::new(); + let mut entries = Vec::new(); + + let sessions = issue["agentSessions"]["nodes"].as_array(); + for session in sessions.into_iter().flatten() { + let links = session["pullRequests"]["nodes"].as_array(); + for link in links.into_iter().flatten() { + let pr = &link["pullRequest"]; + let Some(slug) = pr["slugId"].as_str().filter(|s| !s.is_empty()) else { + continue; + }; + if seen.iter().any(|s| s == slug) { + continue; + } + seen.push(slug.to_string()); + entries.push(json!({ + "reviewUrl": format!("https://linear.app/{}/review/{}", url_key, slug), + "number": pr["number"], + "status": pr["status"], + "url": pr["url"], + "title": pr["title"], + })); + } + } + + entries +} + +async fn show_review_url(issue_id: &str, output: &OutputOptions) -> Result<()> { + let client = LinearClient::new()?; + + let query = r#" + query($id: String!) { + organization { urlKey } + issue(id: $id) { + identifier + agentSessions { + nodes { + pullRequests { + nodes { + pullRequest { slugId url number status title } + } + } + } + } + } + } + "#; + + let result = client.query(query, Some(json!({ "id": issue_id }))).await?; + let issue = &result["data"]["issue"]; + + if issue.is_null() { + anyhow::bail!("Issue not found: {}", issue_id); + } + + let url_key = result["data"]["organization"]["urlKey"] + .as_str() + .unwrap_or_default(); + let entries = review_entries(url_key, issue); + + if entries.is_empty() { + anyhow::bail!( + "No review URL for {}: Linear exposes a pull request's review slug only \ + for PRs linked to an agent session, and this issue has none. Use the \ + GitHub PR URL instead.", + issue["identifier"].as_str().unwrap_or(issue_id) + ); + } + + if output.is_json() || output.has_template() { + return print_json(&json!(entries), output); + } + + for entry in &entries { + println!("{}", entry["reviewUrl"].as_str().unwrap_or_default()); + } + + Ok(()) +} + async fn get_issue_info(issue_id: &str) -> Result<(String, String, String, String)> { let client = LinearClient::new()?; @@ -602,6 +703,71 @@ async fn create_pr(issue_id: &str, base: &str, draft: bool, web: bool) -> Result mod tests { use super::*; + fn issue_with_sessions(sessions: Value) -> Value { + json!({ "identifier": "LIN-123", "agentSessions": { "nodes": sessions } }) + } + + fn pr_link(slug: &str, number: u64) -> Value { + json!({ "pullRequest": { + "slugId": slug, + "number": number, + "status": "open", + "url": format!("https://github.com/acme/app/pull/{}", number), + "title": "Fix the thing" + }}) + } + + #[test] + fn test_review_entries_builds_review_url_from_slug() { + let issue = issue_with_sessions(json!([ + { "pullRequests": { "nodes": [pr_link("7ffd27854fd2", 183)] } } + ])); + + let entries = review_entries("acme", &issue); + + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0]["reviewUrl"], + "https://linear.app/acme/review/7ffd27854fd2" + ); + assert_eq!(entries[0]["number"], 183); + assert_eq!(entries[0]["url"], "https://github.com/acme/app/pull/183"); + } + + #[test] + fn test_review_entries_dedupes_a_pr_linked_by_several_sessions() { + let issue = issue_with_sessions(json!([ + { "pullRequests": { "nodes": [pr_link("aaa111", 7)] } }, + { "pullRequests": { "nodes": [pr_link("aaa111", 7), pr_link("bbb222", 8)] } } + ])); + + let entries = review_entries("acme", &issue); + + assert_eq!(entries.len(), 2, "the repeated pull request is listed once"); + assert_eq!( + entries[0]["reviewUrl"], + "https://linear.app/acme/review/aaa111" + ); + assert_eq!( + entries[1]["reviewUrl"], + "https://linear.app/acme/review/bbb222" + ); + } + + #[test] + fn test_review_entries_empty_without_sessions_or_slug() { + assert!(review_entries("acme", &issue_with_sessions(json!([]))).is_empty()); + + // A session with no linked pull request, and a link whose slug is missing or + // blank: all unresolvable, and none of them may produce a bogus URL. + let unresolvable = issue_with_sessions(json!([ + { "pullRequests": { "nodes": [] } }, + { "pullRequests": { "nodes": [{ "pullRequest": { "number": 1 } }] } }, + { "pullRequests": { "nodes": [{ "pullRequest": { "slugId": "", "number": 2 } }] } } + ])); + assert!(review_entries("acme", &unresolvable).is_empty()); + } + #[test] fn test_generate_branch_name_simple() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 76ceab1..916026f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1160,7 +1160,7 @@ async fn run_command( Commands::Search { action } => search::handle(action, output).await?, Commands::Sync { action } => sync::handle(action, output).await?, Commands::Statuses { action } => statuses::handle(action, output).await?, - Commands::Git { action } => git::handle(action).await?, + Commands::Git { action } => git::handle(action, output).await?, Commands::Bulk { action } => bulk::handle(action, output).await?, Commands::Cache { action } => commands::cache::handle(action).await?, Commands::Notifications { action } => notifications::handle(action, output).await?, From 067382e8bbf4aa030a7f8f8b348bc143f3c2203d Mon Sep 17 00:00:00 2001 From: oliviasculley Date: Wed, 5 Aug 2026 19:07:56 +0000 Subject: [PATCH 2/9] fix(git): resolve review-url from pull request notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass claimed a review URL exists only for pull requests linked to an agent session. That is wrong: Linear creates a review page for any pull request it detects from a branch, and `PullRequestNotification` exposes it — `url` on the notification is the review page itself (`review/-`), alongside the `pullRequest` it belongs to. So `review-url` now matches the issue's `github` pull request attachments against the notification feed and returns that URL verbatim, which also preserves the human-readable title slug instead of dropping it by assembling `review/` by hand. A comment notification's `#comment-` anchor is trimmed so the result is the page, not a position in it. The agent-session path stays as the fallback for a pull request with no notifications, and results are merged per pull request so a PR reachable both ways is listed once. The feed has no server-side pull request filter, so it is walked newest-first for at most 5 pages; a pull request whose activity is older than that falls through to the fallback. What remains genuinely unresolvable is a pull request with no notification at all — typically one opened minutes ago with no CI result, comment, or review yet — and the error says so rather than emitting a URL that would 404. --- README.md | 10 +- src/commands/git.rs | 247 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 241 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 787c478..3dad941 100644 --- a/README.md +++ b/README.md @@ -363,10 +363,12 @@ linear-cli g pr LIN-123 --draft # Create GitHub PR linear-cli g review-url LIN-123 # Linear review URL for the issue's PR ``` -`review-url` resolves `https://linear.app//review/` from the -pull requests Linear has linked to the issue's agent sessions — the only place the -API exposes a PR's review slug. A pull request opened outside that flow has no -slug to resolve, so the command reports that and you use the GitHub PR URL. +`review-url` reads the review URL from the issue's pull request notifications — +the one public place a pull request is paired with its review page — and falls +back to the pull requests linked to the issue's agent sessions. A pull request +that has produced neither (a brand-new PR with no CI result, comment, or review +activity yet) has nothing to resolve, and the command says so instead of guessing +a URL. ### Import / Export diff --git a/src/commands/git.rs b/src/commands/git.rs index 948f5ed..8df9f7f 100644 --- a/src/commands/git.rs +++ b/src/commands/git.rs @@ -88,8 +88,9 @@ pub enum GitCommands { linear git review-url LIN-123 # Print the review URL(s) linear g review-url LIN-123 -o json # Include PR number, state, GitHub URL -NOTE: Linear only exposes a pull request's review slug for PRs it has linked to -an agent session, so a PR opened outside that flow has no review URL to resolve."#)] +NOTE: The review URL is read from the issue's pull request notifications, falling +back to the pull requests linked to its agent sessions. A pull request that has +produced neither has no review URL to resolve."#)] ReviewUrl { /// Issue identifier (e.g., "LIN-123") or ID issue: String, @@ -180,12 +181,84 @@ pub async fn handle(cmd: GitCommands, output: &OutputOptions) -> Result<()> { } } -/// Build the review entries for an issue from a `review-url` query response. +/// GitHub pull request URLs attached to an issue. /// -/// `PullRequest.slugId` is the only public field carrying the slug in a review -/// URL, and it is reachable only through the agent sessions attached to an issue, -/// so an issue can legitimately resolve to zero entries. One pull request can be -/// linked by more than one session, hence the de-duplication by slug. +/// Linear links a pull request to an issue as a `github` attachment as soon as it +/// detects the branch, so this covers every pull request of the issue — but the +/// attachment carries no review slug, which is why the slug is looked up +/// separately. +fn attached_pr_urls(issue: &Value) -> Vec { + let mut urls = Vec::new(); + for node in issue["attachments"]["nodes"] + .as_array() + .into_iter() + .flatten() + { + if node["sourceType"].as_str() != Some("github") { + continue; + } + if let Some(url) = node["url"].as_str().filter(|u| u.contains("/pull/")) { + if !urls.iter().any(|existing| existing == url) { + urls.push(url.to_string()); + } + } + } + urls +} + +/// Pick out the review URLs for `pr_urls` from a page of notifications. +/// +/// A `PullRequestNotification` is the one public place that pairs a pull request +/// with its Linear review URL, and it carries that URL whole (`review/-<id>`) rather than requiring it to be assembled. +fn review_entries_from_notifications(nodes: &[Value], pr_urls: &[String]) -> Vec<Value> { + let mut entries = Vec::new(); + for node in nodes { + let pr = &node["pullRequest"]; + let Some(pr_url) = pr["url"].as_str() else { + continue; + }; + if !pr_urls.iter().any(|wanted| wanted == pr_url) { + continue; + } + let Some(review_url) = node["url"].as_str().filter(|u| !u.is_empty()) else { + continue; + }; + // A comment notification points at an anchor within the review page + // (`…#comment-<id>`); the page itself is what a caller wants. + let review_url = review_url.split('#').next().unwrap_or(review_url); + entries.push(json!({ + "reviewUrl": review_url, + "number": pr["number"], + "status": pr["status"], + "url": pr_url, + "title": pr["title"], + })); + } + entries +} + +/// Merge review entries, keeping the first entry seen per pull request URL. +fn merge_review_entries(entries: Vec<Value>) -> Vec<Value> { + let mut seen: Vec<String> = Vec::new(); + let mut merged = Vec::new(); + for entry in entries { + let key = entry["url"].as_str().unwrap_or_default().to_string(); + if !key.is_empty() && seen.contains(&key) { + continue; + } + seen.push(key); + merged.push(entry); + } + merged +} + +/// Build review entries from the agent sessions attached to an issue. +/// +/// This is the fallback for a pull request with no notification: an agent session +/// exposes `PullRequest.slugId` directly, from which the review URL can be +/// assembled. One pull request can be linked by several sessions, hence the +/// de-duplication by slug. fn review_entries(url_key: &str, issue: &Value) -> Vec<Value> { let mut seen: Vec<String> = Vec::new(); let mut entries = Vec::new(); @@ -215,6 +288,55 @@ fn review_entries(url_key: &str, issue: &Value) -> Vec<Value> { entries } +/// How many pages of notifications `review-url` will read before giving up. +/// +/// The notification feed has no server-side filter for pull requests, so it is +/// walked newest-first; a pull request whose last notification is older than this +/// falls through to the agent-session path. +const REVIEW_NOTIFICATION_PAGES: usize = 5; + +/// Look up review URLs for `pr_urls` by walking the notification feed. +async fn review_urls_via_notifications( + client: &LinearClient, + pr_urls: &[String], +) -> Result<Vec<Value>> { + let query = r#" + query($after: String) { + notifications(first: 100, after: $after, includeArchived: true) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on PullRequestNotification { + url + pullRequest { url number status title } + } + } + } + } + "#; + + let mut entries: Vec<Value> = Vec::new(); + let mut cursor: Option<String> = None; + + for _ in 0..REVIEW_NOTIFICATION_PAGES { + let result = client + .query(query, Some(json!({ "after": cursor }))) + .await?; + let page = &result["data"]["notifications"]; + let nodes = page["nodes"].as_array().cloned().unwrap_or_default(); + + entries.extend(review_entries_from_notifications(&nodes, pr_urls)); + entries = merge_review_entries(entries); + + if entries.len() == pr_urls.len() || page["pageInfo"]["hasNextPage"] != json!(true) { + break; + } + cursor = page["pageInfo"]["endCursor"].as_str().map(str::to_string); + } + + Ok(entries) +} + async fn show_review_url(issue_id: &str, output: &OutputOptions) -> Result<()> { let client = LinearClient::new()?; @@ -223,6 +345,7 @@ async fn show_review_url(issue_id: &str, output: &OutputOptions) -> Result<()> { organization { urlKey } issue(id: $id) { identifier + attachments { nodes { url sourceType } } agentSessions { nodes { pullRequests { @@ -246,14 +369,32 @@ async fn show_review_url(issue_id: &str, output: &OutputOptions) -> Result<()> { let url_key = result["data"]["organization"]["urlKey"] .as_str() .unwrap_or_default(); - let entries = review_entries(url_key, issue); + let pr_urls = attached_pr_urls(issue); + + // A notification carries the review URL whole; agent sessions only expose the + // slug to assemble one, so they are the fallback. + let mut entries = if pr_urls.is_empty() { + Vec::new() + } else { + review_urls_via_notifications(&client, &pr_urls).await? + }; + entries.extend(review_entries(url_key, issue)); + let entries = merge_review_entries(entries); if entries.is_empty() { anyhow::bail!( - "No review URL for {}: Linear exposes a pull request's review slug only \ - for PRs linked to an agent session, and this issue has none. Use the \ - GitHub PR URL instead.", - issue["identifier"].as_str().unwrap_or(issue_id) + "No review URL for {}: {}. Use the GitHub PR URL instead.", + issue["identifier"].as_str().unwrap_or(issue_id), + if pr_urls.is_empty() { + "no pull request is linked to this issue".to_string() + } else { + format!( + "Linear exposes a review URL through pull request notifications, and none \ + of the {} linked pull request(s) has one in the last {} pages of the feed", + pr_urls.len(), + REVIEW_NOTIFICATION_PAGES + ) + } ); } @@ -717,6 +858,88 @@ mod tests { }}) } + #[test] + fn test_attached_pr_urls_keeps_github_pull_requests_only() { + let issue = json!({ "attachments": { "nodes": [ + { "sourceType": "github", "url": "https://github.com/acme/app/pull/183" }, + { "sourceType": "github", "url": "https://github.com/acme/app/pull/183" }, + { "sourceType": "github", "url": "https://github.com/acme/app/issues/12" }, + { "sourceType": "sentry", "url": "https://sentry.io/acme/app/pull/1" }, + { "sourceType": "github", "url": "https://github.com/acme/app/pull/184" } + ]}}); + + assert_eq!( + attached_pr_urls(&issue), + vec![ + "https://github.com/acme/app/pull/183", + "https://github.com/acme/app/pull/184" + ] + ); + } + + #[test] + fn test_review_entries_from_notifications_uses_the_notification_url() { + let nodes = vec![ + json!({ + "__typename": "IssueNotification", + "url": "https://linear.app/acme/issue/LIN-1" + }), + json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "pullRequest": { + "url": "https://github.com/acme/app/pull/183", + "number": 183, "status": "open", "title": "Fix the thing" + } + }), + json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/someone-elses-pr-aaaaaaaaaaaa", + "pullRequest": { "url": "https://github.com/acme/app/pull/999", "number": 999 } + }), + ]; + let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; + + let entries = review_entries_from_notifications(&nodes, &wanted); + + assert_eq!(entries.len(), 1, "only the requested pull request matches"); + assert_eq!( + entries[0]["reviewUrl"], "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "the notification's URL is used verbatim, not reassembled from the slug" + ); + assert_eq!(entries[0]["number"], 183); + } + + #[test] + fn test_review_entries_from_notifications_drops_a_comment_anchor() { + let nodes = vec![json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a#comment-5f63aa7c", + "pullRequest": { "url": "https://github.com/acme/app/pull/183", "number": 183 } + })]; + let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; + + let entries = review_entries_from_notifications(&nodes, &wanted); + + assert_eq!( + entries[0]["reviewUrl"], "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "a comment notification must still yield the review page URL" + ); + } + + #[test] + fn test_merge_review_entries_prefers_the_first_entry_per_pull_request() { + let merged = merge_review_entries(vec![ + json!({ "url": "https://github.com/acme/app/pull/1", "reviewUrl": "from-notification" }), + json!({ "url": "https://github.com/acme/app/pull/1", "reviewUrl": "from-agent-session" }), + json!({ "url": "https://github.com/acme/app/pull/2", "reviewUrl": "other" }), + ]); + + assert_eq!(merged.len(), 2); + assert_eq!(merged[0]["reviewUrl"], "from-notification"); + assert_eq!(merged[1]["reviewUrl"], "other"); + } + #[test] fn test_review_entries_builds_review_url_from_slug() { let issue = issue_with_sessions(json!([ From 52467c12af6cae15233d544b4cd549bfd399693d Mon Sep 17 00:00:00 2001 From: oliviasculley <olivia@sculley.dev> Date: Tue, 25 Aug 2026 02:34:37 +0000 Subject: [PATCH 3/9] refactor(git): model review-url resolution with typed entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review on #45. Move `review-url` out of `git.rs` into `src/commands/git/review_url.rs`, so `git.rs` holds the command variants and local VCS work again rather than GraphQL queries, feed pagination, and API decoding. `git.rs` returns to roughly its pre-feature size. Replace the raw-`Value` merge pipeline with typed deserialization and a serializable `ReviewEntry`. Sources merge into one map keyed by GitHub pull request URL, with the agent-session fallback inserted first so a notification result replaces it — precedence is now the merge's structure rather than a convention about ordering. Missing fields are `Option`s instead of silent nulls, and a pull request Linear returns without a URL yields no entry rather than one with a null identity. Resolution is modelled as resolved plus unresolved pull requests. Previously an issue with two attached pull requests where only one resolved printed that one URL and exited 0, saying nothing about the other. Unresolved pull requests are now part of the output contract: `-o json` returns `{"resolved": [...], "unresolved": [...]}` and the plain-text form names them on stderr. The command still fails only when it resolved nothing. Drop the private cursor state machine in favour of `paginate_until`, a short-circuiting paginator alongside `paginate_nodes` in `pagination.rs`. It follows the canonical cursor rules — including stopping when a connection claims `hasNextPage` without returning an `endCursor`, which the private loop would have answered by rereading the first page until its five-page cap. It stops as soon as every wanted pull request is found, so the common resolved-on-the-first-page case still costs one request rather than five. Also drop a trailing blank line in `initiatives.rs` that failed `cargo fmt --check` and so kept Clippy from running. --- README.md | 9 +- src/commands/git.rs | 388 +--------------- src/commands/git/review_url.rs | 788 +++++++++++++++++++++++++++++++++ src/commands/initiatives.rs | 1 - src/pagination.rs | 122 +++++ 5 files changed, 926 insertions(+), 382 deletions(-) create mode 100644 src/commands/git/review_url.rs diff --git a/README.md b/README.md index 3dad941..030aad3 100644 --- a/README.md +++ b/README.md @@ -367,8 +367,13 @@ linear-cli g review-url LIN-123 # Linear review URL for the iss the one public place a pull request is paired with its review page — and falls back to the pull requests linked to the issue's agent sessions. A pull request that has produced neither (a brand-new PR with no CI result, comment, or review -activity yet) has nothing to resolve, and the command says so instead of guessing -a URL. +activity yet) has nothing to resolve, and the command reports it instead of +guessing a URL. + +Unresolved pull requests are part of the output, not a silent omission: `-o json` +returns `{"resolved": [...], "unresolved": [...]}`, and the plain-text form prints +the review URLs on stdout while naming any unresolved pull request on stderr. The +command fails only when it resolved nothing at all. ### Import / Export diff --git a/src/commands/git.rs b/src/commands/git.rs index 8df9f7f..7799a27 100644 --- a/src/commands/git.rs +++ b/src/commands/git.rs @@ -1,16 +1,20 @@ use anyhow::Result; use clap::{Subcommand, ValueEnum}; use colored::Colorize; -use serde_json::{json, Value}; +use serde_json::json; use std::path::Path; use std::process::Command; use crate::api::LinearClient; use crate::display_options; -use crate::output::{print_json, OutputOptions}; +use crate::output::OutputOptions; use crate::text::truncate; use crate::vcs::{generate_branch_name, git_branch_exists, run_git_command, validate_branch_name}; +mod review_url; + +use review_url::show_review_url; + /// Version control system type #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] pub enum Vcs { @@ -86,11 +90,12 @@ pub enum GitCommands { /// Show the Linear review URL for an issue's pull request(s) #[command(after_help = r#"EXAMPLES: linear git review-url LIN-123 # Print the review URL(s) - linear g review-url LIN-123 -o json # Include PR number, state, GitHub URL + linear g review-url LIN-123 -o json # Resolved and unresolved PRs NOTE: The review URL is read from the issue's pull request notifications, falling back to the pull requests linked to its agent sessions. A pull request that has -produced neither has no review URL to resolve."#)] +produced neither has no review URL to resolve; it is listed as unresolved rather +than dropped, on stderr in plain text and under "unresolved" in JSON."#)] ReviewUrl { /// Issue identifier (e.g., "LIN-123") or ID issue: String, @@ -181,234 +186,6 @@ pub async fn handle(cmd: GitCommands, output: &OutputOptions) -> Result<()> { } } -/// GitHub pull request URLs attached to an issue. -/// -/// Linear links a pull request to an issue as a `github` attachment as soon as it -/// detects the branch, so this covers every pull request of the issue — but the -/// attachment carries no review slug, which is why the slug is looked up -/// separately. -fn attached_pr_urls(issue: &Value) -> Vec<String> { - let mut urls = Vec::new(); - for node in issue["attachments"]["nodes"] - .as_array() - .into_iter() - .flatten() - { - if node["sourceType"].as_str() != Some("github") { - continue; - } - if let Some(url) = node["url"].as_str().filter(|u| u.contains("/pull/")) { - if !urls.iter().any(|existing| existing == url) { - urls.push(url.to_string()); - } - } - } - urls -} - -/// Pick out the review URLs for `pr_urls` from a page of notifications. -/// -/// A `PullRequestNotification` is the one public place that pairs a pull request -/// with its Linear review URL, and it carries that URL whole (`review/<title -/// slug>-<id>`) rather than requiring it to be assembled. -fn review_entries_from_notifications(nodes: &[Value], pr_urls: &[String]) -> Vec<Value> { - let mut entries = Vec::new(); - for node in nodes { - let pr = &node["pullRequest"]; - let Some(pr_url) = pr["url"].as_str() else { - continue; - }; - if !pr_urls.iter().any(|wanted| wanted == pr_url) { - continue; - } - let Some(review_url) = node["url"].as_str().filter(|u| !u.is_empty()) else { - continue; - }; - // A comment notification points at an anchor within the review page - // (`…#comment-<id>`); the page itself is what a caller wants. - let review_url = review_url.split('#').next().unwrap_or(review_url); - entries.push(json!({ - "reviewUrl": review_url, - "number": pr["number"], - "status": pr["status"], - "url": pr_url, - "title": pr["title"], - })); - } - entries -} - -/// Merge review entries, keeping the first entry seen per pull request URL. -fn merge_review_entries(entries: Vec<Value>) -> Vec<Value> { - let mut seen: Vec<String> = Vec::new(); - let mut merged = Vec::new(); - for entry in entries { - let key = entry["url"].as_str().unwrap_or_default().to_string(); - if !key.is_empty() && seen.contains(&key) { - continue; - } - seen.push(key); - merged.push(entry); - } - merged -} - -/// Build review entries from the agent sessions attached to an issue. -/// -/// This is the fallback for a pull request with no notification: an agent session -/// exposes `PullRequest.slugId` directly, from which the review URL can be -/// assembled. One pull request can be linked by several sessions, hence the -/// de-duplication by slug. -fn review_entries(url_key: &str, issue: &Value) -> Vec<Value> { - let mut seen: Vec<String> = Vec::new(); - let mut entries = Vec::new(); - - let sessions = issue["agentSessions"]["nodes"].as_array(); - for session in sessions.into_iter().flatten() { - let links = session["pullRequests"]["nodes"].as_array(); - for link in links.into_iter().flatten() { - let pr = &link["pullRequest"]; - let Some(slug) = pr["slugId"].as_str().filter(|s| !s.is_empty()) else { - continue; - }; - if seen.iter().any(|s| s == slug) { - continue; - } - seen.push(slug.to_string()); - entries.push(json!({ - "reviewUrl": format!("https://linear.app/{}/review/{}", url_key, slug), - "number": pr["number"], - "status": pr["status"], - "url": pr["url"], - "title": pr["title"], - })); - } - } - - entries -} - -/// How many pages of notifications `review-url` will read before giving up. -/// -/// The notification feed has no server-side filter for pull requests, so it is -/// walked newest-first; a pull request whose last notification is older than this -/// falls through to the agent-session path. -const REVIEW_NOTIFICATION_PAGES: usize = 5; - -/// Look up review URLs for `pr_urls` by walking the notification feed. -async fn review_urls_via_notifications( - client: &LinearClient, - pr_urls: &[String], -) -> Result<Vec<Value>> { - let query = r#" - query($after: String) { - notifications(first: 100, after: $after, includeArchived: true) { - pageInfo { hasNextPage endCursor } - nodes { - __typename - ... on PullRequestNotification { - url - pullRequest { url number status title } - } - } - } - } - "#; - - let mut entries: Vec<Value> = Vec::new(); - let mut cursor: Option<String> = None; - - for _ in 0..REVIEW_NOTIFICATION_PAGES { - let result = client - .query(query, Some(json!({ "after": cursor }))) - .await?; - let page = &result["data"]["notifications"]; - let nodes = page["nodes"].as_array().cloned().unwrap_or_default(); - - entries.extend(review_entries_from_notifications(&nodes, pr_urls)); - entries = merge_review_entries(entries); - - if entries.len() == pr_urls.len() || page["pageInfo"]["hasNextPage"] != json!(true) { - break; - } - cursor = page["pageInfo"]["endCursor"].as_str().map(str::to_string); - } - - Ok(entries) -} - -async fn show_review_url(issue_id: &str, output: &OutputOptions) -> Result<()> { - let client = LinearClient::new()?; - - let query = r#" - query($id: String!) { - organization { urlKey } - issue(id: $id) { - identifier - attachments { nodes { url sourceType } } - agentSessions { - nodes { - pullRequests { - nodes { - pullRequest { slugId url number status title } - } - } - } - } - } - } - "#; - - let result = client.query(query, Some(json!({ "id": issue_id }))).await?; - let issue = &result["data"]["issue"]; - - if issue.is_null() { - anyhow::bail!("Issue not found: {}", issue_id); - } - - let url_key = result["data"]["organization"]["urlKey"] - .as_str() - .unwrap_or_default(); - let pr_urls = attached_pr_urls(issue); - - // A notification carries the review URL whole; agent sessions only expose the - // slug to assemble one, so they are the fallback. - let mut entries = if pr_urls.is_empty() { - Vec::new() - } else { - review_urls_via_notifications(&client, &pr_urls).await? - }; - entries.extend(review_entries(url_key, issue)); - let entries = merge_review_entries(entries); - - if entries.is_empty() { - anyhow::bail!( - "No review URL for {}: {}. Use the GitHub PR URL instead.", - issue["identifier"].as_str().unwrap_or(issue_id), - if pr_urls.is_empty() { - "no pull request is linked to this issue".to_string() - } else { - format!( - "Linear exposes a review URL through pull request notifications, and none \ - of the {} linked pull request(s) has one in the last {} pages of the feed", - pr_urls.len(), - REVIEW_NOTIFICATION_PAGES - ) - } - ); - } - - if output.is_json() || output.has_template() { - return print_json(&json!(entries), output); - } - - for entry in &entries { - println!("{}", entry["reviewUrl"].as_str().unwrap_or_default()); - } - - Ok(()) -} - async fn get_issue_info(issue_id: &str) -> Result<(String, String, String, String)> { let client = LinearClient::new()?; @@ -844,153 +621,6 @@ async fn create_pr(issue_id: &str, base: &str, draft: bool, web: bool) -> Result mod tests { use super::*; - fn issue_with_sessions(sessions: Value) -> Value { - json!({ "identifier": "LIN-123", "agentSessions": { "nodes": sessions } }) - } - - fn pr_link(slug: &str, number: u64) -> Value { - json!({ "pullRequest": { - "slugId": slug, - "number": number, - "status": "open", - "url": format!("https://github.com/acme/app/pull/{}", number), - "title": "Fix the thing" - }}) - } - - #[test] - fn test_attached_pr_urls_keeps_github_pull_requests_only() { - let issue = json!({ "attachments": { "nodes": [ - { "sourceType": "github", "url": "https://github.com/acme/app/pull/183" }, - { "sourceType": "github", "url": "https://github.com/acme/app/pull/183" }, - { "sourceType": "github", "url": "https://github.com/acme/app/issues/12" }, - { "sourceType": "sentry", "url": "https://sentry.io/acme/app/pull/1" }, - { "sourceType": "github", "url": "https://github.com/acme/app/pull/184" } - ]}}); - - assert_eq!( - attached_pr_urls(&issue), - vec![ - "https://github.com/acme/app/pull/183", - "https://github.com/acme/app/pull/184" - ] - ); - } - - #[test] - fn test_review_entries_from_notifications_uses_the_notification_url() { - let nodes = vec![ - json!({ - "__typename": "IssueNotification", - "url": "https://linear.app/acme/issue/LIN-1" - }), - json!({ - "__typename": "PullRequestNotification", - "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", - "pullRequest": { - "url": "https://github.com/acme/app/pull/183", - "number": 183, "status": "open", "title": "Fix the thing" - } - }), - json!({ - "__typename": "PullRequestNotification", - "url": "https://linear.app/acme/review/someone-elses-pr-aaaaaaaaaaaa", - "pullRequest": { "url": "https://github.com/acme/app/pull/999", "number": 999 } - }), - ]; - let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; - - let entries = review_entries_from_notifications(&nodes, &wanted); - - assert_eq!(entries.len(), 1, "only the requested pull request matches"); - assert_eq!( - entries[0]["reviewUrl"], "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", - "the notification's URL is used verbatim, not reassembled from the slug" - ); - assert_eq!(entries[0]["number"], 183); - } - - #[test] - fn test_review_entries_from_notifications_drops_a_comment_anchor() { - let nodes = vec![json!({ - "__typename": "PullRequestNotification", - "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a#comment-5f63aa7c", - "pullRequest": { "url": "https://github.com/acme/app/pull/183", "number": 183 } - })]; - let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; - - let entries = review_entries_from_notifications(&nodes, &wanted); - - assert_eq!( - entries[0]["reviewUrl"], "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", - "a comment notification must still yield the review page URL" - ); - } - - #[test] - fn test_merge_review_entries_prefers_the_first_entry_per_pull_request() { - let merged = merge_review_entries(vec![ - json!({ "url": "https://github.com/acme/app/pull/1", "reviewUrl": "from-notification" }), - json!({ "url": "https://github.com/acme/app/pull/1", "reviewUrl": "from-agent-session" }), - json!({ "url": "https://github.com/acme/app/pull/2", "reviewUrl": "other" }), - ]); - - assert_eq!(merged.len(), 2); - assert_eq!(merged[0]["reviewUrl"], "from-notification"); - assert_eq!(merged[1]["reviewUrl"], "other"); - } - - #[test] - fn test_review_entries_builds_review_url_from_slug() { - let issue = issue_with_sessions(json!([ - { "pullRequests": { "nodes": [pr_link("7ffd27854fd2", 183)] } } - ])); - - let entries = review_entries("acme", &issue); - - assert_eq!(entries.len(), 1); - assert_eq!( - entries[0]["reviewUrl"], - "https://linear.app/acme/review/7ffd27854fd2" - ); - assert_eq!(entries[0]["number"], 183); - assert_eq!(entries[0]["url"], "https://github.com/acme/app/pull/183"); - } - - #[test] - fn test_review_entries_dedupes_a_pr_linked_by_several_sessions() { - let issue = issue_with_sessions(json!([ - { "pullRequests": { "nodes": [pr_link("aaa111", 7)] } }, - { "pullRequests": { "nodes": [pr_link("aaa111", 7), pr_link("bbb222", 8)] } } - ])); - - let entries = review_entries("acme", &issue); - - assert_eq!(entries.len(), 2, "the repeated pull request is listed once"); - assert_eq!( - entries[0]["reviewUrl"], - "https://linear.app/acme/review/aaa111" - ); - assert_eq!( - entries[1]["reviewUrl"], - "https://linear.app/acme/review/bbb222" - ); - } - - #[test] - fn test_review_entries_empty_without_sessions_or_slug() { - assert!(review_entries("acme", &issue_with_sessions(json!([]))).is_empty()); - - // A session with no linked pull request, and a link whose slug is missing or - // blank: all unresolvable, and none of them may produce a bogus URL. - let unresolvable = issue_with_sessions(json!([ - { "pullRequests": { "nodes": [] } }, - { "pullRequests": { "nodes": [{ "pullRequest": { "number": 1 } }] } }, - { "pullRequests": { "nodes": [{ "pullRequest": { "slugId": "", "number": 2 } }] } } - ])); - assert!(review_entries("acme", &unresolvable).is_empty()); - } - #[test] fn test_generate_branch_name_simple() { assert_eq!( diff --git a/src/commands/git/review_url.rs b/src/commands/git/review_url.rs new file mode 100644 index 0000000..9f4dffd --- /dev/null +++ b/src/commands/git/review_url.rs @@ -0,0 +1,788 @@ +//! `linear git review-url` — resolve the Linear review page for an issue's pull +//! requests. +//! +//! Linear exposes a pull request's review URL in two places, and neither covers +//! every pull request on its own: +//! +//! * a `PullRequestNotification` carries the review URL whole +//! (`review/<title-slug>-<id>`), but only exists once the pull request has +//! produced notification-worthy activity; +//! * an agent session's `PullRequest.slugId` is enough to assemble a review URL, +//! but only pull requests an agent worked on have a session. +//! +//! So notifications are the primary source, agent sessions the fallback, and a +//! pull request neither source resolves is reported as unresolved rather than +//! guessed at. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::{BTreeMap, BTreeSet}; + +use crate::api::LinearClient; +use crate::output::{print_json, OutputOptions}; +use crate::pagination::{paginate_until, PageFlow, PaginationOptions}; + +/// How many notifications `review-url` reads before falling through to agent +/// sessions. +/// +/// The feed has no server-side filter for pull requests, so it is walked +/// newest-first; a pull request whose last notification is older than this is +/// left to the agent-session path. +const NOTIFICATION_LIMIT: usize = 500; + +/// Notifications per request while walking the feed. +const NOTIFICATION_PAGE_SIZE: usize = 100; + +const ISSUE_QUERY: &str = r#" + query($id: String!) { + organization { urlKey } + issue(id: $id) { + identifier + attachments { nodes { url sourceType } } + agentSessions { + nodes { + pullRequests { + nodes { + pullRequest { slugId url number status title } + } + } + } + } + } + } +"#; + +const NOTIFICATIONS_QUERY: &str = r#" + query($first: Int, $after: String) { + notifications(first: $first, after: $after, includeArchived: true) { + pageInfo { hasNextPage endCursor } + nodes { + __typename + ... on PullRequestNotification { + url + pullRequest { url number status title } + } + } + } + } +"#; + +/// A Linear GraphQL connection, reduced to the nodes callers care about. +#[derive(Debug, Deserialize)] +// `#[serde(default)]` on `nodes` would otherwise pull a `T: Default` bound into +// the generated impl; only `Deserialize` is actually needed. +#[serde(bound(deserialize = "T: Deserialize<'de>"))] +struct NodeList<T> { + #[serde(default)] + nodes: Vec<T>, +} + +impl<T> Default for NodeList<T> { + fn default() -> Self { + Self { nodes: Vec::new() } + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Issue { + #[serde(default)] + identifier: Option<String>, + #[serde(default)] + attachments: NodeList<Attachment>, + #[serde(default)] + agent_sessions: NodeList<AgentSession>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Attachment { + #[serde(default)] + source_type: Option<String>, + #[serde(default)] + url: Option<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentSession { + #[serde(default)] + pull_requests: NodeList<AgentSessionPullRequest>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AgentSessionPullRequest { + #[serde(default)] + pull_request: Option<PullRequest>, +} + +/// A pull request as Linear returns it, on either source. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PullRequest { + #[serde(default)] + slug_id: Option<String>, + #[serde(default)] + url: Option<String>, + #[serde(default)] + number: Option<i64>, + #[serde(default)] + status: Option<String>, + #[serde(default)] + title: Option<String>, +} + +/// A node from the notification feed. The feed is heterogeneous, so everything +/// but `__typename` is optional and non-pull-request nodes are dropped. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Notification { + #[serde(rename = "__typename", default)] + typename: Option<String>, + #[serde(default)] + url: Option<String>, + #[serde(default)] + pull_request: Option<PullRequest>, +} + +/// One pull request that resolved to a review page. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReviewEntry { + review_url: String, + number: Option<i64>, + status: Option<String>, + title: Option<String>, + /// The GitHub pull request URL; the identity a merge keys on. + url: String, +} + +impl ReviewEntry { + /// Build an entry for `pr` at an already-resolved `review_url`. + /// + /// A pull request Linear returned without a URL has no identity to merge or + /// report on, so it yields nothing rather than an entry with a null `url`. + fn new(review_url: String, pr: &PullRequest) -> Option<Self> { + let url = pr.url.clone().filter(|u| !u.is_empty())?; + Some(Self { + review_url, + number: pr.number, + status: pr.status.clone(), + title: pr.title.clone(), + url, + }) + } +} + +/// The outcome of resolving an issue's pull requests. +/// +/// Unresolved pull requests are part of the output contract, not a silent +/// omission: a caller that resolved two of three attached pull requests can see +/// which one it did not get. +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +struct Resolution { + resolved: Vec<ReviewEntry>, + /// GitHub pull request URLs attached to the issue that neither source resolved. + unresolved: Vec<String>, +} + +impl Resolution { + /// Merge both sources into one entry per pull request. + /// + /// `fallback` (agent sessions) goes in first so a `primary` (notification) + /// result — which carries the review URL whole rather than assembling it — + /// replaces it. Entries are reported in `pr_urls` order, which is the order + /// Linear lists the issue's attachments; a pull request an agent session + /// linked without a corresponding attachment follows them. + fn merge(pr_urls: &[String], fallback: Vec<ReviewEntry>, primary: Vec<ReviewEntry>) -> Self { + let mut by_pr_url: BTreeMap<String, ReviewEntry> = BTreeMap::new(); + for entry in fallback.into_iter().chain(primary) { + by_pr_url.insert(entry.url.clone(), entry); + } + + let mut resolved = Vec::with_capacity(by_pr_url.len()); + let mut unresolved = Vec::new(); + for url in pr_urls { + match by_pr_url.remove(url) { + Some(entry) => resolved.push(entry), + None => unresolved.push(url.clone()), + } + } + resolved.extend(by_pr_url.into_values()); + + Self { + resolved, + unresolved, + } + } +} + +impl Issue { + /// GitHub pull request URLs attached to the issue. + /// + /// Linear links a pull request to an issue as a `github` attachment as soon + /// as it detects the branch, so this is the full set of the issue's pull + /// requests — but the attachment carries no review slug, which is why the + /// review URL is looked up separately. + fn attached_pr_urls(&self) -> Vec<String> { + let mut urls: Vec<String> = Vec::new(); + for attachment in &self.attachments.nodes { + if attachment.source_type.as_deref() != Some("github") { + continue; + } + let Some(url) = attachment.url.as_deref().filter(|u| u.contains("/pull/")) else { + continue; + }; + if !urls.iter().any(|existing| existing == url) { + urls.push(url.to_string()); + } + } + urls + } + + /// Review entries assembled from the issue's agent sessions. + /// + /// This is the fallback for a pull request with no notification: a session + /// exposes `PullRequest.slugId`, from which the review URL can be built. + fn agent_session_entries(&self, url_key: &str) -> Vec<ReviewEntry> { + let mut entries = Vec::new(); + for session in &self.agent_sessions.nodes { + for link in &session.pull_requests.nodes { + let Some(pr) = link.pull_request.as_ref() else { + continue; + }; + let Some(slug) = pr.slug_id.as_deref().filter(|s| !s.is_empty()) else { + continue; + }; + let review_url = format!("https://linear.app/{}/review/{}", url_key, slug); + if let Some(entry) = ReviewEntry::new(review_url, pr) { + entries.push(entry); + } + } + } + entries + } +} + +/// Pick out review entries for `pr_urls` from a page of notification nodes. +fn review_entries_from_notifications(nodes: &[Value], pr_urls: &[String]) -> Vec<ReviewEntry> { + let mut entries = Vec::new(); + for node in nodes { + let Ok(notification) = serde_json::from_value::<Notification>(node.clone()) else { + continue; + }; + if notification.typename.as_deref() != Some("PullRequestNotification") { + continue; + } + let Some(pr) = notification.pull_request.as_ref() else { + continue; + }; + if !pr + .url + .as_deref() + .is_some_and(|url| pr_urls.iter().any(|wanted| wanted == url)) + { + continue; + } + let Some(review_url) = notification.url.as_deref().filter(|u| !u.is_empty()) else { + continue; + }; + // A comment notification points at an anchor within the review page + // (`…#comment-<id>`); the page itself is what a caller wants. + let review_url = review_url.split('#').next().unwrap_or(review_url); + if let Some(entry) = ReviewEntry::new(review_url.to_string(), pr) { + entries.push(entry); + } + } + entries +} + +/// Accumulates review entries across pages of the notification feed. +/// +/// The feed is newest-first and a pull request can appear on it many times, so +/// the first entry seen for a pull request is its current one and later pages +/// must not displace it. Once every wanted pull request has an entry there is +/// nothing left to look for, which is what lets the walk stop early. +#[derive(Debug, Default)] +struct NotificationScan { + entries: Vec<ReviewEntry>, + seen_pr_urls: BTreeSet<String>, +} + +impl NotificationScan { + /// Take one page of notification nodes, and report whether to read another. + fn absorb(&mut self, nodes: &[Value], pr_urls: &[String]) -> PageFlow { + for entry in review_entries_from_notifications(nodes, pr_urls) { + if self.seen_pr_urls.insert(entry.url.clone()) { + self.entries.push(entry); + } + } + + if self.seen_pr_urls.len() >= pr_urls.len() { + PageFlow::Stop + } else { + PageFlow::Continue + } + } +} + +/// Walk the notification feed looking for the review URLs of `pr_urls`. +async fn notification_entries( + client: &LinearClient, + pr_urls: &[String], +) -> Result<Vec<ReviewEntry>> { + let options = PaginationOptions { + limit: Some(NOTIFICATION_LIMIT), + page_size: Some(NOTIFICATION_PAGE_SIZE), + ..Default::default() + }; + + let mut scan = NotificationScan::default(); + paginate_until( + client, + NOTIFICATIONS_QUERY, + Map::new(), + &["data", "notifications", "nodes"], + &["data", "notifications", "pageInfo"], + &options, + NOTIFICATION_PAGE_SIZE, + |nodes| scan.absorb(&nodes, pr_urls), + ) + .await?; + + Ok(scan.entries) +} + +/// Why `identifier` has no review URL, given the pull requests attached to it. +fn nothing_resolved_error(identifier: &str, pr_urls: &[String]) -> anyhow::Error { + let reason = if pr_urls.is_empty() { + "no pull request is linked to this issue".to_string() + } else { + format!( + "Linear exposes a review URL through pull request notifications, and none of the {} \ + linked pull request(s) has one in the last {} notifications", + pr_urls.len(), + NOTIFICATION_LIMIT + ) + }; + anyhow::anyhow!( + "No review URL for {}: {}. Use the GitHub PR URL instead.", + identifier, + reason + ) +} + +pub async fn show_review_url(issue_id: &str, output: &OutputOptions) -> Result<()> { + let client = LinearClient::new()?; + let result = client + .query(ISSUE_QUERY, Some(json!({ "id": issue_id }))) + .await?; + + if result["data"]["issue"].is_null() { + anyhow::bail!("Issue not found: {}", issue_id); + } + + let issue: Issue = serde_json::from_value(result["data"]["issue"].clone())?; + let url_key = result["data"]["organization"]["urlKey"] + .as_str() + .unwrap_or_default(); + + let pr_urls = issue.attached_pr_urls(); + let notifications = if pr_urls.is_empty() { + Vec::new() + } else { + notification_entries(&client, &pr_urls).await? + }; + + let resolution = Resolution::merge( + &pr_urls, + issue.agent_session_entries(url_key), + notifications, + ); + + if resolution.resolved.is_empty() { + let identifier = issue.identifier.as_deref().unwrap_or(issue_id); + return Err(nothing_resolved_error(identifier, &pr_urls)); + } + + if output.is_json() || output.has_template() { + return print_json(&json!(resolution), output); + } + + for entry in &resolution.resolved { + println!("{}", entry.review_url); + } + + // A partly resolved issue still prints what it has, but never silently: the + // pull requests it could not resolve are named on stderr so a caller reading + // stdout does not mistake the list for the whole set. + if !resolution.unresolved.is_empty() { + eprintln!( + "warning: no review URL for {} of the issue's pull request(s):", + resolution.unresolved.len() + ); + for url in &resolution.unresolved { + eprintln!(" {}", url); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn issue(value: Value) -> Issue { + serde_json::from_value(value).expect("issue fixture must deserialize") + } + + fn issue_with_sessions(sessions: Value) -> Issue { + issue(json!({ "identifier": "LIN-123", "agentSessions": { "nodes": sessions } })) + } + + fn pr_link(slug: &str, number: u64) -> Value { + json!({ "pullRequest": { + "slugId": slug, + "number": number, + "status": "open", + "url": format!("https://github.com/acme/app/pull/{}", number), + "title": "Fix the thing" + }}) + } + + fn entry(review_url: &str, pr_url: &str) -> ReviewEntry { + ReviewEntry { + review_url: review_url.to_string(), + number: None, + status: None, + title: None, + url: pr_url.to_string(), + } + } + + #[test] + fn test_attached_pr_urls_keeps_github_pull_requests_only() { + let issue = issue(json!({ "attachments": { "nodes": [ + { "sourceType": "github", "url": "https://github.com/acme/app/pull/183" }, + { "sourceType": "github", "url": "https://github.com/acme/app/pull/183" }, + { "sourceType": "github", "url": "https://github.com/acme/app/issues/12" }, + { "sourceType": "sentry", "url": "https://sentry.io/acme/app/pull/1" }, + { "sourceType": "github", "url": "https://github.com/acme/app/pull/184" } + ]}})); + + assert_eq!( + issue.attached_pr_urls(), + vec![ + "https://github.com/acme/app/pull/183", + "https://github.com/acme/app/pull/184" + ] + ); + } + + #[test] + fn test_review_entries_from_notifications_uses_the_notification_url() { + let nodes = vec![ + json!({ + "__typename": "IssueNotification", + "url": "https://linear.app/acme/issue/LIN-1" + }), + json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "pullRequest": { + "url": "https://github.com/acme/app/pull/183", + "number": 183, "status": "open", "title": "Fix the thing" + } + }), + json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/someone-elses-pr-aaaaaaaaaaaa", + "pullRequest": { "url": "https://github.com/acme/app/pull/999", "number": 999 } + }), + ]; + let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; + + let entries = review_entries_from_notifications(&nodes, &wanted); + + assert_eq!(entries.len(), 1, "only the requested pull request matches"); + assert_eq!( + entries[0].review_url, "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "the notification's URL is used verbatim, not reassembled from the slug" + ); + assert_eq!(entries[0].number, Some(183)); + } + + #[test] + fn test_review_entries_from_notifications_drops_a_comment_anchor() { + let nodes = vec![json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a#comment-5f63aa7c", + "pullRequest": { "url": "https://github.com/acme/app/pull/183", "number": 183 } + })]; + let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; + + let entries = review_entries_from_notifications(&nodes, &wanted); + + assert_eq!( + entries[0].review_url, "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "a comment notification must still yield the review page URL" + ); + } + + #[test] + fn test_review_entries_from_notifications_skips_a_pull_request_without_a_url() { + // Nothing to key or report on, so it must not become an entry. + let nodes = vec![json!({ + "__typename": "PullRequestNotification", + "url": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "pullRequest": { "number": 183 } + })]; + let wanted = vec!["https://github.com/acme/app/pull/183".to_string()]; + + assert!(review_entries_from_notifications(&nodes, &wanted).is_empty()); + } + + #[test] + fn test_merge_prefers_the_notification_entry_per_pull_request() { + let pr = "https://github.com/acme/app/pull/1"; + let other = "https://github.com/acme/app/pull/2"; + + let merged = Resolution::merge( + &[pr.to_string(), other.to_string()], + vec![ + entry("from-agent-session", pr), + entry("other-from-agent-session", other), + ], + vec![entry("from-notification", pr)], + ); + + assert_eq!( + merged, + Resolution { + resolved: vec![ + entry("from-notification", pr), + entry("other-from-agent-session", other) + ], + unresolved: vec![], + } + ); + } + + #[test] + fn test_merge_reports_an_attached_pull_request_neither_source_resolved() { + let resolved_pr = "https://github.com/acme/app/pull/1"; + let unresolved_pr = "https://github.com/acme/app/pull/2"; + + let merged = Resolution::merge( + &[resolved_pr.to_string(), unresolved_pr.to_string()], + vec![], + vec![entry("from-notification", resolved_pr)], + ); + + assert_eq!( + merged, + Resolution { + resolved: vec![entry("from-notification", resolved_pr)], + unresolved: vec![unresolved_pr.to_string()], + }, + "a partial resolution must name the pull request it could not resolve" + ); + } + + #[test] + fn test_merge_reports_in_attachment_order_then_unattached_pull_requests() { + let second = "https://github.com/acme/app/pull/99"; + let first = "https://github.com/acme/app/pull/183"; + let unattached = "https://github.com/acme/app/pull/7"; + + let merged = Resolution::merge( + // Attachment order, which is not the order the entries arrive in and + // not lexicographic by URL. + &[first.to_string(), second.to_string()], + vec![entry("c", unattached)], + vec![entry("b", second), entry("a", first)], + ); + + assert_eq!( + merged.resolved, + vec![ + entry("a", first), + entry("b", second), + entry("c", unattached) + ] + ); + assert!(merged.unresolved.is_empty()); + } + + #[test] + fn test_agent_session_entries_builds_review_url_from_slug() { + let issue = issue_with_sessions(json!([ + { "pullRequests": { "nodes": [pr_link("7ffd27854fd2", 183)] } } + ])); + + let entries = issue.agent_session_entries("acme"); + + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0].review_url, + "https://linear.app/acme/review/7ffd27854fd2" + ); + assert_eq!(entries[0].number, Some(183)); + assert_eq!(entries[0].url, "https://github.com/acme/app/pull/183"); + } + + #[test] + fn test_agent_session_entries_dedupe_a_pr_linked_by_several_sessions() { + let issue = issue_with_sessions(json!([ + { "pullRequests": { "nodes": [pr_link("aaa111", 7)] } }, + { "pullRequests": { "nodes": [pr_link("aaa111", 7), pr_link("bbb222", 8)] } } + ])); + let pr_urls = vec![ + "https://github.com/acme/app/pull/7".to_string(), + "https://github.com/acme/app/pull/8".to_string(), + ]; + + let merged = Resolution::merge(&pr_urls, issue.agent_session_entries("acme"), vec![]); + + assert_eq!( + merged.resolved.len(), + 2, + "the repeated pull request is listed once" + ); + assert_eq!( + merged.resolved[0].review_url, + "https://linear.app/acme/review/aaa111" + ); + assert_eq!( + merged.resolved[1].review_url, + "https://linear.app/acme/review/bbb222" + ); + } + + #[test] + fn test_agent_session_entries_empty_without_sessions_or_slug() { + assert!(issue_with_sessions(json!([])) + .agent_session_entries("acme") + .is_empty()); + + // A session with no linked pull request, and a link whose slug is missing + // or blank: all unresolvable, and none of them may produce a bogus URL. + let unresolvable = issue_with_sessions(json!([ + { "pullRequests": { "nodes": [] } }, + { "pullRequests": { "nodes": [{ "pullRequest": { "number": 1 } }] } }, + { "pullRequests": { "nodes": [{ "pullRequest": { "slugId": "", "number": 2 } }] } } + ])); + assert!(unresolvable.agent_session_entries("acme").is_empty()); + } + + #[test] + fn test_resolution_serializes_both_halves() { + let resolution = Resolution::merge( + &[ + "https://github.com/acme/app/pull/1".to_string(), + "https://github.com/acme/app/pull/2".to_string(), + ], + vec![], + vec![ReviewEntry { + review_url: "https://linear.app/acme/review/fix-the-thing-72e2bba2372a".to_string(), + number: Some(1), + status: Some("open".to_string()), + title: Some("Fix the thing".to_string()), + url: "https://github.com/acme/app/pull/1".to_string(), + }], + ); + + assert_eq!( + json!(resolution), + json!({ + "resolved": [{ + "reviewUrl": "https://linear.app/acme/review/fix-the-thing-72e2bba2372a", + "number": 1, + "status": "open", + "title": "Fix the thing", + "url": "https://github.com/acme/app/pull/1" + }], + "unresolved": ["https://github.com/acme/app/pull/2"] + }) + ); + } + + #[test] + fn test_nothing_resolved_error_distinguishes_no_pull_request_from_no_review_url() { + assert!(nothing_resolved_error("LIN-123", &[]) + .to_string() + .contains("no pull request is linked to this issue")); + + let attached = vec!["https://github.com/acme/app/pull/1".to_string()]; + let message = nothing_resolved_error("LIN-123", &attached).to_string(); + assert!(message.contains("1 linked pull request(s)")); + assert!(message.contains(&NOTIFICATION_LIMIT.to_string())); + } + + fn notification(review_url: &str, pr_url: &str) -> Value { + json!({ + "__typename": "PullRequestNotification", + "url": review_url, + "pullRequest": { "url": pr_url } + }) + } + + #[test] + fn test_scan_stops_once_every_wanted_pull_request_is_found() { + let first = "https://github.com/acme/app/pull/1"; + let second = "https://github.com/acme/app/pull/2"; + let wanted = vec![first.to_string(), second.to_string()]; + let mut scan = NotificationScan::default(); + + assert_eq!( + scan.absorb(&[notification("review-1", first)], &wanted), + PageFlow::Continue, + "one of two found: the rest of the feed is still worth reading" + ); + assert_eq!( + scan.absorb(&[notification("review-2", second)], &wanted), + PageFlow::Stop, + "both found: no further page may be requested" + ); + assert_eq!( + scan.entries, + vec![entry("review-1", first), entry("review-2", second)] + ); + } + + #[test] + fn test_scan_keeps_the_newest_notification_per_pull_request() { + let pr = "https://github.com/acme/app/pull/1"; + let other = "https://github.com/acme/app/pull/2"; + let wanted = vec![pr.to_string(), other.to_string()]; + let mut scan = NotificationScan::default(); + + // The feed is newest-first, so a later page's older notification for the + // same pull request must not displace the one already held. + scan.absorb(&[notification("newest-review", pr)], &wanted); + scan.absorb(&[notification("older-review", pr)], &wanted); + + assert_eq!(scan.entries, vec![entry("newest-review", pr)]); + } + + #[test] + fn test_scan_ignores_a_page_with_nothing_wanted_on_it() { + let wanted = vec!["https://github.com/acme/app/pull/1".to_string()]; + let mut scan = NotificationScan::default(); + + let flow = scan.absorb( + &[notification( + "review-x", + "https://github.com/acme/app/pull/999", + )], + &wanted, + ); + + assert_eq!(flow, PageFlow::Continue); + assert!(scan.entries.is_empty()); + } +} diff --git a/src/commands/initiatives.rs b/src/commands/initiatives.rs index f4888d8..9b54bea 100644 --- a/src/commands/initiatives.rs +++ b/src/commands/initiatives.rs @@ -396,4 +396,3 @@ async fn delete_initiative(id: &str, force: bool) -> Result<()> { Ok(()) } - diff --git a/src/pagination.rs b/src/pagination.rs index 73f4008..db0b6bc 100644 --- a/src/pagination.rs +++ b/src/pagination.rs @@ -150,6 +150,128 @@ pub async fn paginate_nodes( Ok(items) } +/// Whether a short-circuiting paginator should ask for another page. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageFlow { + /// Read the next page, if the connection has one and the limit allows. + Continue, + /// Everything the caller wanted is in hand; stop without another request. + Stop, +} + +/// Walk a connection forward, letting the caller stop as soon as it has enough. +/// +/// Same cursor rules as [`paginate_nodes`], but each page is handed to +/// `accumulate` before the next request goes out and `accumulate` owns whatever +/// it collects. A caller scanning an unfilterable feed for a known set of +/// records therefore pays for the pages it actually needs rather than +/// `limit / page_size` of them every time. +/// +/// # Example +/// +/// ```ignore +/// let mut found = Vec::new(); +/// paginate_until( +/// &client, +/// query, +/// Map::new(), +/// &["data", "notifications", "nodes"], +/// &["data", "notifications", "pageInfo"], +/// &options, +/// 100, +/// |nodes| { +/// found.extend(nodes.into_iter().filter(is_wanted)); +/// if found.len() == wanted { PageFlow::Stop } else { PageFlow::Continue } +/// }, +/// ) +/// .await?; +/// ``` +#[allow(clippy::too_many_arguments)] +pub async fn paginate_until<F>( + client: &LinearClient, + query: &str, + base_variables: Map<String, Value>, + nodes_path: &[&str], + page_info_path: &[&str], + options: &PaginationOptions, + default_page_size: usize, + mut accumulate: F, +) -> Result<()> +where + F: FnMut(Vec<Value>) -> PageFlow, +{ + let limit = if options.all { None } else { options.limit }; + let page_size = options.effective_page_size(default_page_size); + let mut after = options.after.clone(); + let mut read: usize = 0; + + loop { + let batch_size = limit + .map(|l| l.saturating_sub(read).min(page_size)) + .unwrap_or(page_size) + .max(1); + + let mut page_vars = Map::with_capacity(base_variables.len() + 2); + page_vars.insert( + "first".to_string(), + Value::Number(serde_json::Number::from(batch_size as u64)), + ); + if let Some(ref cursor) = after { + page_vars.insert("after".to_string(), Value::String(cursor.clone())); + } + for (k, v) in &base_variables { + page_vars.insert(k.clone(), v.clone()); + } + + let result = client.query(query, Some(Value::Object(page_vars))).await?; + + let mut nodes = get_path(&result, nodes_path) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if nodes.is_empty() { + break; + } + if let Some(l) = limit { + nodes.truncate(l.saturating_sub(read)); + } + read += nodes.len(); + + if accumulate(nodes) == PageFlow::Stop { + break; + } + + if limit.is_some_and(|l| read >= l) { + break; + } + + // An unbounded request (no `--limit`, no `--all`) is one page, as in + // `paginate_nodes`. + if !options.all && options.limit.is_none() { + break; + } + + let Some(page_info) = get_path(&result, page_info_path).and_then(|v| v.as_object()) else { + break; + }; + if !page_info + .get("hasNextPage") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + break; + } + // A connection that claims another page without handing back a cursor + // would otherwise reread the first page until the limit ran out. + let Some(cursor) = page_info.get("endCursor").and_then(|v| v.as_str()) else { + break; + }; + after = Some(cursor.to_string()); + } + + Ok(()) +} + /// Stream paginated results, calling a handler for each batch of nodes. /// /// This is memory-efficient for large exports because it processes each page From cb33aaf493ac4e609732bda98556a54cf2c901a0 Mon Sep 17 00:00:00 2001 From: Phil Bjorge <phil@87group.ai> Date: Tue, 18 Aug 2026 13:35:08 -0700 Subject: [PATCH 4/9] fix(initiatives): drop progress, which Linear removed from Initiative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `initiatives list` has been returning HTTP 400 for every caller: Cannot query field "progress" on type "Initiative". Did you mean "projects"? Schema introspection confirms Initiative no longer exposes `progress`; it carries `health` and `status` instead. Query `health` and show it in the table in place of the derived percentage. `initiative get` was unaffected — it never selected the field — and the `progress` selection on Project inside its projects query is still valid, so both are left alone. --- src/commands/initiatives.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/commands/initiatives.rs b/src/commands/initiatives.rs index 9b54bea..909ca60 100644 --- a/src/commands/initiatives.rs +++ b/src/commands/initiatives.rs @@ -66,8 +66,8 @@ struct InitiativeRow { name: String, #[tabled(rename = "Status")] status: String, - #[tabled(rename = "Progress")] - progress: String, + #[tabled(rename = "Health")] + health: String, #[tabled(rename = "Projects")] project_count: String, } @@ -113,7 +113,7 @@ async fn list_initiatives(output: &OutputOptions, pagination: &PaginationOptions description status sortOrder - progress + health projects { nodes { id @@ -143,10 +143,7 @@ async fn list_initiatives(output: &OutputOptions, pagination: &PaginationOptions .iter() .filter_map(|v| { let i = serde_json::from_value::<Initiative>(v.clone()).ok()?; - let progress = format!( - "{}%", - (v["progress"].as_f64().unwrap_or(0.0) * 100.0) as i32 - ); + let health = v["health"].as_str().unwrap_or("-").to_string(); let project_count = v["projects"]["nodes"] .as_array() .map(|a| a.len().to_string()) @@ -155,7 +152,7 @@ async fn list_initiatives(output: &OutputOptions, pagination: &PaginationOptions id: i.id, name: truncate(&i.name, max_width), status: i.status.as_deref().unwrap_or("-").to_string(), - progress, + health, project_count, }) }) From 4676564ffd3bcb25696f004c440668aa06bc24b7 Mon Sep 17 00:00:00 2001 From: Phil Bjorge <phil@87group.ai> Date: Tue, 18 Aug 2026 13:35:08 -0700 Subject: [PATCH 5/9] fix(issues): drop sla fields that are invalid on IssueHistory `issues get --history` has been returning HTTP 400 for every caller: Cannot query field "slaBreachesAt" on type "IssueHistory". Did you mean "toSlaBreachesAt", "fromSlaBreachesAt", or "toSlaBreached"? IssueHistory only carries the to*/from* prefixed variants. Remove the two bare selections from the history fragment. The identically named fields on the Issue type are valid and stay, as does the formatter that reads them when the API does return them. --- src/commands/issues.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/commands/issues.rs b/src/commands/issues.rs index 8527275..439bc37 100644 --- a/src/commands/issues.rs +++ b/src/commands/issues.rs @@ -1031,8 +1031,6 @@ async fn get_issue(id: &str, output: &OutputOptions, history: bool, comments: bo toProject { name } archived trashed - slaBreachesAt - slaStartedAt } }"# } else { From 327cf8bd16f8a1b7a02692ac96f2beb0ac51b554 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:14:48 +0700 Subject: [PATCH 6/9] feat(issues): add project flag to create Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 3 +++ src/commands/issues.rs | 19 +++++++++++++++++++ tests/cli_tests.rs | 7 +++++++ 3 files changed, 29 insertions(+) diff --git a/README.md b/README.md index 030aad3..14d40c3 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ linear-cli i get LIN-123 --comments # Inline comments linear-cli i get LIN-1 LIN-2 LIN-3 # Batch fetch linear-cli i create "Fix login" -t ENG -p 1 # Create urgent issue +linear-cli i create "Fix login" -t ENG --project "Q2 Roadmap" linear-cli i update LIN-123 -s Done # Update status linear-cli i update LIN-123 -l bug -l urgent # Add labels linear-cli i update LIN-123 --due tomorrow # Set due date @@ -100,6 +101,8 @@ linear-cli i open LIN-123 # Open in browser linear-cli i link LIN-123 # Print URL ``` +**Create flags:** `--team`, `--description`, `--data`, `--priority`, `--state`, `--assignee`, `--labels`, `--due`, `--estimate`, `--project`, `--template`, `--dry-run` + **List flags:** `--mine`, `--team`, `--state`, `--assignee`, `--project`, `--label`, `--since`, `--view`, `--group-by` (state/priority/assignee/project), `--count-only`, `--archived` ### Projects diff --git a/src/commands/issues.rs b/src/commands/issues.rs index 439bc37..36f018f 100644 --- a/src/commands/issues.rs +++ b/src/commands/issues.rs @@ -103,6 +103,7 @@ pub enum IssueCommands { linear issues create "Fix bug" -t ENG # Create with title and team linear i create "Feature" -t ENG -p 2 # Create with high priority linear i create "Task" -t ENG -a me # Assign to yourself + linear i create "Task" -t ENG --project Q2 # Add to project linear i create "Task" -t ENG --due +3d # Due in 3 days linear i create "Bug" -t ENG --dry-run # Preview without creating"#)] Create { @@ -135,6 +136,9 @@ pub enum IssueCommands { /// Estimate in points (e.g., 1, 2, 3, 5, 8) #[arg(short, long)] estimate: Option<f64>, + /// Project name or ID + #[arg(long)] + project: Option<String>, /// Template name to use for default values #[arg(long)] template: Option<String>, @@ -348,6 +352,7 @@ pub async fn handle( labels, due, estimate, + project, template, dry_run, } => { @@ -429,6 +434,7 @@ pub async fn handle( final_labels, due, estimate, + project, output, agent_opts, dry_run, @@ -1265,6 +1271,7 @@ async fn create_issue( labels: Vec<String>, due: Option<String>, estimate: Option<f64>, + project: Option<String>, output: &OutputOptions, agent_opts: AgentOptions, dry_run: bool, @@ -1354,6 +1361,14 @@ async fn create_issue( if let Some(e) = estimate { input["estimate"] = json!(e); } + if let Some(ref p) = project { + if dry_run { + input["projectId"] = json!(p); + } else { + let project_id = resolve_project_id(&client, p, &output.cache).await?; + input["projectId"] = json!(project_id); + } + } // Dry run: show what would be created without actually creating if dry_run { @@ -1372,6 +1387,7 @@ async fn create_issue( "labels": labels, "dueDate": due, "estimate": estimate, + "project": project, } }), output, @@ -1407,6 +1423,9 @@ async fn create_issue( if let Some(e) = estimate { println!(" Estimate: {}", e); } + if let Some(ref p) = project { + println!(" Project: {}", p); + } } return Ok(()); } diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index ef5925c..5cde509 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -73,6 +73,13 @@ fn test_issues_help() { assert!(stdout.contains("stop")); } +#[test] +fn test_issues_create_help_includes_project() { + let (code, stdout, _stderr) = run_cli(&["issues", "create", "--help"]); + assert_eq!(code, 0); + assert!(stdout.contains("--project")); +} + #[test] fn test_teams_help() { let (code, stdout, _stderr) = run_cli(&["teams", "--help"]); From 77d70231cad8787cdccb409923f933971808342b Mon Sep 17 00:00:00 2001 From: Finesssee <truongnamphong8@gmail.com> Date: Sun, 13 Sep 2026 17:52:40 +0700 Subject: [PATCH 7/9] ci: add fail-closed CircleCI release pipeline --- .circleci/README.md | 38 +++ .circleci/config.yml | 438 ++++++++++++++++++++++++++++++++++ .github/CI.md | 69 +++--- .github/workflows/ci.yml | 23 +- .github/workflows/release.yml | 121 ---------- CONTEXT.md | 30 ++- Cargo.lock | 2 +- Cargo.toml | 6 +- README.md | 10 +- docs/ai-agents.md | 2 +- docs/manual-release.md | 159 +++++------- docs/skills.md | 8 +- src/api.rs | 26 +- src/commands/api.rs | 3 +- src/commands/import.rs | 4 +- src/commands/issues.rs | 2 +- src/commands/update.rs | 2 +- src/main.rs | 170 +++++++++++++ src/output.rs | 9 + tests/cli_tests.rs | 31 +++ 20 files changed, 841 insertions(+), 312 deletions(-) create mode 100644 .circleci/README.md create mode 100644 .circleci/config.yml delete mode 100644 .github/workflows/release.yml diff --git a/.circleci/README.md b/.circleci/README.md new file mode 100644 index 0000000..3a6add8 --- /dev/null +++ b/.circleci/README.md @@ -0,0 +1,38 @@ +# CircleCI CI and release pipeline + +CircleCI is the canonical CI and release path for this repository. + +## CI + +The `ci` workflow runs the locked test suite, formatting check, clippy with +warnings denied, and a default-feature build on Linux for non-release refs. + +## Release + +Push an annotated or lightweight tag matching `vX.Y.Z`. The release workflow: + +1. Builds the five supported targets in parallel: + - `x86_64-unknown-linux-gnu` + - `aarch64-unknown-linux-gnu` + - `x86_64-pc-windows-msvc` + - `x86_64-apple-darwin` + - `aarch64-apple-darwin` +2. Verifies that the tag version matches `Cargo.toml`. +3. Requires exactly those five archives, checks each binary's `--version`, + and generates `SHA256SUMS` plus `release-manifest.json`. +4. Uploads the verified assets to the GitHub release. +5. Publishes the matching crate version to crates.io. + +The GitHub release and crates.io steps are intentionally downstream of the +five-asset gate. They require a CircleCI context named `linear-cli-release` +containing: + +- `GH_TOKEN`: a GitHub token allowed to create/update releases in this repo. +- `CARGO_REGISTRY_TOKEN`: the crates.io publish token. + +Configure that context and connect this repository to CircleCI before pushing +the release tag. The local CircleCI CLI can validate the file with: + +```bash +circleci config validate .circleci/config.yml +``` diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..52d20d4 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,438 @@ +version: 2.1 + +commands: + install-rust: + description: Install the stable Rust toolchain and CI components. + steps: + - run: + name: Install Rust + command: | + set -euo pipefail + if ! command -v rustup >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + fi + export PATH="$HOME/.cargo/bin:$PATH" + rustup toolchain install stable --profile minimal + rustup default stable + rustup component add rustfmt clippy + + install-linux-deps: + description: Install the Linux dependencies used by secure-storage builds. + steps: + - run: + name: Install Linux build dependencies + command: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y libdbus-1-dev pkg-config + + package-unix: + parameters: + target: + type: string + steps: + - run: + name: Verify and package << parameters.target >> + command: | + set -euo pipefail + target="<< parameters.target >>" + binary="target/$target/release/linear-cli" + artifact_dir="artifacts/$target" + test -x "$binary" + "$binary" --version + mkdir -p "$artifact_dir" + tar -C "target/$target/release" -czf "$artifact_dir/linear-cli-$target.tar.gz" linear-cli + tar -tzf "$artifact_dir/linear-cli-$target.tar.gz" | grep -qx 'linear-cli' + - persist_to_workspace: + root: . + paths: + - artifacts/<< parameters.target >> + - store_artifacts: + path: artifacts/<< parameters.target >> + +jobs: + test: + machine: + image: ubuntu-2404:current + resource_class: large + steps: + - checkout + - install-linux-deps + - install-rust + - run: + name: Run locked test suite + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo test --locked --features secure-storage + - run: + name: Check formatting + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo fmt --all -- --check + - run: + name: Run clippy with warnings denied + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo clippy --locked --features secure-storage -- -D warnings + - run: + name: Build default feature set + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo build --locked --verbose + + build-linux-x86: + machine: + image: ubuntu-2404:current + resource_class: large + steps: + - checkout + - install-linux-deps + - install-rust + - run: + name: Build x86_64 Linux binary + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + rustup target add x86_64-unknown-linux-gnu + cargo build --locked --release --features secure-storage --target x86_64-unknown-linux-gnu + - package-unix: + target: x86_64-unknown-linux-gnu + + build-linux-arm: + machine: + image: ubuntu-2404:current + resource_class: large + steps: + - checkout + - install-rust + - run: + name: Build aarch64 Linux binary with Cross + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + cargo install cross --locked --version 0.2.5 + cross build --locked --release --features secure-storage --target aarch64-unknown-linux-gnu + - package-unix: + target: aarch64-unknown-linux-gnu + + build-macos-x86: + macos: + xcode: 26.6.0 + resource_class: m4pro.medium + steps: + - checkout + - install-rust + - run: + name: Build x86_64 macOS binary + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + rustup target add x86_64-apple-darwin + cargo build --locked --release --features secure-storage --target x86_64-apple-darwin + - package-unix: + target: x86_64-apple-darwin + + build-macos-arm: + macos: + xcode: 26.6.0 + resource_class: m4pro.medium + steps: + - checkout + - install-rust + - run: + name: Build aarch64 macOS binary + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + rustup target add aarch64-apple-darwin + cargo build --locked --release --features secure-storage --target aarch64-apple-darwin + - package-unix: + target: aarch64-apple-darwin + + build-windows-x86: + machine: + image: windows-server-2022-gui:current + shell: powershell.exe -ExecutionPolicy Bypass + resource_class: windows.medium + steps: + - checkout + - run: + name: Install Rust + command: | + $ErrorActionPreference = 'Stop' + $env:Path = "$env:USERPROFILE\.cargo\bin;$env:Path" + if (-not (Get-Command rustup -ErrorAction SilentlyContinue)) { + $installer = Join-Path $env:TEMP 'rustup-init.exe' + Invoke-WebRequest -Uri 'https://win.rustup.rs/x86_64' -OutFile $installer + & $installer -y --default-toolchain stable --profile minimal + } + rustup default stable + rustup component add rustfmt clippy + rustup target add x86_64-pc-windows-msvc + - run: + name: Build and package Windows binary + command: | + $ErrorActionPreference = 'Stop' + $env:Path = "$env:USERPROFILE\.cargo\bin;$env:Path" + $target = 'x86_64-pc-windows-msvc' + cargo build --locked --release --features secure-storage --target $target + $binary = "target/$target/release/linear-cli.exe" + if (-not (Test-Path -LiteralPath $binary)) { throw "Missing binary: $binary" } + & $binary --version + $artifactDir = "artifacts/$target" + New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null + $stage = Join-Path $env:TEMP "linear-cli-package-$PID" + New-Item -ItemType Directory -Force -Path $stage | Out-Null + Copy-Item -LiteralPath $binary -Destination (Join-Path $stage 'linear-cli.exe') + Compress-Archive -Path (Join-Path $stage 'linear-cli.exe') -DestinationPath "$artifactDir/linear-cli-$target.zip" -Force + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [System.IO.Compression.ZipFile]::OpenRead("$artifactDir/linear-cli-$target.zip") + try { + if (($archive.Entries | ForEach-Object FullName) -ne 'linear-cli.exe') { throw 'Windows archive must contain linear-cli.exe at its root' } + } finally { + $archive.Dispose() + } + - persist_to_workspace: + root: . + paths: + - artifacts/x86_64-pc-windows-msvc + - store_artifacts: + path: artifacts/x86_64-pc-windows-msvc + + verify-release: + machine: + image: ubuntu-2404:current + resource_class: medium + steps: + - checkout + - install-rust + - attach_workspace: + at: . + - run: + name: Verify release tag, version, assets, and checksums + command: | + set -euo pipefail + : "${CIRCLE_TAG:?This job must run from a release tag}" + if [[ ! "$CIRCLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unsupported release tag: $CIRCLE_TAG" >&2 + exit 1 + fi + version="${CIRCLE_TAG#v}" + cargo_version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" + test "$cargo_version" = "$version" + + expected=( + linear-cli-x86_64-unknown-linux-gnu.tar.gz + linear-cli-aarch64-unknown-linux-gnu.tar.gz + linear-cli-x86_64-pc-windows-msvc.zip + linear-cli-x86_64-apple-darwin.tar.gz + linear-cli-aarch64-apple-darwin.tar.gz + ) + mkdir -p release + for archive in "${expected[@]}"; do + found="$(find artifacts -type f -name "$archive" -print -quit)" + test -n "$found" + cp "$found" "release/$archive" + done + actual_count="$(find artifacts -type f \( -name 'linear-cli-*.tar.gz' -o -name 'linear-cli-*.zip' \) | wc -l)" + test "$actual_count" -eq "${#expected[@]}" + + extract_dir="$(mktemp -d)" + trap 'rm -rf "$extract_dir"' EXIT + for archive in "${expected[@]}"; do + target="${archive#linear-cli-}" + target="${target%.tar.gz}" + target="${target%.zip}" + destination="$extract_dir/$target" + mkdir -p "$destination" + if [[ "$archive" == *.tar.gz ]]; then + tar -xzf "release/$archive" -C "$destination" + binary="$destination/linear-cli" + test -x "$binary" + reported="$($binary --version)" + test "$reported" = "linear-cli $version" + else + unzip -q "release/$archive" -d "$destination" + binary="$destination/linear-cli.exe" + test -f "$binary" + fi + done + + sha256sum release/linear-cli-* > release/SHA256SUMS + python3 - "$version" \<<'PY' + import hashlib + import json + import pathlib + import sys + + version = sys.argv[1] + targets = [] + for archive in sorted(pathlib.Path("release").glob("linear-cli-*.tar.gz")): + target = archive.name.removeprefix("linear-cli-").removesuffix(".tar.gz") + targets.append((target, archive)) + for archive in sorted(pathlib.Path("release").glob("linear-cli-*.zip")): + target = archive.name.removeprefix("linear-cli-").removesuffix(".zip") + targets.append((target, archive)) + manifest = { + "version": version, + "archives": [ + { + "target": target, + "file": archive.name, + "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), + } + for target, archive in sorted(targets) + ], + } + if len(manifest["archives"]) != 5: + raise SystemExit("manifest must contain exactly five archives") + pathlib.Path("release/release-manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + PY + test "$(find release -maxdepth 1 -type f | wc -l)" -eq 7 + - persist_to_workspace: + root: . + paths: + - release + - store_artifacts: + path: release + + publish-release: + machine: + image: ubuntu-2404:current + resource_class: small + steps: + - checkout + - attach_workspace: + at: . + - run: + name: Upload verified assets to GitHub Release + command: | + set -euo pipefail + : "${CIRCLE_TAG:?This job must run from a release tag}" + : "${GH_TOKEN:?Set GH_TOKEN in the release context}" + export GH_REPO="${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}" + if ! command -v gh >/dev/null 2>&1; then + type -p curl >/dev/null + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null + sudo apt-get update + sudo apt-get install -y gh + fi + if gh release view "$CIRCLE_TAG" >/dev/null 2>&1; then + gh release upload "$CIRCLE_TAG" release/* --clobber + else + gh release create "$CIRCLE_TAG" release/* --verify-tag --title "$CIRCLE_TAG" --generate-notes + fi + expected=( + SHA256SUMS + release-manifest.json + linear-cli-x86_64-unknown-linux-gnu.tar.gz + linear-cli-aarch64-unknown-linux-gnu.tar.gz + linear-cli-x86_64-pc-windows-msvc.zip + linear-cli-x86_64-apple-darwin.tar.gz + linear-cli-aarch64-apple-darwin.tar.gz + ) + actual="$(gh release view "$CIRCLE_TAG" --json assets --jq '.assets[].name' | sort)" + expected_sorted="$(printf '%s\n' "${expected[@]}" | sort)" + test "$actual" = "$expected_sorted" + + publish-crate: + machine: + image: ubuntu-2404:current + resource_class: small + steps: + - checkout + - install-rust + - run: + name: Publish the version proven by the release tag + command: | + set -euo pipefail + : "${CIRCLE_TAG:?This job must run from a release tag}" + : "${CARGO_REGISTRY_TOKEN:?Set CARGO_REGISTRY_TOKEN in the release context}" + version="${CIRCLE_TAG#v}" + cargo_version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" + test "$cargo_version" = "$version" + export PATH="$HOME/.cargo/bin:$PATH" + cargo publish --locked + +workflows: + ci: + jobs: + - test: + filters: + branches: + only: /.*/ + tags: + ignore: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + + release: + jobs: + - build-linux-x86: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - build-linux-arm: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - build-macos-x86: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - build-macos-arm: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - build-windows-x86: + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - verify-release: + requires: + - build-linux-x86 + - build-linux-arm + - build-macos-x86 + - build-macos-arm + - build-windows-x86 + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - publish-release: + requires: + - verify-release + context: linear-cli-release + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ + - publish-crate: + requires: + - publish-release + context: linear-cli-release + filters: + branches: + ignore: /.*/ + tags: + only: /^v[0-9]+\.[0-9]+\.[0-9]+$/ diff --git a/.github/CI.md b/.github/CI.md index 43519e1..ea9d64f 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -1,55 +1,42 @@ # CI Operator Guide -linear-cli shares a Blacksmith free-tier runner pool with sibling repos. This -guide tells an operator how to control who runs. +CircleCI is the canonical CI and release path. See +`.circleci/README.md` and `.circleci/config.yml` for the checked-in pipeline. -## Budget mode +## Branch and pull-request CI -Set the org/repo variable `CI_BUDGET_MODE` (Settings → Secrets and variables → -Actions → Variables). Unset/empty is treated as `normal`. +The CircleCI `ci` workflow runs locked tests with `secure-storage`, formatting, +clippy with warnings denied, and a default-feature build on Linux. It runs for +non-release refs. -| Mode | PR Check | Release (dispatch) | When to use | -|----------|----------|--------------------|-----------------------------------------------| -| `normal` | runs | runs | Default. A real gate on every PR. | -| `thin` | skipped | runs | Defer linear-cli so Win-CodexBar gets the pool.| -| `off` | skipped | skipped | Pause all CI for this repo. | +## Tagged releases -- PR Check `if`: `vars.CI_BUDGET_MODE != 'off' && vars.CI_BUDGET_MODE != 'thin'` -- Release `if`: `vars.CI_BUDGET_MODE != 'off'` +Push a tag matching `vX.Y.Z`. CircleCI builds these five archives: -## Intended split +- `x86_64-unknown-linux-gnu` +- `aarch64-unknown-linux-gnu` +- `x86_64-pc-windows-msvc` +- `x86_64-apple-darwin` +- `aarch64-apple-darwin` -The shared Blacksmith free tier is ~3000 runner-minutes/month. The intended -share of that pool across repos is roughly **60 / 30 / 10**: -- **~60%** → Win-CodexBar (the priority repo). -- **~30%** → linear-cli. -- **~10%** → buffer for spikes and overruns. +The release verifier fails closed unless the tag matches `Cargo.toml`, all +five archives are present, each archive contains the expected binary, and each +binary reports the tagged version. The GitHub release upload and crates.io +publish jobs run only after this gate. -This is a share of pool minutes per repo, **not** calendar time spent in each -`CI_BUDGET_MODE`. The budget mode is the knob that holds linear-cli near its -~30%: `thin` skips the **entire** linear-cli PR Check job (not individual -matrix legs), and `off` pauses all of this repo's CI. It is a planning target, -not a hard cap — move to `thin` whenever Win-CodexBar has open PRs competing -for the pool. +## Credentials and plan prerequisites -## $0 spend alert +Create the restricted CircleCI context `linear-cli-release` with: -Blacksmith bills the free pool per runner-minute, and **Windows bills ~2x -Linux**. PR Check is Linux-only and macOS release builds stay on GitHub-hosted -`macos-latest` specifically to avoid burning the Blacksmith pool. If you see -free-tier minutes dropping faster than the 60/30/10 plan accounts for, set -`CI_BUDGET_MODE=off` on the non-priority repo first. +- `GH_TOKEN`: permission to create or update releases in `nesszer/linear-cli`. +- `CARGO_REGISTRY_TOKEN`: permission to publish `linear-cli` on crates.io. -## Release +The macOS and Windows executors also need to be enabled for the CircleCI +organization/plan. The pipeline cannot prove that external project wiring or +executor entitlements exist from this repository alone. -Releases are **local by default**. See `docs/manual-release.md` for the -`cargo publish` + `gh release` sequence. The `release.yml` workflow is -**dispatch-only** (Actions → Release → Run workflow) and never auto-runs on a -tag or GitHub release. When dispatched: +## Legacy fallback -- Linux x86_64 / aarch64 → `blacksmith-4vcpu-ubuntu-2404` -- Windows x86_64 → `blacksmith-4vcpu-windows-2025` -- macOS x86_64 / aarch64 → `macos-latest` (GitHub-hosted, not Blacksmith) - -A dispatched release still runs in `thin` mode (operator intent overrides the -defer), but is skipped in `off`. +The GitHub Actions CI file is manual-dispatch only during the migration. The +old GitHub Actions release workflow is disabled so it cannot publish a partial +or incorrectly tagged asset set. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea10edf..102ce21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,25 +1,12 @@ name: CI -# Thin PR Check for the linear-cli repo. Runs only on Blacksmith's Linux pool -# so the shared Blacksmith free tier is not burned by macOS/Windows builds. -# See .github/CI.md and docs/adr/0001-pr-check-blacksmith-local-release.md for -# why this is Linux-only and how the budget modes gate it. +# Legacy fallback for the linear-cli repo. CircleCI is canonical; this workflow +# is intentionally manual-dispatch only during the migration. on: - push: - branches: [master, main] - paths-ignore: - - "docs/**" - - "skills/**" - - "**/*.md" - - "LICENSE" - pull_request: - branches: [master, main] - paths-ignore: - - "docs/**" - - "skills/**" - - "**/*.md" - - "LICENSE" + # CircleCI is canonical; keep this as an explicitly dispatched fallback + # while branch-protection checks are migrated. + workflow_dispatch: concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 7bc5096..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Release - -# Dispatch-only release. Never auto-runs on tag/release so the shared -# Blacksmith free tier is not consumed by surprise release builds. The -# workflow stays local-by-default: an operator dispatches it after a manual -# `cargo publish`. See docs/adr/0001-pr-check-blacksmith-local-release.md. - -on: - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - name: Build ${{ matrix.target }} - runs-on: ${{ matrix.runner }} - # Budget gate: release only runs when not in `off` mode. `thin` still - # allows a manually dispatched release (operator intent), only PR Check - # honors `thin`. - if: vars.CI_BUDGET_MODE != 'off' - permissions: - contents: read - strategy: - matrix: - include: - - target: x86_64-pc-windows-msvc - runner: blacksmith-4vcpu-windows-2025 - ext: .exe - - target: x86_64-apple-darwin - runner: macos-latest - ext: "" - - target: aarch64-apple-darwin - runner: macos-latest - ext: "" - - target: x86_64-unknown-linux-gnu - runner: blacksmith-4vcpu-ubuntu-2404 - ext: "" - - target: aarch64-unknown-linux-gnu - runner: blacksmith-4vcpu-ubuntu-2404 - ext: "" - use_cross: true - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Linux keyring build deps - if: runner.os == 'Linux' && !matrix.use_cross - run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - with: - targets: ${{ matrix.target }} - - - name: Install cross - if: matrix.use_cross - run: cargo install cross --locked --version 0.2.5 - - - name: Build (cross) - if: matrix.use_cross - run: cross build --release --features secure-storage --target ${{ matrix.target }} - - - name: Build (native) - if: ${{ !matrix.use_cross }} - run: cargo build --release --features secure-storage --target ${{ matrix.target }} - - - name: Package (Unix) - if: runner.os != 'Windows' - run: | - cd target/${{ matrix.target }}/release - tar -czvf ../../../linear-cli-${{ matrix.target }}.tar.gz linear-cli${{ matrix.ext }} - - - name: Package (Windows) - if: runner.os == 'Windows' - run: | - cd target/${{ matrix.target }}/release - 7z a ../../../linear-cli-${{ matrix.target }}.zip linear-cli${{ matrix.ext }} - - - name: Upload artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: linear-cli-${{ matrix.target }} - path: linear-cli-${{ matrix.target }}.* - - upload: - name: Upload Release Assets - needs: build - runs-on: ubuntu-latest - if: vars.CI_BUDGET_MODE != 'off' - permissions: - contents: write - - steps: - - name: Download all artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - - - name: Upload to release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 - with: - files: | - linear-cli-*/linear-cli-* - - publish: - name: Publish to crates.io - needs: upload - runs-on: ubuntu-latest - if: vars.CI_BUDGET_MODE != 'off' - permissions: - contents: read - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable - - - name: Publish to crates.io - run: cargo publish --allow-dirty - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/CONTEXT.md b/CONTEXT.md index a01a725..d18125a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,20 +1,26 @@ -# Shared CI Budget +# CI and Release -linear-cli shares a Blacksmith free-tier runner pool with other repos (notably Win-CodexBar). To keep one repo from starving the others, CI runs are gated by a single org/repo variable, `CI_BUDGET_MODE`, and PR Check is trimmed to a single Linux job. Releases are local by default and the release workflow is dispatch-only. This file is the shared vocabulary so operators and contributors mean the same thing across repos. +CircleCI is the canonical CI and release system for linear-cli. The checked-in +configuration is `.circleci/config.yml`; it owns branch validation and tagged +release artifacts. GitHub Actions is retained only as an explicitly dispatched +fallback for CI while repository protection rules are migrated. ## Language -**PR Check**: The gate that runs on every push/PR. For linear-cli it is a single Linux job on `blacksmith-4vcpu-ubuntu-2404`: `cargo test`, `cargo fmt --check`, `cargo clippy` (all with `--features secure-storage`), plus one default-features `cargo build`. Windows/macOS are intentionally absent from PR Check to protect the shared pool. -_Avoid_: "the test matrix", "CI" (CI is the whole workflow, not just the gate). +**CI**: The CircleCI `ci` workflow runs the locked secure-storage test suite, +formatting check, clippy with warnings denied, and a default-feature build. -**Release**: Cutting and publishing a version — `cargo publish` plus GitHub release assets. For linear-cli this is local by default; the `release.yml` workflow exists only as a dispatch fallback and never auto-runs on tag/release. -_Avoid_: "deploy" (nothing is deployed; binaries are uploaded to a GitHub release). +**Release**: A semver tag matching `vX.Y.Z` starts five parallel target builds. +The release gate requires that the tag version equals `Cargo.toml`, that the +exact five archives exist, and that each binary reports the tagged version. +Only then are the GitHub release assets uploaded and the crate published. -**Blacksmith Pool**: The shared free tier of Blacksmith runners (`blacksmith-4vcpu-ubuntu-2404`, `blacksmith-4vcpu-windows-2025`) that linear-cli and sibling repos draw from. Windows builds bill ~2x against this pool versus Linux, which is why PR Check stays Linux-only and macOS release builds stay on GitHub-hosted `macos-latest`. -_Avoid_: "Blacksmith runners" without naming the shared-pool constraint; "the cluster". +**Target set**: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, +`x86_64-pc-windows-msvc`, `x86_64-apple-darwin`, and `aarch64-apple-darwin`. -**Local Release**: The default way to release — an operator runs `cargo publish` and `gh release create/upload` by hand (see `docs/manual-release.md`). The dispatch-only `release.yml` is a fallback, not the primary path. -_Avoid_: "manual release" interchangeably with the workflow; the workflow is "dispatch release". +**Release context**: The CircleCI context `linear-cli-release` must provide +`GH_TOKEN` for GitHub release uploads and `CARGO_REGISTRY_TOKEN` for crates.io. +Those credentials are used only by downstream release jobs after verification. -**Budget Mode**: The value of org/repo variable `CI_BUDGET_MODE` — `normal` (PR Check runs), `thin` (the **entire** linear-cli PR Check job is skipped — not individual matrix legs — so Win-CodexBar gets priority), `off` (all CI skips). Unset/empty is treated as `normal`. PR Check honors `thin`; a manually dispatched Release still runs in `thin` because the operator asked for it. The intended share of the ~3000 free pool minutes/month is roughly 60 / 30 / 10 (Win-CodexBar / linear-cli / buffer) — a per-repo minute share, not calendar time spent in each mode. -_Avoid_: "spend mode", "throttle". +**Manual fallback**: When CircleCI is unavailable, follow +`docs/manual-release.md` and preserve the same five-asset/version gate. diff --git a/Cargo.lock b/Cargo.lock index d32266a..14a0cf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,7 +1245,7 @@ dependencies = [ [[package]] name = "linear-cli" -version = "0.3.26" +version = "0.3.28" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index b84a3bc..f270f87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "linear-cli" -version = "0.3.26" +version = "0.3.28" edition = "2021" description = "A powerful CLI for Linear.app - manage issues, projects, cycles, and more from your terminal" authors = ["scwrcd"] license = "MIT" -repository = "https://github.com/Finesssee/linear-cli" -homepage = "https://github.com/Finesssee/linear-cli" +repository = "https://github.com/nesszer/linear-cli" +homepage = "https://github.com/nesszer/linear-cli" readme = "README.md" keywords = ["linear", "cli", "project-management", "issues", "productivity"] categories = ["command-line-utilities", "development-tools"] diff --git a/README.md b/README.md index 14d40c3..841a6d4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # linear-cli [![Crates.io](https://img.shields.io/crates/v/linear-cli)](https://crates.io/crates/linear-cli) -[![CI](https://github.com/Finesssee/linear-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Finesssee/linear-cli/actions/workflows/ci.yml) +[![CircleCI](https://dl.circleci.com/status-badge/img/gh/nesszer/linear-cli/tree/master.svg?style=shield)](https://app.circleci.com/pipelines/github/nesszer/linear-cli) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](https://www.rust-lang.org/) @@ -20,11 +20,11 @@ cargo install linear-cli cargo install linear-cli --features secure-storage # From source -git clone https://github.com/Finesssee/linear-cli.git +git clone https://github.com/nesszer/linear-cli.git cd linear-cli && cargo build --release ``` -Pre-built binaries for Linux (x86_64, aarch64), macOS (x86_64, aarch64), and Windows (x86_64) are available at [GitHub Releases](https://github.com/Finesssee/linear-cli/releases). [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) downloads these automatically. +Pre-built binaries for Linux (x86_64, aarch64), macOS (x86_64, aarch64), and Windows (x86_64) are available at [GitHub Releases](https://github.com/nesszer/linear-cli/releases). [cargo-binstall](https://github.com/cargo-bins/cargo-binstall) downloads these automatically. ## Updating @@ -595,10 +595,10 @@ linear-cli includes Agent Skills for AI coding assistants (Claude Code, Cursor, ```bash # Install all skills -npx skills add Finesssee/linear-cli +npx skills add nesszer/linear-cli # Install specific skill -npx skills add Finesssee/linear-cli --skill linear-workflow +npx skills add nesszer/linear-cli --skill linear-workflow ``` 38 skills covering issues, git, planning, organization, operations, tracking, and advanced API usage. Skills are 10-50x more token-efficient than MCP tools. See [docs/skills.md](docs/skills.md) for details. diff --git a/docs/ai-agents.md b/docs/ai-agents.md index 7cfa5fe..43e63d4 100644 --- a/docs/ai-agents.md +++ b/docs/ai-agents.md @@ -7,7 +7,7 @@ When using AI coding assistants (Claude Code, Cursor, Windsurf, Copilot, OpenAI The easiest way to integrate linear-cli with your AI agent: ```bash -npx skills add Finesssee/linear-cli +npx skills add nesszer/linear-cli ``` This installs **27 Agent Skills** covering all CLI features. Your agent automatically loads the right skill based on the task. diff --git a/docs/manual-release.md b/docs/manual-release.md index 750992d..c24774f 100644 --- a/docs/manual-release.md +++ b/docs/manual-release.md @@ -1,132 +1,91 @@ # Manual Release Guide -Use this guide when GitHub Actions is unavailable or when release assets need to be backfilled by hand. +This is the controlled fallback when CircleCI is unavailable. The same +fail-closed contract as `.circleci/config.yml` applies: never publish or +announce a release with a partial asset set. -## Rules +## Release contract -1. Publish the crate to crates.io before creating or updating the matching GitHub release. -2. Only attach binaries built from the exact source for that version tag. -3. Keep Windows release assets on `x86_64-pc-windows-msvc` so `cargo-binstall` metadata stays correct. +1. Set `Cargo.toml` and `Cargo.lock` to the intended version, for example + `0.3.28`. +2. Create and push the matching tag, for example `v0.3.28`, from the exact + source to be released. +3. Build exactly these archives: -## Version Order + - `linear-cli-x86_64-unknown-linux-gnu.tar.gz` + - `linear-cli-aarch64-unknown-linux-gnu.tar.gz` + - `linear-cli-x86_64-pc-windows-msvc.zip` + - `linear-cli-x86_64-apple-darwin.tar.gz` + - `linear-cli-aarch64-apple-darwin.tar.gz` -- Repair a broken old release from its exact tagged source. -- Cut the next release from the current branch only after the old release is consistent again. +4. Verify every binary with `linear-cli --version`, and verify that its output + equals `linear-cli <version>`. +5. Create/upload the GitHub release only after all five artifacts pass. +6. Publish the same version to crates.io with `cargo publish --locked`. -## Repairing `v0.3.16` +The Windows archive must contain `linear-cli.exe` at its archive root. The +other archives must contain `linear-cli` at their archive root. -Build from commit `84f522199e1a5c9332fca76ccefeae924c92115e`. +## Build commands -### Linux x86_64 - -```bash -cargo build --release --target x86_64-unknown-linux-gnu -tar -C target/x86_64-unknown-linux-gnu/release -czf linear-cli-x86_64-unknown-linux-gnu.tar.gz linear-cli -``` - -### Linux aarch64 - -`v0.3.16` still uses OpenSSL-backed TLS, so build it with a temporary `Cross.toml` instead of editing the old tag: - -```toml -[target.aarch64-unknown-linux-gnu] -pre-build = [ - "dpkg --add-architecture $CROSS_DEB_ARCH", - "apt-get update && apt-get --assume-yes install libssl-dev:$CROSS_DEB_ARCH" -] -``` - -```bash -CROSS_CONFIG=/absolute/path/to/Cross.toml cross build --release --target aarch64-unknown-linux-gnu -tar -C target/aarch64-unknown-linux-gnu/release -czf linear-cli-aarch64-unknown-linux-gnu.tar.gz linear-cli -``` - -### Windows x86_64 (MSVC) - -On Ubuntu, `cargo xwin` also needs LLVM's MSVC-compatible entrypoints available on `PATH`: +Run these commands from the release tag. Linux builds need `libdbus-1-dev` and +`pkg-config`; the repository `Cross.toml` supplies the aarch64 Linux setup. ```bash sudo apt-get update -sudo apt-get install -y clang lld -sudo ln -sf /usr/bin/clang-18 /usr/local/bin/clang-cl -sudo ln -sf /usr/bin/llvm-lib-18 /usr/local/bin/llvm-lib -sudo ln -sf /usr/bin/llvm-ar-18 /usr/local/bin/llvm-ar -``` - -```bash -rustup target add x86_64-pc-windows-msvc -cargo xwin build --release --target x86_64-pc-windows-msvc -cd target/x86_64-pc-windows-msvc/release && 7z a ../../../linear-cli-x86_64-pc-windows-msvc.zip linear-cli.exe +sudo apt-get install -y libdbus-1-dev pkg-config + +cargo build --locked --release --features secure-storage \ + --target x86_64-unknown-linux-gnu +tar -C target/x86_64-unknown-linux-gnu/release -czf \ + linear-cli-x86_64-unknown-linux-gnu.tar.gz linear-cli + +cargo install cross --locked --version 0.2.5 +cross build --locked --release --features secure-storage \ + --target aarch64-unknown-linux-gnu +tar -C target/aarch64-unknown-linux-gnu/release -czf \ + linear-cli-aarch64-unknown-linux-gnu.tar.gz linear-cli ``` -### macOS on a real Mac +Build the two Apple targets on a macOS host: ```bash rustup target add x86_64-apple-darwin aarch64-apple-darwin -cargo build --release --target x86_64-apple-darwin -tar -C target/x86_64-apple-darwin/release -czf linear-cli-x86_64-apple-darwin.tar.gz linear-cli - -cargo build --release --target aarch64-apple-darwin -tar -C target/aarch64-apple-darwin/release -czf linear-cli-aarch64-apple-darwin.tar.gz linear-cli -``` - -### Publish and upload - -```bash -cargo publish -gh release upload v0.3.16 \ - linear-cli-x86_64-unknown-linux-gnu.tar.gz \ - linear-cli-aarch64-unknown-linux-gnu.tar.gz \ - linear-cli-x86_64-pc-windows-msvc.zip \ - linear-cli-x86_64-apple-darwin.tar.gz \ - linear-cli-aarch64-apple-darwin.tar.gz +cargo build --locked --release --features secure-storage --target x86_64-apple-darwin +tar -C target/x86_64-apple-darwin/release -czf \ + linear-cli-x86_64-apple-darwin.tar.gz linear-cli +cargo build --locked --release --features secure-storage --target aarch64-apple-darwin +tar -C target/aarch64-apple-darwin/release -czf \ + linear-cli-aarch64-apple-darwin.tar.gz linear-cli ``` -Confirm crates.io shows `0.3.16` before moving on. - -## Releasing `v0.3.17` and newer - -Current `master` uses `reqwest` with `rustls`. Official binaries should build with -`--features secure-storage` so Keychain / Credential Manager / Secret Service work. +Build the Windows target on a Windows host with the MSVC toolchain: -Linux builds need `libdbus-1-dev` (and `pkg-config`). For aarch64 cross builds, use the -repo-root `Cross.toml` which installs `libdbus-1-dev:$CROSS_DEB_ARCH`. - -### Local builds - -```bash -sudo apt-get install -y libdbus-1-dev pkg-config # Linux hosts only - -cargo build --release --features secure-storage --target x86_64-unknown-linux-gnu -tar -C target/x86_64-unknown-linux-gnu/release -czf linear-cli-x86_64-unknown-linux-gnu.tar.gz linear-cli - -cross build --release --features secure-storage --target aarch64-unknown-linux-gnu -tar -C target/aarch64-unknown-linux-gnu/release -czf linear-cli-aarch64-unknown-linux-gnu.tar.gz linear-cli - -cargo xwin build --release --features secure-storage --target x86_64-pc-windows-msvc -cd target/x86_64-pc-windows-msvc/release && 7z a ../../../linear-cli-x86_64-pc-windows-msvc.zip linear-cli.exe +```powershell +rustup target add x86_64-pc-windows-msvc +cargo build --locked --release --features secure-storage --target x86_64-pc-windows-msvc +Compress-Archive -Path target/x86_64-pc-windows-msvc/release/linear-cli.exe ` + -DestinationPath linear-cli-x86_64-pc-windows-msvc.zip -Force ``` -Build both Apple targets on a Mac with `--features secure-storage` as well. +## Verify and upload -### Publish first, then release +Before uploading, check the archive names and versions manually, then create +or update the release with the exact tag: ```bash -cargo publish -gh release create v0.3.17 --title v0.3.17 --notes "Manual release." -gh release upload v0.3.17 \ - linear-cli-x86_64-unknown-linux-gnu.tar.gz \ - linear-cli-aarch64-unknown-linux-gnu.tar.gz \ - linear-cli-x86_64-pc-windows-msvc.zip \ - linear-cli-x86_64-apple-darwin.tar.gz \ - linear-cli-aarch64-apple-darwin.tar.gz +sha256sum linear-cli-* +gh release create v0.3.28 --verify-tag --title v0.3.28 --generate-notes +gh release upload v0.3.28 linear-cli-*.tar.gz linear-cli-*.zip --clobber +cargo publish --locked ``` -## Final Checks +Finally confirm both public surfaces show the same version: ```bash cargo search linear-cli --limit 1 -gh release view v0.3.17 +gh release view v0.3.28 ``` -The crates.io version and the GitHub release tag should match before announcing the release. +The crates.io version and the GitHub release tag must match before announcing +the release. diff --git a/docs/skills.md b/docs/skills.md index bba10f5..3ac54db 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -6,13 +6,13 @@ ```bash # Install all skills -npx skills add Finesssee/linear-cli +npx skills add nesszer/linear-cli # Install specific skill -npx skills add Finesssee/linear-cli --skill linear-list +npx skills add nesszer/linear-cli --skill linear-list # Install globally (available in all projects) -npx skills add Finesssee/linear-cli -g +npx skills add nesszer/linear-cli -g ``` ## Available Skills (38 total) @@ -154,5 +154,5 @@ npx skills update npx skills remove --skill linear-list # Remove all linear-cli skills -npx skills remove Finesssee/linear-cli +npx skills remove nesszer/linear-cli ``` diff --git a/src/api.rs b/src/api.rs index 0f092e0..46552af 100644 --- a/src/api.rs +++ b/src/api.rs @@ -14,11 +14,28 @@ use crate::error::{CliError, ErrorKind}; use crate::pagination::{paginate_nodes, PaginationOptions}; use crate::retry::{with_retry, RetryConfig}; use crate::text::is_uuid; -use std::sync::OnceLock; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + OnceLock, +}; const LINEAR_API_URL: &str = "https://api.linear.app/graphql"; const LINEAR_UPLOADS_HOST: &str = "uploads.linear.app"; +static DRY_RUN_MODE: AtomicBool = AtomicBool::new(false); + +/// Set the process-wide dry-run mode before dispatching a command. +pub fn set_dry_run(enabled: bool) { + DRY_RUN_MODE.store(enabled, Ordering::Relaxed); +} + +fn mutation_allowed(dry_run: bool) -> Result<()> { + if dry_run { + anyhow::bail!("--dry-run is not supported for this mutation; no changes were made"); + } + Ok(()) +} + /// Configuration for generic ID resolution struct ResolverConfig<'a> { cache_type: CacheType, @@ -747,6 +764,7 @@ impl LinearClient { } pub async fn mutate(&self, mutation: &str, variables: Option<Value>) -> Result<Value> { + mutation_allowed(DRY_RUN_MODE.load(Ordering::Relaxed))?; // Mutations must not be retried to avoid duplicate side effects self.query_once(mutation, variables).await } @@ -949,6 +967,12 @@ mod tests { assert_eq!(refined.kind, ErrorKind::NotFound); } + #[test] + fn dry_run_blocks_unhandled_mutations() { + let error = mutation_allowed(true).unwrap_err(); + assert!(error.to_string().contains("no changes were made")); + } + #[test] fn test_auth_state_api_key_header() { let state = AuthState::ApiKey("lin_api_key123".to_string()); diff --git a/src/commands/api.rs b/src/commands/api.rs index 6c139b9..628e78b 100644 --- a/src/commands/api.rs +++ b/src/commands/api.rs @@ -4,7 +4,7 @@ use serde_json::{json, Map, Value}; use std::io::{self, BufRead, IsTerminal}; use crate::api::LinearClient; -use crate::output::{print_json_owned, OutputOptions}; +use crate::output::{print_json_owned, reject_unsupported_dry_run, OutputOptions}; use crate::pagination::{paginate_nodes, PaginationOptions}; #[derive(Subcommand)] @@ -73,6 +73,7 @@ pub async fn handle(cmd: ApiCommands, output: &OutputOptions) -> Result<()> { .await } ApiCommands::Mutate { query, variables } => { + reject_unsupported_dry_run(output.dry_run, "api mutate")?; let resolved = resolve_query_source(query)?; run_mutate(&resolved, &variables, output).await } diff --git a/src/commands/import.rs b/src/commands/import.rs index d49b2a7..f923f48 100644 --- a/src/commands/import.rs +++ b/src/commands/import.rs @@ -43,12 +43,12 @@ pub async fn handle(cmd: ImportCommands, output: &OutputOptions) -> Result<()> { file, team, dry_run, - } => import_csv(&file, &team, dry_run, output).await, + } => import_csv(&file, &team, dry_run || output.dry_run, output).await, ImportCommands::Json { file, team, dry_run, - } => import_json(&file, &team, dry_run, output).await, + } => import_json(&file, &team, dry_run || output.dry_run, output).await, } } diff --git a/src/commands/issues.rs b/src/commands/issues.rs index 36f018f..5d2466f 100644 --- a/src/commands/issues.rs +++ b/src/commands/issues.rs @@ -1424,7 +1424,7 @@ async fn create_issue( println!(" Estimate: {}", e); } if let Some(ref p) = project { - println!(" Project: {}", p); + println!(" Project: {}", safe_terminal_value(p)); } } return Ok(()); diff --git a/src/commands/update.rs b/src/commands/update.rs index 8b416a6..0766c4d 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -12,7 +12,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::output::print_json_owned; use crate::{AgentOptions, OutputOptions}; -const RELEASE_API_URL: &str = "https://api.github.com/repos/Finesssee/linear-cli/releases/latest"; +const RELEASE_API_URL: &str = "https://api.github.com/repos/nesszer/linear-cli/releases/latest"; const UPDATE_CHECK_INTERVAL_SECONDS: u64 = 24 * 60 * 60; #[derive(Debug, Deserialize)] diff --git a/src/main.rs b/src/main.rs index 916026f..6dffe84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -935,6 +935,7 @@ fn main() -> Result<()> { async fn async_main() -> Result<i32> { let cli = Cli::parse(); + api::set_dry_run(cli.dry_run); if cli.no_color || cli.color_mode == ColorChoice::Never { colored::control::set_override(false); } else if cli.color_mode == ColorChoice::Always { @@ -1105,6 +1106,175 @@ async fn run_command( agent_opts: AgentOptions, retry: u32, ) -> Result<()> { + let unsupported_dry_run_command = match &command { + Commands::Attachments { + action: + attachments::AttachmentCommands::Create { .. } + | attachments::AttachmentCommands::Update { .. } + | attachments::AttachmentCommands::Delete { .. } + | attachments::AttachmentCommands::LinkUrl { .. }, + } => Some("attachments"), + Commands::Bulk { .. } => Some("bulk"), + Commands::Cycles { action } + if !matches!( + action, + cycles::CycleCommands::Update { .. } + | cycles::CycleCommands::List { .. } + | cycles::CycleCommands::Get { .. } + | cycles::CycleCommands::Current { .. } + ) => + { + Some("cycles") + } + Commands::Comments { + action: + comments::CommentCommands::Create { .. } + | comments::CommentCommands::Update { .. } + | comments::CommentCommands::Delete { .. }, + } => Some("comments"), + Commands::Favorites { + action: + favorites::FavoriteCommands::Add { .. } | favorites::FavoriteCommands::Remove { .. }, + } => Some("favorites"), + Commands::Labels { + action: + labels::LabelCommands::Create { .. } + | labels::LabelCommands::Delete { .. } + | labels::LabelCommands::Update { .. }, + } => Some("labels"), + Commands::Notifications { + action: + notifications::NotificationCommands::Read { .. } + | notifications::NotificationCommands::ReadAll + | notifications::NotificationCommands::Archive { .. } + | notifications::NotificationCommands::ArchiveAll, + } => Some("notifications"), + Commands::ProjectUpdates { + action: + project_updates::ProjectUpdateCommands::Create { .. } + | project_updates::ProjectUpdateCommands::Update { .. } + | project_updates::ProjectUpdateCommands::Archive { .. } + | project_updates::ProjectUpdateCommands::Unarchive { .. }, + } => Some("project-updates"), + Commands::Projects { action } + if !matches!( + action, + projects::ProjectCommands::Update { .. } + | projects::ProjectCommands::List { .. } + | projects::ProjectCommands::Get { .. } + | projects::ProjectCommands::Open { .. } + | projects::ProjectCommands::Members { .. } + ) => + { + Some("projects") + } + Commands::Documents { + action: documents::DocumentCommands::Create { .. }, + } => Some("documents"), + Commands::Roadmaps { + action: + roadmaps::RoadmapCommands::Create { .. } | roadmaps::RoadmapCommands::Delete { .. }, + } => Some("roadmaps"), + Commands::Initiatives { + action: + initiatives::InitiativeCommands::Create { .. } + | initiatives::InitiativeCommands::Delete { .. }, + } => Some("initiatives"), + Commands::Milestones { + action: commands::milestones::MilestoneCommands::Delete { .. }, + } => Some("milestones"), + Commands::Issues { action } + if !matches!( + action, + issues::IssueCommands::Create { .. } + | issues::IssueCommands::Update { .. } + | issues::IssueCommands::List { .. } + | issues::IssueCommands::Get { .. } + | issues::IssueCommands::Open { .. } + | issues::IssueCommands::Link { .. } + ) => + { + Some("issues") + } + Commands::Relations { action } + if !matches!(action, relations::RelationCommands::List { .. }) => + { + Some("relations") + } + Commands::Sprint { + action: sprint::SprintCommands::CarryOver { .. }, + } => Some("sprint"), + Commands::Teams { + action: + teams::TeamCommands::Create { .. } + | teams::TeamCommands::Update { .. } + | teams::TeamCommands::Delete { .. }, + } => Some("teams"), + Commands::Time { + action: + time::TimeCommands::Log { .. } + | time::TimeCommands::Delete { .. } + | time::TimeCommands::Update { .. }, + } => Some("time"), + Commands::Triage { + action: triage::TriageCommands::Claim { .. } | triage::TriageCommands::Snooze { .. }, + } => Some("triage"), + Commands::Api { + action: commands::api::ApiCommands::Mutate { .. }, + } => Some("api mutate"), + Commands::Interactive { .. } => Some("interactive"), + Commands::Git { + action: + git::GitCommands::Checkout { .. } + | git::GitCommands::Create { .. } + | git::GitCommands::Pr { .. }, + } => Some("git"), + Commands::Done { .. } => Some("done"), + Commands::Setup => Some("setup"), + Commands::Auth { action } if !matches!(action, auth::AuthCommands::Status { .. }) => { + Some("auth") + } + Commands::Cache { + action: commands::cache::CacheCommands::Clear { .. }, + } => Some("cache"), + Commands::Config { + action: + ConfigCommands::SetKey + | ConfigCommands::Set { .. } + | ConfigCommands::WorkspaceAdd { .. } + | ConfigCommands::WorkspaceSwitch { .. } + | ConfigCommands::WorkspaceRemove { .. }, + } => Some("config"), + Commands::Update { check: false } => Some("update"), + Commands::Doctor { fix: true, .. } => Some("doctor"), + Commands::Webhooks { + action: + webhooks::WebhookCommands::RotateSecret { .. } + | webhooks::WebhookCommands::Listen { .. }, + } => Some("webhooks"), + Commands::Templates { + action: + templates::TemplateCommands::Delete { .. } + | templates::TemplateCommands::RemoteCreate { .. } + | templates::TemplateCommands::RemoteUpdate { .. } + | templates::TemplateCommands::RemoteDelete { .. }, + } => Some("templates"), + Commands::Uploads { + action: uploads::UploadCommands::Fetch { file: Some(_), .. }, + } => Some("uploads"), + Commands::Export { + action: + export::ExportCommands::Csv { file: Some(_), .. } + | export::ExportCommands::Markdown { file: Some(_), .. } + | export::ExportCommands::Json { file: Some(_), .. } + | export::ExportCommands::ProjectsCsv { file: Some(_), .. }, + } => Some("export"), + _ => None, + }; + if let Some(command_name) = unsupported_dry_run_command { + output::reject_unsupported_dry_run(output.dry_run, command_name)?; + } + match command { Commands::Common => { println!("Common tasks:"); diff --git a/src/output.rs b/src/output.rs index d9b4cdb..f834dbf 100644 --- a/src/output.rs +++ b/src/output.rs @@ -85,6 +85,15 @@ impl OutputOptions { } } +/// Reject a mutation when the caller requested a preview but the command has +/// no implementation for producing one. +pub fn reject_unsupported_dry_run(dry_run: bool, command: &str) -> Result<()> { + if dry_run { + anyhow::bail!("--dry-run is not supported for `{command}`; no changes were made"); + } + Ok(()) +} + #[derive(Debug, Clone)] pub enum FilterOp { Eq, diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 5cde509..de51dc0 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1357,6 +1357,37 @@ fn test_dry_run_output() { ); } +#[test] +fn test_unsupported_dry_run_fails_closed_before_auth() { + let cases: [&[&str]; 2] = [ + &[ + "relations", + "add", + "LIN-1", + "-r", + "blocks", + "LIN-2", + "--dry-run", + ], + &["bulk", "assign", "me", "-i", "LIN-1", "--dry-run"], + ]; + + for args in cases { + let (code, stdout, stderr) = run_cli(args); + assert_ne!(code, 0, "unsupported dry-run must fail: {args:?}"); + let combined = format!("{stdout}\n{stderr}"); + assert!( + combined.contains("--dry-run is not supported") + && combined.contains("no changes were made"), + "dry-run rejection should be explicit: args={args:?}, output={combined:?}" + ); + assert!( + !combined.contains("No API key configured"), + "rejection should happen before auth: args={args:?}, output={combined:?}" + ); + } +} + #[test] fn test_json_output_format() { // --output json should be accepted without error on help From b1e4f1730ac7628ae014c339ce5810658e04789c Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:32:53 +0700 Subject: [PATCH 8/9] fix: harden CircleCI release gates --- .circleci/README.md | 5 +- .circleci/config.yml | 102 +++++-------------------- .circleci/release-manifest.py | 64 ++++++++++++++++ .circleci/verify-release.sh | 79 ++++++++++++++++++++ .github/CI.md | 8 +- CONTEXT.md | 4 +- docs/manual-release.md | 6 +- src/commands/api.rs | 136 ++++++++++++++++++++++++++++++++++ tests/cli_tests.rs | 8 +- 9 files changed, 320 insertions(+), 92 deletions(-) create mode 100644 .circleci/release-manifest.py create mode 100644 .circleci/verify-release.sh diff --git a/.circleci/README.md b/.circleci/README.md index 3a6add8..de86093 100644 --- a/.circleci/README.md +++ b/.circleci/README.md @@ -18,8 +18,9 @@ Push an annotated or lightweight tag matching `vX.Y.Z`. The release workflow: - `x86_64-apple-darwin` - `aarch64-apple-darwin` 2. Verifies that the tag version matches `Cargo.toml`. -3. Requires exactly those five archives, checks each binary's `--version`, - and generates `SHA256SUMS` plus `release-manifest.json`. +3. Requires exactly those five archives, checks their archive roots and target + formats, verifies native Linux/Windows binaries with `--version`, and + generates `SHA256SUMS` plus `release-manifest.json`. 4. Uploads the verified assets to the GitHub release. 5. Publishes the matching crate version to crates.io. diff --git a/.circleci/config.yml b/.circleci/config.yml index 52d20d4..f1b3791 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,8 +39,10 @@ commands: binary="target/$target/release/linear-cli" artifact_dir="artifacts/$target" test -x "$binary" - "$binary" --version + cargo_version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" + test -n "$cargo_version" mkdir -p "$artifact_dir" + printf 'linear-cli %s' "$cargo_version" > "$artifact_dir/version.txt" tar -C "target/$target/release" -czf "$artifact_dir/linear-cli-$target.tar.gz" linear-cli tar -tzf "$artifact_dir/linear-cli-$target.tar.gz" | grep -qx 'linear-cli' - persist_to_workspace: @@ -71,6 +73,9 @@ jobs: set -euo pipefail export PATH="$HOME/.cargo/bin:$PATH" cargo fmt --all -- --check + - run: + name: Check release verifier shell syntax + command: bash -n .circleci/verify-release.sh - run: name: Run clippy with warnings denied command: | @@ -99,6 +104,14 @@ jobs: export PATH="$HOME/.cargo/bin:$PATH" rustup target add x86_64-unknown-linux-gnu cargo build --locked --release --features secure-storage --target x86_64-unknown-linux-gnu + - run: + name: Verify native x86_64 Linux binary + command: | + set -euo pipefail + export PATH="$HOME/.cargo/bin:$PATH" + version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" + reported="$(target/x86_64-unknown-linux-gnu/release/linear-cli --version)" + test "$reported" = "linear-cli $version" - package-unix: target: x86_64-unknown-linux-gnu @@ -185,6 +198,10 @@ jobs: & $binary --version $artifactDir = "artifacts/$target" New-Item -ItemType Directory -Force -Path $artifactDir | Out-Null + $versionMatch = Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"' + if ($null -eq $versionMatch) { throw 'Cargo.toml version is missing' } + $version = $versionMatch.Matches[0].Groups[1].Value + [System.IO.File]::WriteAllText((Join-Path $artifactDir 'version.txt'), "linear-cli $version") $stage = Join-Path $env:TEMP "linear-cli-package-$PID" New-Item -ItemType Directory -Force -Path $stage | Out-Null Copy-Item -LiteralPath $binary -Destination (Join-Path $stage 'linear-cli.exe') @@ -209,92 +226,11 @@ jobs: resource_class: medium steps: - checkout - - install-rust - attach_workspace: at: . - run: name: Verify release tag, version, assets, and checksums - command: | - set -euo pipefail - : "${CIRCLE_TAG:?This job must run from a release tag}" - if [[ ! "$CIRCLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Unsupported release tag: $CIRCLE_TAG" >&2 - exit 1 - fi - version="${CIRCLE_TAG#v}" - cargo_version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" - test "$cargo_version" = "$version" - - expected=( - linear-cli-x86_64-unknown-linux-gnu.tar.gz - linear-cli-aarch64-unknown-linux-gnu.tar.gz - linear-cli-x86_64-pc-windows-msvc.zip - linear-cli-x86_64-apple-darwin.tar.gz - linear-cli-aarch64-apple-darwin.tar.gz - ) - mkdir -p release - for archive in "${expected[@]}"; do - found="$(find artifacts -type f -name "$archive" -print -quit)" - test -n "$found" - cp "$found" "release/$archive" - done - actual_count="$(find artifacts -type f \( -name 'linear-cli-*.tar.gz' -o -name 'linear-cli-*.zip' \) | wc -l)" - test "$actual_count" -eq "${#expected[@]}" - - extract_dir="$(mktemp -d)" - trap 'rm -rf "$extract_dir"' EXIT - for archive in "${expected[@]}"; do - target="${archive#linear-cli-}" - target="${target%.tar.gz}" - target="${target%.zip}" - destination="$extract_dir/$target" - mkdir -p "$destination" - if [[ "$archive" == *.tar.gz ]]; then - tar -xzf "release/$archive" -C "$destination" - binary="$destination/linear-cli" - test -x "$binary" - reported="$($binary --version)" - test "$reported" = "linear-cli $version" - else - unzip -q "release/$archive" -d "$destination" - binary="$destination/linear-cli.exe" - test -f "$binary" - fi - done - - sha256sum release/linear-cli-* > release/SHA256SUMS - python3 - "$version" \<<'PY' - import hashlib - import json - import pathlib - import sys - - version = sys.argv[1] - targets = [] - for archive in sorted(pathlib.Path("release").glob("linear-cli-*.tar.gz")): - target = archive.name.removeprefix("linear-cli-").removesuffix(".tar.gz") - targets.append((target, archive)) - for archive in sorted(pathlib.Path("release").glob("linear-cli-*.zip")): - target = archive.name.removeprefix("linear-cli-").removesuffix(".zip") - targets.append((target, archive)) - manifest = { - "version": version, - "archives": [ - { - "target": target, - "file": archive.name, - "sha256": hashlib.sha256(archive.read_bytes()).hexdigest(), - } - for target, archive in sorted(targets) - ], - } - if len(manifest["archives"]) != 5: - raise SystemExit("manifest must contain exactly five archives") - pathlib.Path("release/release-manifest.json").write_text( - json.dumps(manifest, indent=2) + "\n", encoding="utf-8" - ) - PY - test "$(find release -maxdepth 1 -type f | wc -l)" -eq 7 + command: bash .circleci/verify-release.sh - persist_to_workspace: root: . paths: diff --git a/.circleci/release-manifest.py b/.circleci/release-manifest.py new file mode 100644 index 0000000..904e090 --- /dev/null +++ b/.circleci/release-manifest.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Generate the exact five-target release manifest.""" + +import hashlib +import json +import pathlib +import sys + + +EXPECTED_ARCHIVES = sorted( + [ + "linear-cli-x86_64-unknown-linux-gnu.tar.gz", + "linear-cli-aarch64-unknown-linux-gnu.tar.gz", + "linear-cli-x86_64-pc-windows-msvc.zip", + "linear-cli-x86_64-apple-darwin.tar.gz", + "linear-cli-aarch64-apple-darwin.tar.gz", + ] +) + + +def target_for(name: str) -> str: + prefix = "linear-cli-" + if name.endswith(".tar.gz"): + suffix = ".tar.gz" + elif name.endswith(".zip"): + suffix = ".zip" + else: + raise ValueError(f"unsupported archive: {name}") + return name[len(prefix) : -len(suffix)] + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("usage: release-manifest.py VERSION") + + version = sys.argv[1] + release_dir = pathlib.Path("release") + archives = sorted( + path.name + for path in release_dir.iterdir() + if path.is_file() and (path.name.endswith(".tar.gz") or path.name.endswith(".zip")) + ) + if archives != EXPECTED_ARCHIVES: + raise SystemExit(f"release archives do not match expected set: {archives!r}") + + manifest = { + "version": version, + "archives": [ + { + "target": target_for(name), + "file": name, + "sha256": hashlib.sha256((release_dir / name).read_bytes()).hexdigest(), + } + for name in EXPECTED_ARCHIVES + ], + } + (release_dir / "release-manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.circleci/verify-release.sh b/.circleci/verify-release.sh new file mode 100644 index 0000000..eb10846 --- /dev/null +++ b/.circleci/verify-release.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${CIRCLE_TAG:?This job must run from a release tag}" +if [[ ! "$CIRCLE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Unsupported release tag: $CIRCLE_TAG" >&2 + exit 1 +fi + +version="${CIRCLE_TAG#v}" +cargo_version="$(awk -F'"' '/^version = "/ { print $2; exit }' Cargo.toml)" +test "$cargo_version" = "$version" + +expected=( + linear-cli-x86_64-unknown-linux-gnu.tar.gz + linear-cli-aarch64-unknown-linux-gnu.tar.gz + linear-cli-x86_64-pc-windows-msvc.zip + linear-cli-x86_64-apple-darwin.tar.gz + linear-cli-aarch64-apple-darwin.tar.gz +) +mkdir -p release +for archive in "${expected[@]}"; do + found="$(find artifacts -type f -name "$archive" -print -quit)" + test -n "$found" + cp -- "$found" "release/$archive" + + proof="$(dirname -- "$found")/version.txt" + test -f "$proof" + test "$(tr -d '\r\n' < "$proof")" = "linear-cli $version" +done + +actual_count="$(find artifacts -type f \( -name 'linear-cli-*.tar.gz' -o -name 'linear-cli-*.zip' \) | wc -l)" +test "$actual_count" -eq "${#expected[@]}" + +command -v file >/dev/null +extract_dir="$(mktemp -d)" +trap 'rm -rf -- "$extract_dir"' EXIT +for archive in "${expected[@]}"; do + target="${archive#linear-cli-}" + target="${target%.tar.gz}" + target="${target%.zip}" + destination="$extract_dir/$target" + mkdir -p "$destination" + if [[ "$archive" == *.tar.gz ]]; then + test "$(tar -tzf "release/$archive")" = "linear-cli" + tar -xzf "release/$archive" -C "$destination" + binary="$destination/linear-cli" + test -x "$binary" + file_description="$(file -b "$binary")" + case "$archive" in + linear-cli-x86_64-unknown-linux-gnu.tar.gz) + grep -Eq 'ELF 64-bit.*x86-64' <<<"$file_description" + reported="$($binary --version)" + test "$reported" = "linear-cli $version" + ;; + linear-cli-aarch64-unknown-linux-gnu.tar.gz) + grep -Eq 'ELF 64-bit.*(ARM aarch64|AArch64)' <<<"$file_description" + ;; + linear-cli-x86_64-apple-darwin.tar.gz) + grep -Eq 'Mach-O 64-bit.*x86_64' <<<"$file_description" + ;; + linear-cli-aarch64-apple-darwin.tar.gz) + grep -Eq 'Mach-O 64-bit.*(arm64|ARM64)' <<<"$file_description" + ;; + esac + else + test "$(unzip -Z1 "release/$archive" | tr -d '\r')" = "linear-cli.exe" + unzip -q "release/$archive" -d "$destination" + binary="$destination/linear-cli.exe" + test -f "$binary" + file_description="$(file -b "$binary")" + grep -Eq 'PE32\+ executable.*x86-64' <<<"$file_description" + fi +done + +sha256sum release/linear-cli-* > release/SHA256SUMS +test "$(wc -l < release/SHA256SUMS)" -eq "${#expected[@]}" +python3 .circleci/release-manifest.py "$version" +test "$(find release -maxdepth 1 -type f | wc -l)" -eq 7 diff --git a/.github/CI.md b/.github/CI.md index ea9d64f..125f21e 100644 --- a/.github/CI.md +++ b/.github/CI.md @@ -20,9 +20,11 @@ Push a tag matching `vX.Y.Z`. CircleCI builds these five archives: - `aarch64-apple-darwin` The release verifier fails closed unless the tag matches `Cargo.toml`, all -five archives are present, each archive contains the expected binary, and each -binary reports the tagged version. The GitHub release upload and crates.io -publish jobs run only after this gate. +five archives are present, each archive contains only the expected root binary, +the target formats match, and native binaries report the tagged version. +Cross-target jobs carry an independently checked version proof from the tagged +source. The GitHub release upload and crates.io publish jobs run only after +this gate. ## Credentials and plan prerequisites diff --git a/CONTEXT.md b/CONTEXT.md index d18125a..35cac52 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -12,7 +12,9 @@ formatting check, clippy with warnings denied, and a default-feature build. **Release**: A semver tag matching `vX.Y.Z` starts five parallel target builds. The release gate requires that the tag version equals `Cargo.toml`, that the -exact five archives exist, and that each binary reports the tagged version. +exact five archives exist, that each archive has the expected target format and +root binary, and that native binaries report the tagged version. Cross-target +jobs carry an independently checked version proof from their tagged source. Only then are the GitHub release assets uploaded and the crate published. **Target set**: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, diff --git a/docs/manual-release.md b/docs/manual-release.md index c24774f..d3d3948 100644 --- a/docs/manual-release.md +++ b/docs/manual-release.md @@ -18,8 +18,10 @@ announce a release with a partial asset set. - `linear-cli-x86_64-apple-darwin.tar.gz` - `linear-cli-aarch64-apple-darwin.tar.gz` -4. Verify every binary with `linear-cli --version`, and verify that its output - equals `linear-cli <version>`. +4. Verify native binaries with `linear-cli --version`, and verify that their + output equals `linear-cli <version>`. For cross-target builds, verify the + target format with `file` and preserve the version from the tagged + `Cargo.toml` build. 5. Create/upload the GitHub release only after all five artifacts pass. 6. Publish the same version to crates.io with `cargo publish --locked`. diff --git a/src/commands/api.rs b/src/commands/api.rs index 628e78b..0171145 100644 --- a/src/commands/api.rs +++ b/src/commands/api.rs @@ -62,6 +62,7 @@ pub async fn handle(cmd: ApiCommands, output: &OutputOptions) -> Result<()> { page_info_path, } => { let resolved = resolve_query_source(query)?; + reject_mutation_document_for_dry_run(&resolved, output.dry_run)?; run_query( &resolved, &variables, @@ -109,6 +110,106 @@ fn read_stdin() -> Result<String> { Ok(query) } +fn reject_mutation_document_for_dry_run(query: &str, dry_run: bool) -> Result<()> { + if dry_run && contains_top_level_mutation_operation(query) { + anyhow::bail!( + "--dry-run is not supported for mutation documents passed to `api query`; use `api mutate` without --dry-run; no changes were made" + ); + } + Ok(()) +} + +fn contains_top_level_mutation_operation(query: &str) -> bool { + let bytes = query.as_bytes(); + let mut index = 0; + let mut brace_depth: usize = 0; + let mut paren_depth: usize = 0; + let mut bracket_depth: usize = 0; + let mut previous_top_level_name: Option<String> = None; + + while index < bytes.len() { + match bytes[index] { + b'#' => { + while index < bytes.len() && bytes[index] != b'\n' { + index += 1; + } + } + b'"' => { + if bytes[index..].starts_with(b"\"\"\"") { + index += 3; + while index < bytes.len() { + if bytes[index..].starts_with(b"\"\"\"") { + index += 3; + break; + } + index += 1; + } + } else { + index += 1; + while index < bytes.len() { + match bytes[index] { + b'\\' => index = (index + 2).min(bytes.len()), + b'"' => { + index += 1; + break; + } + _ => index += 1, + } + } + } + } + b'{' => { + brace_depth += 1; + index += 1; + } + b'}' => { + brace_depth = brace_depth.saturating_sub(1); + index += 1; + } + b'(' => { + paren_depth += 1; + index += 1; + } + b')' => { + paren_depth = paren_depth.saturating_sub(1); + index += 1; + } + b'[' => { + bracket_depth += 1; + index += 1; + } + b']' => { + bracket_depth = bracket_depth.saturating_sub(1); + index += 1; + } + byte if byte.is_ascii_alphabetic() || byte == b'_' => { + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') + { + index += 1; + } + if brace_depth == 0 && paren_depth == 0 && bracket_depth == 0 { + let name = &query[start..index]; + if name == "mutation" + && !matches!( + previous_top_level_name.as_deref(), + Some("query") | Some("subscription") | Some("fragment") + ) + { + return true; + } + previous_top_level_name = Some(name.to_string()); + } + } + _ => index += 1, + } + } + + false +} + fn read_query(input: &str) -> Result<String> { if input == "-" { read_stdin() @@ -226,6 +327,41 @@ mod tests { assert!(result.is_none()); } + #[test] + fn dry_run_rejects_mutation_documents() { + let error = reject_mutation_document_for_dry_run( + "mutation CreateIssue { issueCreate(input: {}) { success } }", + true, + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("is not supported for mutation documents")); + } + + #[test] + fn dry_run_accepts_query_documents() { + assert!(reject_mutation_document_for_dry_run("query { viewer { id } }", true).is_ok()); + assert!(reject_mutation_document_for_dry_run("{ viewer { id } }", true).is_ok()); + } + + #[test] + fn mutation_word_in_query_text_is_not_an_operation() { + assert!( + reject_mutation_document_for_dry_run("query mutation { viewer { name } }", true) + .is_ok() + ); + assert!( + reject_mutation_document_for_dry_run("query { viewer { name } } # mutation", true) + .is_ok() + ); + assert!(reject_mutation_document_for_dry_run( + "query { viewer { name } note(text: \"mutation\") }", + true + ) + .is_ok()); + } + #[test] fn test_parse_variables_string() { let vars = vec!["name=hello".to_string()]; diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index de51dc0..cd0a78c 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1359,7 +1359,7 @@ fn test_dry_run_output() { #[test] fn test_unsupported_dry_run_fails_closed_before_auth() { - let cases: [&[&str]; 2] = [ + let cases: [&[&str]; 3] = [ &[ "relations", "add", @@ -1370,6 +1370,12 @@ fn test_unsupported_dry_run_fails_closed_before_auth() { "--dry-run", ], &["bulk", "assign", "me", "-i", "LIN-1", "--dry-run"], + &[ + "api", + "query", + "mutation { issueCreate(input: {}) { success } }", + "--dry-run", + ], ]; for args in cases { From f650af6705b2c9bcc68d62fd1dec65e84d473327 Mon Sep 17 00:00:00 2001 From: Finesssee <90105158+Finesssee@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:10:36 +0700 Subject: [PATCH 9/9] fix: finish thermo review blockers --- src/commands/comments.rs | 82 ++++++------- src/commands/documents.rs | 26 ++--- src/commands/issues.rs | 32 ++--- src/commands/templates.rs | 20 ++-- src/commands/views.rs | 26 ++--- src/dry_run.rs | 240 ++++++++++++++++++++++++++++++++++++++ src/main.rs | 172 +-------------------------- src/vcs.rs | 44 ++++++- 8 files changed, 378 insertions(+), 264 deletions(-) create mode 100644 src/dry_run.rs diff --git a/src/commands/comments.rs b/src/commands/comments.rs index 504f51a..1cbe6bb 100644 --- a/src/commands/comments.rs +++ b/src/commands/comments.rs @@ -357,47 +357,6 @@ async fn create_comment(issue_id: &str, body: &str, parent_id: Option<String>) - Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_safe_terminal_value_removes_escape_sequences() { - assert_eq!( - safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), - "badtitle" - ); - } - - #[test] - fn comments_status_ok_when_nothing_failed() { - assert!(comments_status(None, &[]).is_ok()); - } - - #[test] - fn comments_status_notfound_when_only_missing() { - let err = comments_status(None, &["LIN-9".to_string()]).unwrap_err(); - assert_eq!(err.downcast_ref::<CliError>().expect("CliError").code(), 2); - } - - #[test] - fn comments_status_preserves_real_error_over_missing() { - // A real fetch error keeps its kind (rate-limited => 4), not NotFound(2). - let err = comments_status( - Some( - CliError::rate_limited("429") - .with_retry_after(Some(5)) - .into(), - ), - &["LIN-9".to_string()], - ) - .unwrap_err(); - let cli = err.downcast_ref::<CliError>().expect("CliError"); - assert_eq!(cli.code(), 4); - assert_eq!(cli.retry_after, Some(5)); - } -} - async fn update_comment(id: &str, body: &str, output: &OutputOptions) -> Result<()> { let client = LinearClient::new()?; @@ -466,3 +425,44 @@ async fn delete_comment(id: &str, force: bool) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_terminal_value_removes_escape_sequences() { + assert_eq!( + safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), + "badtitle" + ); + } + + #[test] + fn comments_status_ok_when_nothing_failed() { + assert!(comments_status(None, &[]).is_ok()); + } + + #[test] + fn comments_status_notfound_when_only_missing() { + let err = comments_status(None, &["LIN-9".to_string()]).unwrap_err(); + assert_eq!(err.downcast_ref::<CliError>().expect("CliError").code(), 2); + } + + #[test] + fn comments_status_preserves_real_error_over_missing() { + // A real fetch error keeps its kind (rate-limited => 4), not NotFound(2). + let err = comments_status( + Some( + CliError::rate_limited("429") + .with_retry_after(Some(5)) + .into(), + ), + &["LIN-9".to_string()], + ) + .unwrap_err(); + let cli = err.downcast_ref::<CliError>().expect("CliError"); + assert_eq!(cli.code(), 4); + assert_eq!(cli.retry_after, Some(5)); + } +} diff --git a/src/commands/documents.rs b/src/commands/documents.rs index d7daa23..2eafc82 100644 --- a/src/commands/documents.rs +++ b/src/commands/documents.rs @@ -428,19 +428,6 @@ async fn create_document( Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_safe_terminal_value_removes_escape_sequences() { - assert_eq!( - safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), - "badtitle" - ); - } -} - #[allow(clippy::too_many_arguments)] async fn update_document( id: &str, @@ -579,3 +566,16 @@ async fn delete_document( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_terminal_value_removes_escape_sequences() { + assert_eq!( + safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), + "badtitle" + ); + } +} diff --git a/src/commands/issues.rs b/src/commands/issues.rs index 5d2466f..5dffa7d 100644 --- a/src/commands/issues.rs +++ b/src/commands/issues.rs @@ -1394,31 +1394,29 @@ async fn create_issue( )?; } else { println!("{}", "[DRY RUN] Would create issue:".yellow().bold()); - println!(" Title: {}", final_title); - println!(" Team: {} ({})", final_team, team_id); + println!(" Title: {}", safe_terminal_value(&final_title)); + println!( + " Team: {} ({})", + safe_terminal_value(final_team), + safe_terminal_value(&team_id) + ); if let Some(ref desc) = description { - let preview: String = desc.chars().take(50).collect(); - let preview = if preview.len() < desc.len() { - format!("{}...", preview) - } else { - preview - }; - println!(" Description: {}", preview); + println!(" Description: {}", truncate(desc, Some(50))); } if let Some(p) = priority { println!(" Priority: {}", p); } if let Some(ref s) = state { - println!(" State: {}", s); + println!(" State: {}", safe_terminal_value(s)); } if let Some(ref a) = assignee { - println!(" Assignee: {}", a); + println!(" Assignee: {}", safe_terminal_value(a)); } if !labels.is_empty() { - println!(" Labels: {}", labels.join(", ")); + println!(" Labels: {}", safe_terminal_value(&labels.join(", "))); } if let Some(ref d) = due { - println!(" Due: {}", d); + println!(" Due: {}", safe_terminal_value(d)); } if let Some(e) = estimate { println!(" Estimate: {}", e); @@ -2480,4 +2478,12 @@ mod tests { serde_json::json!({ "name": { "eqIgnoreCase": name } }) ); } + + #[test] + fn dry_run_text_values_strip_terminal_controls() { + assert_eq!( + safe_terminal_value("Q1\u{1b}]52;c;ZXZpbA==\u{7} Roadmap"), + "Q1 Roadmap" + ); + } } diff --git a/src/commands/templates.rs b/src/commands/templates.rs index ff82771..39d6a9f 100644 --- a/src/commands/templates.rs +++ b/src/commands/templates.rs @@ -744,16 +744,6 @@ async fn remote_get_template(id: &str, output: &OutputOptions) -> Result<()> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_safe_terminal_value_removes_escape_sequences() { - assert_eq!(safe_terminal_value("bad\u{1b}[31mname\u{1b}[0m"), "badname"); - } -} - async fn remote_create_template( name: &str, template_type: &str, @@ -904,3 +894,13 @@ async fn remote_delete_template(id: &str, force: bool, output: &OutputOptions) - Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_terminal_value_removes_escape_sequences() { + assert_eq!(safe_terminal_value("bad\u{1b}[31mname\u{1b}[0m"), "badname"); + } +} diff --git a/src/commands/views.rs b/src/commands/views.rs index 39eef87..37228cf 100644 --- a/src/commands/views.rs +++ b/src/commands/views.rs @@ -605,19 +605,6 @@ pub async fn fetch_view_filter( Ok(filter.clone()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_safe_terminal_value_removes_escape_sequences() { - assert_eq!( - safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), - "badtitle" - ); - } -} - /// Fetch the projectFilterData for a custom view (used by projects list --view). pub async fn fetch_view_project_filter( client: &LinearClient, @@ -646,3 +633,16 @@ pub async fn fetch_view_project_filter( Ok(filter.clone()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_safe_terminal_value_removes_escape_sequences() { + assert_eq!( + safe_terminal_value("bad\u{1b}]52;c;ZXZpbA==\u{7}title"), + "badtitle" + ); + } +} diff --git a/src/dry_run.rs b/src/dry_run.rs new file mode 100644 index 0000000..17db842 --- /dev/null +++ b/src/dry_run.rs @@ -0,0 +1,240 @@ +//! Command-level dry-run policy. +//! +//! Commands that own a non-API side effect must reject `--dry-run` before +//! authentication or dispatch. API mutations have a second fail-closed guard +//! in [`crate::api::LinearClient::mutate`], so adding a new mutation cannot +//! accidentally turn a preview into a write if this audit list is missed. + +use crate::commands::{ + attachments, auth, comments, cycles, documents, export, favorites, git, initiatives, labels, + notifications, project_updates, projects, roadmaps, sprint, teams, templates, time, triage, + uploads, webhooks, +}; +use crate::{Commands, ConfigCommands}; + +/// Return the command name for a dry-run-incompatible operation. +/// +/// Keep this list explicit for operations that write local state, start an +/// interactive process, or otherwise have no preview. GraphQL mutations also +/// remain protected by the process-wide client guard, which is the defense in +/// depth for mutation paths added after this list was written. +pub(crate) fn unsupported_command(command: &Commands) -> Option<&'static str> { + match command { + Commands::Attachments { + action: + attachments::AttachmentCommands::Create { .. } + | attachments::AttachmentCommands::Update { .. } + | attachments::AttachmentCommands::Delete { .. } + | attachments::AttachmentCommands::LinkUrl { .. }, + } => Some("attachments"), + Commands::Bulk { .. } => Some("bulk"), + Commands::Cycles { action } + if !matches!( + action, + cycles::CycleCommands::Update { .. } + | cycles::CycleCommands::List { .. } + | cycles::CycleCommands::Get { .. } + | cycles::CycleCommands::Current { .. } + ) => + { + Some("cycles") + } + Commands::Comments { + action: + comments::CommentCommands::Create { .. } + | comments::CommentCommands::Update { .. } + | comments::CommentCommands::Delete { .. }, + } => Some("comments"), + Commands::Favorites { + action: + favorites::FavoriteCommands::Add { .. } | favorites::FavoriteCommands::Remove { .. }, + } => Some("favorites"), + Commands::Labels { + action: + labels::LabelCommands::Create { .. } + | labels::LabelCommands::Delete { .. } + | labels::LabelCommands::Update { .. }, + } => Some("labels"), + Commands::Notifications { + action: + notifications::NotificationCommands::Read { .. } + | notifications::NotificationCommands::ReadAll + | notifications::NotificationCommands::Archive { .. } + | notifications::NotificationCommands::ArchiveAll, + } => Some("notifications"), + Commands::ProjectUpdates { + action: + project_updates::ProjectUpdateCommands::Create { .. } + | project_updates::ProjectUpdateCommands::Update { .. } + | project_updates::ProjectUpdateCommands::Archive { .. } + | project_updates::ProjectUpdateCommands::Unarchive { .. }, + } => Some("project-updates"), + Commands::Projects { action } + if !matches!( + action, + projects::ProjectCommands::Update { .. } + | projects::ProjectCommands::List { .. } + | projects::ProjectCommands::Get { .. } + | projects::ProjectCommands::Open { .. } + | projects::ProjectCommands::Members { .. } + ) => + { + Some("projects") + } + Commands::Documents { + action: documents::DocumentCommands::Create { .. }, + } => Some("documents"), + Commands::Roadmaps { + action: + roadmaps::RoadmapCommands::Create { .. } | roadmaps::RoadmapCommands::Delete { .. }, + } => Some("roadmaps"), + Commands::Initiatives { + action: + initiatives::InitiativeCommands::Create { .. } + | initiatives::InitiativeCommands::Delete { .. }, + } => Some("initiatives"), + Commands::Milestones { + action: crate::commands::milestones::MilestoneCommands::Delete { .. }, + } => Some("milestones"), + Commands::Issues { action } + if !matches!( + action, + crate::commands::issues::IssueCommands::Create { .. } + | crate::commands::issues::IssueCommands::Update { .. } + | crate::commands::issues::IssueCommands::List { .. } + | crate::commands::issues::IssueCommands::Get { .. } + | crate::commands::issues::IssueCommands::Open { .. } + | crate::commands::issues::IssueCommands::Link { .. } + ) => + { + Some("issues") + } + Commands::Relations { action } + if !matches!( + action, + crate::commands::relations::RelationCommands::List { .. } + ) => + { + Some("relations") + } + Commands::Sprint { + action: sprint::SprintCommands::CarryOver { .. }, + } => Some("sprint"), + Commands::Teams { + action: + teams::TeamCommands::Create { .. } + | teams::TeamCommands::Update { .. } + | teams::TeamCommands::Delete { .. }, + } => Some("teams"), + Commands::Time { + action: + time::TimeCommands::Log { .. } + | time::TimeCommands::Delete { .. } + | time::TimeCommands::Update { .. }, + } => Some("time"), + Commands::Triage { + action: triage::TriageCommands::Claim { .. } | triage::TriageCommands::Snooze { .. }, + } => Some("triage"), + Commands::Api { + action: crate::commands::api::ApiCommands::Mutate { .. }, + } => Some("api mutate"), + Commands::Interactive { .. } => Some("interactive"), + Commands::Git { + action: + git::GitCommands::Checkout { .. } + | git::GitCommands::Create { .. } + | git::GitCommands::Pr { .. }, + } => Some("git"), + Commands::Done { .. } => Some("done"), + Commands::Setup => Some("setup"), + Commands::Auth { action } if !matches!(action, auth::AuthCommands::Status { .. }) => { + Some("auth") + } + Commands::Cache { + action: crate::commands::cache::CacheCommands::Clear { .. }, + } => Some("cache"), + Commands::Config { + action: + ConfigCommands::SetKey + | ConfigCommands::Set { .. } + | ConfigCommands::WorkspaceAdd { .. } + | ConfigCommands::WorkspaceSwitch { .. } + | ConfigCommands::WorkspaceRemove { .. }, + } => Some("config"), + Commands::Update { check: false } => Some("update"), + Commands::Doctor { fix: true, .. } => Some("doctor"), + Commands::Webhooks { + action: + webhooks::WebhookCommands::RotateSecret { .. } + | webhooks::WebhookCommands::Listen { .. }, + } => Some("webhooks"), + Commands::Templates { + action: + templates::TemplateCommands::Delete { .. } + | templates::TemplateCommands::RemoteCreate { .. } + | templates::TemplateCommands::RemoteUpdate { .. } + | templates::TemplateCommands::RemoteDelete { .. }, + } => Some("templates"), + Commands::Uploads { + action: uploads::UploadCommands::Fetch { file: Some(_), .. }, + } => Some("uploads"), + Commands::Export { + action: + export::ExportCommands::Csv { file: Some(_), .. } + | export::ExportCommands::Markdown { file: Some(_), .. } + | export::ExportCommands::Json { file: Some(_), .. } + | export::ExportCommands::ProjectsCsv { file: Some(_), .. }, + } => Some("export"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::relations::RelationCommands; + + #[test] + fn mutation_families_are_rejected_before_dispatch() { + let cases = [ + ( + Commands::Bulk { + action: crate::commands::bulk::BulkCommands::Assign { + user: "me".to_string(), + issues: vec!["LIN-1".to_string()], + }, + }, + "bulk", + ), + ( + Commands::Relations { + action: RelationCommands::List { + id: "LIN-1".to_string(), + }, + }, + "", + ), + ( + Commands::Api { + action: crate::commands::api::ApiCommands::Mutate { + query: Some("mutation { noop }".to_string()), + variables: vec![], + }, + }, + "api mutate", + ), + ]; + + assert_eq!(unsupported_command(&cases[0].0), Some(cases[0].1)); + assert_eq!(unsupported_command(&cases[1].0), None); + assert_eq!(unsupported_command(&cases[2].0), Some(cases[2].1)); + } + + #[test] + fn read_only_variants_are_not_rejected() { + let command = Commands::Teams { + action: teams::TeamCommands::List, + }; + assert_eq!(unsupported_command(&command), None); + } +} diff --git a/src/main.rs b/src/main.rs index 6dffe84..aa697d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod cache; mod commands; mod config; mod dates; +mod dry_run; mod error; mod input; mod json_path; @@ -269,7 +270,7 @@ pub fn display_options() -> DisplayOptions { } #[derive(Subcommand)] -enum Commands { +pub(crate) enum Commands { /// Show common tasks and examples #[command(alias = "tasks")] Common, @@ -776,7 +777,7 @@ ENV OVERRIDES: } #[derive(Subcommand)] -enum ConfigCommands { +pub(crate) enum ConfigCommands { /// Set API key #[command(after_help = r#"EXAMPLE: printf '%s\n' "$LINEAR_API_KEY" | linear config set-key"#)] @@ -1106,172 +1107,7 @@ async fn run_command( agent_opts: AgentOptions, retry: u32, ) -> Result<()> { - let unsupported_dry_run_command = match &command { - Commands::Attachments { - action: - attachments::AttachmentCommands::Create { .. } - | attachments::AttachmentCommands::Update { .. } - | attachments::AttachmentCommands::Delete { .. } - | attachments::AttachmentCommands::LinkUrl { .. }, - } => Some("attachments"), - Commands::Bulk { .. } => Some("bulk"), - Commands::Cycles { action } - if !matches!( - action, - cycles::CycleCommands::Update { .. } - | cycles::CycleCommands::List { .. } - | cycles::CycleCommands::Get { .. } - | cycles::CycleCommands::Current { .. } - ) => - { - Some("cycles") - } - Commands::Comments { - action: - comments::CommentCommands::Create { .. } - | comments::CommentCommands::Update { .. } - | comments::CommentCommands::Delete { .. }, - } => Some("comments"), - Commands::Favorites { - action: - favorites::FavoriteCommands::Add { .. } | favorites::FavoriteCommands::Remove { .. }, - } => Some("favorites"), - Commands::Labels { - action: - labels::LabelCommands::Create { .. } - | labels::LabelCommands::Delete { .. } - | labels::LabelCommands::Update { .. }, - } => Some("labels"), - Commands::Notifications { - action: - notifications::NotificationCommands::Read { .. } - | notifications::NotificationCommands::ReadAll - | notifications::NotificationCommands::Archive { .. } - | notifications::NotificationCommands::ArchiveAll, - } => Some("notifications"), - Commands::ProjectUpdates { - action: - project_updates::ProjectUpdateCommands::Create { .. } - | project_updates::ProjectUpdateCommands::Update { .. } - | project_updates::ProjectUpdateCommands::Archive { .. } - | project_updates::ProjectUpdateCommands::Unarchive { .. }, - } => Some("project-updates"), - Commands::Projects { action } - if !matches!( - action, - projects::ProjectCommands::Update { .. } - | projects::ProjectCommands::List { .. } - | projects::ProjectCommands::Get { .. } - | projects::ProjectCommands::Open { .. } - | projects::ProjectCommands::Members { .. } - ) => - { - Some("projects") - } - Commands::Documents { - action: documents::DocumentCommands::Create { .. }, - } => Some("documents"), - Commands::Roadmaps { - action: - roadmaps::RoadmapCommands::Create { .. } | roadmaps::RoadmapCommands::Delete { .. }, - } => Some("roadmaps"), - Commands::Initiatives { - action: - initiatives::InitiativeCommands::Create { .. } - | initiatives::InitiativeCommands::Delete { .. }, - } => Some("initiatives"), - Commands::Milestones { - action: commands::milestones::MilestoneCommands::Delete { .. }, - } => Some("milestones"), - Commands::Issues { action } - if !matches!( - action, - issues::IssueCommands::Create { .. } - | issues::IssueCommands::Update { .. } - | issues::IssueCommands::List { .. } - | issues::IssueCommands::Get { .. } - | issues::IssueCommands::Open { .. } - | issues::IssueCommands::Link { .. } - ) => - { - Some("issues") - } - Commands::Relations { action } - if !matches!(action, relations::RelationCommands::List { .. }) => - { - Some("relations") - } - Commands::Sprint { - action: sprint::SprintCommands::CarryOver { .. }, - } => Some("sprint"), - Commands::Teams { - action: - teams::TeamCommands::Create { .. } - | teams::TeamCommands::Update { .. } - | teams::TeamCommands::Delete { .. }, - } => Some("teams"), - Commands::Time { - action: - time::TimeCommands::Log { .. } - | time::TimeCommands::Delete { .. } - | time::TimeCommands::Update { .. }, - } => Some("time"), - Commands::Triage { - action: triage::TriageCommands::Claim { .. } | triage::TriageCommands::Snooze { .. }, - } => Some("triage"), - Commands::Api { - action: commands::api::ApiCommands::Mutate { .. }, - } => Some("api mutate"), - Commands::Interactive { .. } => Some("interactive"), - Commands::Git { - action: - git::GitCommands::Checkout { .. } - | git::GitCommands::Create { .. } - | git::GitCommands::Pr { .. }, - } => Some("git"), - Commands::Done { .. } => Some("done"), - Commands::Setup => Some("setup"), - Commands::Auth { action } if !matches!(action, auth::AuthCommands::Status { .. }) => { - Some("auth") - } - Commands::Cache { - action: commands::cache::CacheCommands::Clear { .. }, - } => Some("cache"), - Commands::Config { - action: - ConfigCommands::SetKey - | ConfigCommands::Set { .. } - | ConfigCommands::WorkspaceAdd { .. } - | ConfigCommands::WorkspaceSwitch { .. } - | ConfigCommands::WorkspaceRemove { .. }, - } => Some("config"), - Commands::Update { check: false } => Some("update"), - Commands::Doctor { fix: true, .. } => Some("doctor"), - Commands::Webhooks { - action: - webhooks::WebhookCommands::RotateSecret { .. } - | webhooks::WebhookCommands::Listen { .. }, - } => Some("webhooks"), - Commands::Templates { - action: - templates::TemplateCommands::Delete { .. } - | templates::TemplateCommands::RemoteCreate { .. } - | templates::TemplateCommands::RemoteUpdate { .. } - | templates::TemplateCommands::RemoteDelete { .. }, - } => Some("templates"), - Commands::Uploads { - action: uploads::UploadCommands::Fetch { file: Some(_), .. }, - } => Some("uploads"), - Commands::Export { - action: - export::ExportCommands::Csv { file: Some(_), .. } - | export::ExportCommands::Markdown { file: Some(_), .. } - | export::ExportCommands::Json { file: Some(_), .. } - | export::ExportCommands::ProjectsCsv { file: Some(_), .. }, - } => Some("export"), - _ => None, - }; - if let Some(command_name) = unsupported_dry_run_command { + if let Some(command_name) = dry_run::unsupported_command(&command) { output::reject_unsupported_dry_run(output.dry_run, command_name)?; } diff --git a/src/vcs.rs b/src/vcs.rs index 831ab98..e78d38b 100644 --- a/src/vcs.rs +++ b/src/vcs.rs @@ -48,6 +48,8 @@ pub fn git_branch_exists(branch: &str) -> bool { } pub fn generate_branch_name(identifier: &str, title: &str) -> String { + const MAX_SLUG_CHARS: usize = 50; + // Convert title to kebab-case for branch name let slug: String = title .to_lowercase() @@ -59,12 +61,42 @@ pub fn generate_branch_name(identifier: &str, title: &str) -> String { .collect::<Vec<_>>() .join("-"); - // Truncate if too long - let slug = if slug.len() > 50 { - slug[..50].trim_end_matches('-').to_string() - } else { - slug - }; + // Truncate on character boundaries. A title can contain multibyte + // characters even though the generated slug is otherwise ASCII today. + let slug: String = slug + .chars() + .take(MAX_SLUG_CHARS) + .collect::<String>() + .trim_end_matches('-') + .to_string(); + let slug = if slug.is_empty() { "update" } else { &slug }; format!("{}/{}", identifier.to_lowercase(), slug) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn long_titles_are_truncated_without_invalid_boundaries() { + let branch = generate_branch_name("LIN-1", &"fix ".repeat(20)); + let slug = branch.strip_prefix("lin-1/").expect("identifier prefix"); + assert!(slug.chars().count() <= 50); + assert!(!slug.ends_with('-')); + assert!(validate_branch_name(&branch).is_ok()); + } + + #[test] + fn multibyte_titles_do_not_panic_when_truncated() { + let branch = generate_branch_name("LIN-2", &"界".repeat(60)); + assert_eq!(branch, format!("lin-2/{}", "界".repeat(50))); + } + + #[test] + fn punctuation_only_titles_get_a_valid_fallback_slug() { + let branch = generate_branch_name("LIN-3", "!!! --- ???"); + assert_eq!(branch, "lin-3/update"); + assert!(validate_branch_name(&branch).is_ok()); + } +}