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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 7 additions & 7 deletions crates/linear-cli/src/cli/issue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String>,
/// 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<String>,
/// 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
Expand All @@ -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
Expand Down
91 changes: 75 additions & 16 deletions crates/linear-cli/src/commands/issue/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use crate::{
},
};
use chrono::{DateTime, Utc};
use futures_util::{StreamExt, TryStreamExt, stream};
use serde::Serialize;

use std::{
collections::{HashMap, HashSet},
Expand All @@ -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::<Result<Vec<_>>>()?
};
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<Fetched> = 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::<Result<Vec<_>>>()?;
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<Utc>,
) -> Result<String> {
let options = ctx.options();
let download = !args.no_download && options.download_images();
let attachments = download && options.auto_download_attachments();
Expand Down Expand Up @@ -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<u8> {
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,
Expand Down
3 changes: 2 additions & 1 deletion crates/linear-cli/src/commands/json.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading