From 40292317c756c034d19c8075f53cd57b3f7d0371 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Thu, 22 Oct 2026 14:39:29 -0700 Subject: [PATCH] feat: View multiple issues at once Accept repeated issue references to inspect several issues in one invocation. Preserve argument order and complete detail pagination, combine human output in one pager, and return a JSON array for multiple issues while keeping the single-issue object. Resolve every input before opening or fetching, and fail without partial data output when any issue cannot be fetched. --- CHANGELOG.md | 4 + README.md | 2 + crates/linear-cli/src/cli/issue.rs | 14 +- crates/linear-cli/src/commands/issue/view.rs | 91 ++++- crates/linear-cli/src/commands/json.rs | 3 +- crates/linear-cli/tests/cli/issue_read.rs | 332 +++++++++++++++++++ crates/linear-cli/tests/cli/web.rs | 52 +++ skills/linear-cli/SKILL.md | 6 +- skills/linear-cli/SKILL.template.md | 6 +- skills/linear-cli/references/issue.md | 16 +- 10 files changed, 492 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13cfb29b..71c6ea70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- `issue view` accepts multiple issue IDs or URLs, showing them in argument order with one pager session. `--json` returns an array for multiple issues and keeps the existing object for one issue. If an issue cannot be fetched, the command fails without printing partial results. + ## [3.1.0] - 2026-10-22 ### Added diff --git a/README.md b/README.md index 8594123f..aaa41d1e 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ note that [Linear's GitHub integration](https://linear.app/docs/github#branch-fo linear issue view # view current issue details in terminal linear issue view ABC-123 linear issue view 123 +linear issue view ABC-123 ABC-124 # multiple issues, in argument order +linear issue view ABC-123 ABC-124 --json # an array; one issue returns an object linear issue view -w # open issue in web browser linear issue view -a # open issue in Linear.app linear issue id # prints the issue id from current branch (e.g., "ENG-123") diff --git a/crates/linear-cli/src/cli/issue.rs b/crates/linear-cli/src/cli/issue.rs index efa7c711..9c0e74b7 100644 --- a/crates/linear-cli/src/cli/issue.rs +++ b/crates/linear-cli/src/cli/issue.rs @@ -21,7 +21,7 @@ pub enum IssueCommand { /// Find issues by filters or full-text search #[command(alias = "q")] Query(IssueQuery), - /// Show an issue + /// Show one or more issues #[command(alias = "v")] View(IssueView), /// Create an issue @@ -223,13 +223,13 @@ pub struct IssueStart { #[derive(Debug, Args)] pub struct IssueView { - /// Issue ID like ENG-123, or a URL; defaults to the current branch's issue - #[arg(value_name = "ISSUE")] - pub issue_id: Option, - /// Open the issue in the browser + /// Issue IDs like ENG-123, or URLs; defaults to the current branch's issue + #[arg(value_name = "ISSUE", value_parser = NonBlank)] + pub issue_ids: Vec, + /// Open the issues in the browser #[arg(long, short)] pub web: bool, - /// Open the issue in the Linear app + /// Open the issues in the Linear app #[arg(long, short)] pub app: bool, /// Leave out comments @@ -241,7 +241,7 @@ pub struct IssueView { /// Do not page long output #[arg(long)] pub no_pager: bool, - /// Print JSON + /// Print JSON: an object for one issue, an array for multiple issues #[arg(long, short)] pub json: bool, /// Keep remote image and file URLs instead of downloading them diff --git a/crates/linear-cli/src/commands/issue/view.rs b/crates/linear-cli/src/commands/issue/view.rs index 5266faaf..ad99d677 100644 --- a/crates/linear-cli/src/commands/issue/view.rs +++ b/crates/linear-cli/src/commands/issue/view.rs @@ -14,6 +14,8 @@ use crate::{ }, }; use chrono::{DateTime, Utc}; +use futures_util::{StreamExt, TryStreamExt, stream}; +use serde::Serialize; use std::{ collections::{HashMap, HashSet}, @@ -24,15 +26,79 @@ pub fn run(ctx: &Ctx, args: &IssueView) -> Result<()> { } fn view(ctx: &Ctx, args: &IssueView) -> Result<()> { - let identifier = super::require(ctx, args.issue_id.as_deref())?; + let multiple = args.issue_ids.len() > 1; + let identifiers = if args.issue_ids.is_empty() { + vec![super::require(ctx, None)?] + } else { + args.issue_ids + .iter() + .map(|input| { + super::require(ctx, Some(input)).map_err(|error| { + if multiple { + error.context(format!("Failed to resolve issue {input}")) + } else { + error + } + }) + }) + .collect::>>()? + }; if args.web || args.app { - return ctx.open_in_linear(&format!("issue/{identifier}"), args.app); + let workspace = ctx.workspace_url_key()?; + for identifier in identifiers { + ctx.open_url( + &format!("https://linear.app/{workspace}/issue/{identifier}"), + args.app, + )?; + } + return Ok(()); } let client = ctx.client()?; - let fetched = ctx.spin(!args.json, fetch(client, identifier, !args.no_comments))?; + let fetched: Vec = ctx.spin(!args.json, async { + stream::iter(identifiers) + .map(|identifier| async move { + fetch(client, identifier.clone(), !args.no_comments) + .await + .map_err(|error| { + if multiple { + error.context(format!("Failed to fetch issue {identifier}")) + } else { + error + } + }) + }) + .buffered(5) + .try_collect() + .await + })?; if args.json { - return ctx.print(fetched.json()); + return ctx.print(if multiple { + crate::commands::json::render(&fetched) + } else { + crate::commands::json::render( + fetched.first().expect("view resolves at least one issue"), + ) + }); + } + let now = Utc::now(); + let views = fetched + .into_iter() + .map(|issue| render(ctx, args, client, issue, now)) + .collect::>>()?; + if !ctx.stdout_tty() { + return ctx.print(format!("{}\n", views.join("\n\n---\n\n"))); } + let separator = format!("\n{}\n", ctx.render_markdown("---")); + ctx.page(&views.join(&separator), !args.no_pager) +} + +fn render( + ctx: &Ctx, + args: &IssueView, + client: &LinearClient, + fetched: Fetched, + now: DateTime, +) -> Result { let options = ctx.options(); let download = !args.no_download && options.download_images(); let attachments = download && options.auto_download_attachments(); @@ -68,36 +134,29 @@ fn view(ctx: &Ctx, args: &IssueView) -> Result<()> { } else { HashMap::new() }; - let now = Utc::now(); if !ctx.stdout_tty() { - let markdown = markdown(&issue, &paths, args.show_resolved_threads, now)?; - return ctx.print(format!("{markdown}\n")); + return markdown(&issue, &paths, args.show_resolved_threads, now); } let render = ctx.render_options(); - let rendered = terminal( + terminal( &issue, &paths, args.show_resolved_threads, now, &render, true, - )?; - ctx.page(&rendered, !args.no_pager) + ) } pub type Issue = GetIssueDetailsWithCommentsIssue; pub type Comment = GetIssueDetailsWithCommentsIssueCommentsNodes; +#[derive(Serialize)] +#[serde(untagged)] pub enum Fetched { Without(GetIssueDetailsIssue), With(Issue), } impl Fetched { - pub fn json(&self) -> Vec { - match self { - Self::Without(i) => crate::commands::json::render(i), - Self::With(i) => crate::commands::json::render(i), - } - } pub fn into_issue(self) -> Issue { match self { Self::With(i) => i, diff --git a/crates/linear-cli/src/commands/json.rs b/crates/linear-cli/src/commands/json.rs index 04e41bd0..549551b5 100644 --- a/crates/linear-cli/src/commands/json.rs +++ b/crates/linear-cli/src/commands/json.rs @@ -1,7 +1,8 @@ //! `--json` output. //! //! Every command prints the same shapes: a list command prints a JSON array -//! of its entities, a view command the entity object, and a command that +//! of its entities, a view command the entity object (or an array when viewing +//! multiple issues), and a command that //! changes something the entity it created or changed. Connections nested in //! an entity are arrays of their nodes. Nothing carries pagination fields: //! `--limit` decides how much is fetched. diff --git a/crates/linear-cli/tests/cli/issue_read.rs b/crates/linear-cli/tests/cli/issue_read.rs index 9c8ae7ca..82482a0f 100644 --- a/crates/linear-cli/tests/cli/issue_read.rs +++ b/crates/linear-cli/tests/cli/issue_read.rs @@ -71,6 +71,338 @@ fn details(issue: Value) -> Value { json!({ "issue": issue }) } +fn numbered_issue(number: u32, comments: bool) -> Value { + let mut value = issue(comments); + value["identifier"] = json!(format!("ENG-{number}")); + value["title"] = json!(format!("Widget task {number}")); + value["url"] = json!(format!("https://linear.app/acme/issue/ENG-{number}")); + value["branchName"] = json!(format!("eng-{number}-widget-task")); + value +} + +#[test] +fn view_multiple_json_preserves_input_order_and_comment_selection() { + for comments in [true, false] { + let api = MockLinear::start(); + let expected: Vec<_> = [26, 21, 25, 22, 24, 23] + .into_iter() + .map(|number| numbered_issue(number, comments)) + .collect(); + let operation = if comments { + "GetIssueDetailsWithComments" + } else { + "GetIssueDetails" + }; + for value in expected.iter().rev() { + api.on_variables( + operation, + json!({ "id": value["identifier"] }), + details(value.clone()), + ); + } + let mut args = vec![ + "issue", "view", "ENG-26", "ENG-21", "ENG-25", "ENG-22", "ENG-24", "ENG-23", "--json", + ]; + if !comments { + args.push("--no-comments"); + } + let run = Cli::for_api(&api).run(&args); + assert_json(&run.success().json(), &json!(expected)); + assert!(run.stderr.is_empty(), "{run}"); + assert_eq!(api.operations().len(), expected.len()); + } +} + +#[test] +fn view_multiple_text_separates_issues_and_applies_comment_options_to_each() { + for (comments, resolved) in [(true, false), (true, true), (false, false)] { + let api = MockLinear::start(); + let operation = if comments { + "GetIssueDetailsWithComments" + } else { + "GetIssueDetails" + }; + for number in [21, 22] { + api.on_variables( + operation, + json!({ "id": format!("ENG-{number}") }), + details(numbered_issue(number, comments)), + ); + } + let mut args = vec![ + "issue", + "view", + "ENG-22", + "ENG-21", + "--no-download", + "--no-pager", + ]; + if !comments { + args.push("--no-comments"); + } + if resolved { + args.push("--show-resolved-threads"); + } + let run = Cli::for_api(&api).run(&args); + run.success(); + let first = run + .stdout + .find("# ENG-22: Widget task 22") + .expect("first issue heading"); + let second = run + .stdout + .find("# ENG-21: Widget task 21") + .expect("second issue heading"); + assert!(first < second, "{run}"); + assert!(run.stdout[first..second].contains("\n\n---\n\n"), "{run}"); + assert_eq!( + run.stdout.matches("Root comment body").count(), + if comments { 2 } else { 0 } + ); + assert_eq!( + run.stdout.matches("Resolved thread body").count(), + if resolved { 2 } else { 0 } + ); + } +} + +#[test] +fn view_multiple_normalizes_mixed_references_and_retains_duplicates() { + let api = MockLinear::start(); + let expected: Vec<_> = [21, 22, 21, 23] + .into_iter() + .map(|n| numbered_issue(n, false)) + .collect(); + for value in &expected { + api.on_variables( + "GetIssueDetails", + json!({ "id": value["identifier"] }), + details(value.clone()), + ); + } + let run = Cli::for_api(&api).env("LINEAR_TEAM_ID", "ENG").run(&[ + "issue", + "v", + "eng-21", + "https://linear.app/acme/issue/eng-22/widget-task", + "ENG-21", + "23", + "--json", + "--no-comments", + ]); + assert_json(&run.success().json(), &json!(expected)); +} + +#[test] +fn view_multiple_rejects_invalid_later_input_before_requests() { + for invalid in ["not an id!", "", " "] { + let api = MockLinear::start(); + let run = Cli::for_api(&api).run(&["issue", "view", "ENG-21", invalid, "--json"]); + run.usage_error(); + if !invalid.trim().is_empty() { + run.stderr_has(invalid); + } + assert!(run.stdout.is_empty(), "{run}"); + assert!(api.requests().is_empty()); + } +} + +#[test] +fn view_multiple_errors_name_the_issue_without_partial_output() { + for json in [true, false] { + for missing in [true, false] { + let api = MockLinear::start(); + api.on_variables( + "GetIssueDetailsWithComments", + json!({ "id": "ENG-21" }), + details(numbered_issue(21, true)), + ); + if missing { + api.on_variables( + "GetIssueDetailsWithComments", + json!({ "id": "ENG-22" }), + json!({ "issue": null }), + ); + } else { + api.on_variables_error( + "GetIssueDetailsWithComments", + json!({ "id": "ENG-22" }), + "Something went wrong", + ); + } + let mut args = vec!["issue", "view", "ENG-21", "ENG-22", "--no-download"]; + if json { + args.push("--json"); + } + let run = Cli::for_api(&api).run(&args); + if missing { + run.not_found().stderr_has("Issue not found: ENG-22"); + } else { + run.failure().stderr_has("Something went wrong"); + } + run.stderr_has("ENG-22"); + assert!(run.stdout.is_empty(), "{run}"); + } + } +} + +#[test] +fn view_multiple_finishes_each_issues_nested_pages() { + let api = MockLinear::start(); + for number in [21, 22] { + let mut value = numbered_issue(number, true); + value["comments"]["pageInfo"] = json!({ "hasNextPage": true, "endCursor": "next" }); + api.on_variables( + "GetIssueDetailsWithComments", + json!({ "id": format!("ENG-{number}") }), + details(value), + ); + api.on_variables( + "GetIssueCommentsPage", + json!({ "id": format!("ENG-{number}"), "first": 100, "after": "next" }), + json!({ "issue": { "comments": { + "nodes": [comment("late", &format!("Late comment for {number}"), None, None)], + "pageInfo": { "hasNextPage": false, "endCursor": null } + } } }), + ); + } + let run = Cli::for_api(&api).run(&["issue", "view", "ENG-22", "ENG-21", "--json"]); + let values = run.success().json_nodes(); + assert_eq!( + values[0]["comments"] + .as_array() + .expect("comments") + .last() + .expect("late comment")["body"], + "Late comment for 22" + ); + assert_eq!( + values[1]["comments"] + .as_array() + .expect("comments") + .last() + .expect("late comment")["body"], + "Late comment for 21" + ); +} + +#[test] +fn view_multiple_nested_page_failure_has_no_partial_output() { + let api = MockLinear::start(); + api.on_variables( + "GetIssueDetailsWithComments", + json!({ "id": "ENG-21" }), + details(numbered_issue(21, true)), + ); + let mut value = numbered_issue(22, true); + value["comments"]["pageInfo"] = json!({ "hasNextPage": true, "endCursor": "next" }); + api.on_variables( + "GetIssueDetailsWithComments", + json!({ "id": "ENG-22" }), + details(value), + ); + api.on_error("GetIssueCommentsPage", "Could not load comments"); + let run = Cli::for_api(&api).run(&["issue", "view", "ENG-21", "ENG-22", "--json"]); + run.failure() + .stderr_has("ENG-22") + .stderr_has("Could not load comments"); + assert!(run.stdout.is_empty(), "{run}"); +} + +#[test] +fn view_multiple_terminal_output_uses_one_pager_unless_disabled() { + for paging in [true, false] { + let api = MockLinear::start(); + for number in [21, 22] { + let mut value = numbered_issue(number, false); + value["description"] = json!("A long widget description.\n\n".repeat(60)); + api.on_variables( + "GetIssueDetails", + json!({ "id": format!("ENG-{number}") }), + details(value), + ); + } + let cli = Cli::for_api(&api) + .stub_bin("sh", "exec /bin/sh \"$@\"") + .stub_bin("test-pager", "cat"); + let pager = cli.path("bin/test-pager").display().to_string(); + let cli = cli.env("PAGER", &pager); + let mut args = vec![ + "issue", + "view", + "ENG-22", + "ENG-21", + "--no-comments", + "--no-download", + ]; + if !paging { + args.push("--no-pager"); + } + let run = cli.run_tty(&args, &[]); + run.success() + .stdout_has("ENG-22: Widget task 22") + .stdout_has("ENG-21: Widget task 21"); + assert_eq!(cli.calls("test-pager").len(), usize::from(paging)); + assert!( + run.stdout.find("ENG-22: Widget task 22") < run.stdout.find("ENG-21: Widget task 21"), + "{run}" + ); + } +} + +#[test] +fn view_multiple_downloads_each_issues_images_only_in_text_download_mode() { + for mode in ["download", "--no-download", "--json"] { + let api = MockLinear::start(); + let mut urls = Vec::new(); + for number in [21, 22] { + let path = format!("/img/widget-{number}.png"); + let url = format!("{}{path}", api.base_url()); + let mut value = numbered_issue(number, false); + value["description"] = json!(format!("![widget]({url})")); + urls.push(url); + api.on_variables( + "GetIssueDetails", + json!({ "id": format!("ENG-{number}") }), + details(value), + ); + if mode == "download" { + api.on_http("GET", &path, 200, format!("IMAGE-{number}").as_bytes()); + } + } + let cli = Cli::for_api(&api); + let mut args = vec![ + "issue", + "view", + "ENG-21", + "ENG-22", + "--no-comments", + "--no-pager", + ]; + if mode != "download" { + args.push(mode); + } + let run = cli.run(&args); + run.success(); + let files = files_under(&cli.path("home/.cache/linear-cli/images")); + if mode == "download" { + assert_eq!(files.len(), 2); + for file in files { + run.stdout_has(&file.display().to_string()); + } + for url in urls { + assert!(!run.stdout.contains(&url), "{run}"); + } + } else { + assert!(files.is_empty()); + for url in urls { + run.stdout_has(&url); + } + assert!(api.requests().iter().all(|request| request.method != "GET")); + } + } +} + #[test] fn view_json_prints_the_issue_with_comments() { let api = MockLinear::start(); diff --git a/crates/linear-cli/tests/cli/web.rs b/crates/linear-cli/tests/cli/web.rs index f81d2ff1..a981d6ee 100644 --- a/crates/linear-cli/tests/cli/web.rs +++ b/crates/linear-cli/tests/cli/web.rs @@ -43,6 +43,58 @@ fn issue_view_opens_the_issue_without_a_request() { ); } +#[test] +fn issue_view_opens_every_issue_in_order() { + for flag in ["--web", "--app"] { + let api = MockLinear::start(); + let cli = configured(&api).env("LINEAR_TEAM_ID", "ENG"); + let run = cli.run(&["issue", "view", "eng-22", "21", flag, "--json"]); + run.success(); + assert!(run.stdout.is_empty(), "{run}"); + assert_eq!( + opened(&cli), + [ + "https://linear.app/acme/issue/ENG-22", + "https://linear.app/acme/issue/ENG-21" + ] + ); + assert!(api.requests().is_empty()); + } +} + +#[test] +fn issue_view_validates_all_references_before_opening() { + for flag in ["--web", "--app"] { + let api = MockLinear::start(); + let cli = configured(&api); + cli.run(&["issue", "view", "ENG-21", "invalid issue", flag]) + .usage_error() + .stderr_has("invalid issue"); + assert!(opened(&cli).is_empty()); + assert!(api.requests().is_empty()); + } +} + +#[test] +fn issue_view_looks_up_the_workspace_once_for_multiple_issues() { + let api = MockLinear::start(); + api.on( + "GetViewer", + json!({ "viewer": { "organization": { "urlKey": "acme" } } }), + ); + let cli = open_stubs(Cli::for_api(&api)); + cli.run(&["issue", "view", "ENG-22", "ENG-21", "--web"]) + .success(); + assert_eq!( + opened(&cli), + [ + "https://linear.app/acme/issue/ENG-22", + "https://linear.app/acme/issue/ENG-21" + ] + ); + assert_eq!(api.operations(), ["GetViewer"]); +} + #[test] fn issue_list_opens_the_team_issue_view() { let api = MockLinear::start(); diff --git a/skills/linear-cli/SKILL.md b/skills/linear-cli/SKILL.md index 94f8542c..0037a58f 100644 --- a/skills/linear-cli/SKILL.md +++ b/skills/linear-cli/SKILL.md @@ -105,14 +105,18 @@ linear issue comment unresolve # reopen a thread Name a thread by its top-level comment's ID; a reply's ID is refused. In `comment list --json`, `resolvedAt` is set only on a resolved thread's top-level comment, and a reply carries its thread's state in `parent.resolvedAt`. The IDs of open threads are `jq -r '.[] | select(.parent == null and .resolvedAt == null) | .id'`. -### View an issue / get its URL +### View issues / get an issue URL ```bash linear issue view ENG-123 # details incl. comments linear issue view ENG-123 --json # structured output +linear issue view ENG-123 ENG-124 # multiple issues, in argument order +linear issue view ENG-123 ENG-124 --json # an array of issue objects linear issue url ENG-123 # print just the URL ``` +`issue view --json` returns an object for one issue and an array for multiple arguments. Argument order and repeated references are preserved. If any issue cannot be fetched, the command fails without printing partial results. + ### Close, delete, or archive an issue ```bash diff --git a/skills/linear-cli/SKILL.template.md b/skills/linear-cli/SKILL.template.md index 01c4cb1f..46f95ff6 100644 --- a/skills/linear-cli/SKILL.template.md +++ b/skills/linear-cli/SKILL.template.md @@ -105,14 +105,18 @@ linear issue comment unresolve # reopen a thread Name a thread by its top-level comment's ID; a reply's ID is refused. In `comment list --json`, `resolvedAt` is set only on a resolved thread's top-level comment, and a reply carries its thread's state in `parent.resolvedAt`. The IDs of open threads are `jq -r '.[] | select(.parent == null and .resolvedAt == null) | .id'`. -### View an issue / get its URL +### View issues / get an issue URL ```bash linear issue view ENG-123 # details incl. comments linear issue view ENG-123 --json # structured output +linear issue view ENG-123 ENG-124 # multiple issues, in argument order +linear issue view ENG-123 ENG-124 --json # an array of issue objects linear issue url ENG-123 # print just the URL ``` +`issue view --json` returns an object for one issue and an array for multiple arguments. Argument order and repeated references are preserved. If any issue cannot be fetched, the command fails without printing partial results. + ### Close, delete, or archive an issue ```bash diff --git a/skills/linear-cli/references/issue.md b/skills/linear-cli/references/issue.md index 1751446a..ed75b3cf 100644 --- a/skills/linear-cli/references/issue.md +++ b/skills/linear-cli/references/issue.md @@ -12,7 +12,7 @@ Usage: linear issue [OPTIONS] Commands: list List issues, assigned to you by default query Find issues by filters or full-text search - view Show an issue + view Show one or more issues create Create an issue update Update an issue delete Delete an issue (moves it to the trash) @@ -126,23 +126,23 @@ Global options: ### view -> Show an issue +> Show one or more issues ``` -Show an issue +Show one or more issues -Usage: linear issue view [OPTIONS] [ISSUE] +Usage: linear issue view [OPTIONS] [ISSUE]... Arguments: - [ISSUE] Issue ID like ENG-123, or a URL; defaults to the current branch's issue + [ISSUE]... Issue IDs like ENG-123, or URLs; defaults to the current branch's issue Options: - -w, --web Open the issue in the browser - -a, --app Open the issue in the Linear app + -w, --web Open the issues in the browser + -a, --app Open the issues in the Linear app --no-comments Leave out comments --show-resolved-threads Include resolved comment threads --no-pager Do not page long output - -j, --json Print JSON + -j, --json Print JSON: an object for one issue, an array for multiple issues --no-download Keep remote image and file URLs instead of downloading them -h, --help Print help