Skip to content

Commit fcca1b6

Browse files
committed
feat(git): add git review-url to resolve an issue's Linear review URL
Linear's review page for a pull request (linear.app/<workspace>/review/<slug>) 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 <issue>` 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.
1 parent 51af446 commit fcca1b6

3 files changed

Lines changed: 175 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,8 +360,14 @@ linear-cli g branch LIN-123 # Show branch name
360360
linear-cli g create LIN-123 # Create branch (no checkout)
361361
linear-cli g commits # Commits with Linear trailers (jj)
362362
linear-cli g pr LIN-123 --draft # Create GitHub PR
363+
linear-cli g review-url LIN-123 # Linear review URL for the issue's PR
363364
```
364365

366+
`review-url` resolves `https://linear.app/<workspace>/review/<slug>` from the
367+
pull requests Linear has linked to the issue's agent sessions — the only place the
368+
API exposes a PR's review slug. A pull request opened outside that flow has no
369+
slug to resolve, so the command reports that and you use the GitHub PR URL.
370+
365371
### Import / Export
366372

367373
Round-trip CSV and JSON import/export with field resolution for status, assignee, and labels.

‎src/commands/git.rs‎

Lines changed: 168 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
use anyhow::Result;
22
use clap::{Subcommand, ValueEnum};
33
use colored::Colorize;
4-
use serde_json::json;
4+
use serde_json::{json, Value};
55
use std::path::Path;
66
use std::process::Command;
77

88
use crate::api::LinearClient;
99
use crate::display_options;
10+
use crate::output::{print_json, OutputOptions};
1011
use crate::text::truncate;
1112
use crate::vcs::{generate_branch_name, git_branch_exists, run_git_command, validate_branch_name};
1213

@@ -82,6 +83,17 @@ pub enum GitCommands {
8283
#[arg(long, value_enum)]
8384
vcs: Option<Vcs>,
8485
},
86+
/// Show the Linear review URL for an issue's pull request(s)
87+
#[command(after_help = r#"EXAMPLES:
88+
linear git review-url LIN-123 # Print the review URL(s)
89+
linear g review-url LIN-123 -o json # Include PR number, state, GitHub URL
90+
91+
NOTE: Linear only exposes a pull request's review slug for PRs it has linked to
92+
an agent session, so a PR opened outside that flow has no review URL to resolve."#)]
93+
ReviewUrl {
94+
/// Issue identifier (e.g., "LIN-123") or ID
95+
issue: String,
96+
},
8597
/// Create a GitHub PR from a Linear issue
8698
#[command(after_help = r#"EXAMPLES:
8799
linear git pr LIN-123 # Create PR for issue
@@ -140,8 +152,9 @@ fn get_vcs(vcs_flag: Option<Vcs>) -> Result<Vcs> {
140152
}
141153
}
142154

143-
pub async fn handle(cmd: GitCommands) -> Result<()> {
155+
pub async fn handle(cmd: GitCommands, output: &OutputOptions) -> Result<()> {
144156
match cmd {
157+
GitCommands::ReviewUrl { issue } => show_review_url(&issue, output).await,
145158
GitCommands::Checkout { issue, branch, vcs } => {
146159
let vcs = get_vcs(vcs)?;
147160
checkout_issue(&issue, branch, vcs).await
@@ -167,6 +180,94 @@ pub async fn handle(cmd: GitCommands) -> Result<()> {
167180
}
168181
}
169182

183+
/// Build the review entries for an issue from a `review-url` query response.
184+
///
185+
/// `PullRequest.slugId` is the only public field carrying the slug in a review
186+
/// URL, and it is reachable only through the agent sessions attached to an issue,
187+
/// so an issue can legitimately resolve to zero entries. One pull request can be
188+
/// linked by more than one session, hence the de-duplication by slug.
189+
fn review_entries(url_key: &str, issue: &Value) -> Vec<Value> {
190+
let mut seen: Vec<String> = Vec::new();
191+
let mut entries = Vec::new();
192+
193+
let sessions = issue["agentSessions"]["nodes"].as_array();
194+
for session in sessions.into_iter().flatten() {
195+
let links = session["pullRequests"]["nodes"].as_array();
196+
for link in links.into_iter().flatten() {
197+
let pr = &link["pullRequest"];
198+
let Some(slug) = pr["slugId"].as_str().filter(|s| !s.is_empty()) else {
199+
continue;
200+
};
201+
if seen.iter().any(|s| s == slug) {
202+
continue;
203+
}
204+
seen.push(slug.to_string());
205+
entries.push(json!({
206+
"reviewUrl": format!("https://linear.app/{}/review/{}", url_key, slug),
207+
"number": pr["number"],
208+
"status": pr["status"],
209+
"url": pr["url"],
210+
"title": pr["title"],
211+
}));
212+
}
213+
}
214+
215+
entries
216+
}
217+
218+
async fn show_review_url(issue_id: &str, output: &OutputOptions) -> Result<()> {
219+
let client = LinearClient::new()?;
220+
221+
let query = r#"
222+
query($id: String!) {
223+
organization { urlKey }
224+
issue(id: $id) {
225+
identifier
226+
agentSessions {
227+
nodes {
228+
pullRequests {
229+
nodes {
230+
pullRequest { slugId url number status title }
231+
}
232+
}
233+
}
234+
}
235+
}
236+
}
237+
"#;
238+
239+
let result = client.query(query, Some(json!({ "id": issue_id }))).await?;
240+
let issue = &result["data"]["issue"];
241+
242+
if issue.is_null() {
243+
anyhow::bail!("Issue not found: {}", issue_id);
244+
}
245+
246+
let url_key = result["data"]["organization"]["urlKey"]
247+
.as_str()
248+
.unwrap_or_default();
249+
let entries = review_entries(url_key, issue);
250+
251+
if entries.is_empty() {
252+
anyhow::bail!(
253+
"No review URL for {}: Linear exposes a pull request's review slug only \
254+
for PRs linked to an agent session, and this issue has none. Use the \
255+
GitHub PR URL instead.",
256+
issue["identifier"].as_str().unwrap_or(issue_id)
257+
);
258+
}
259+
260+
if output.is_json() || output.has_template() {
261+
return print_json(&json!(entries), output);
262+
}
263+
264+
for entry in &entries {
265+
println!("{}", entry["reviewUrl"].as_str().unwrap_or_default());
266+
}
267+
268+
Ok(())
269+
}
270+
170271
async fn get_issue_info(issue_id: &str) -> Result<(String, String, String, String)> {
171272
let client = LinearClient::new()?;
172273

@@ -602,6 +703,71 @@ async fn create_pr(issue_id: &str, base: &str, draft: bool, web: bool) -> Result
602703
mod tests {
603704
use super::*;
604705

706+
fn issue_with_sessions(sessions: Value) -> Value {
707+
json!({ "identifier": "LIN-123", "agentSessions": { "nodes": sessions } })
708+
}
709+
710+
fn pr_link(slug: &str, number: u64) -> Value {
711+
json!({ "pullRequest": {
712+
"slugId": slug,
713+
"number": number,
714+
"status": "open",
715+
"url": format!("https://github.com/acme/app/pull/{}", number),
716+
"title": "Fix the thing"
717+
}})
718+
}
719+
720+
#[test]
721+
fn test_review_entries_builds_review_url_from_slug() {
722+
let issue = issue_with_sessions(json!([
723+
{ "pullRequests": { "nodes": [pr_link("7ffd27854fd2", 183)] } }
724+
]));
725+
726+
let entries = review_entries("acme", &issue);
727+
728+
assert_eq!(entries.len(), 1);
729+
assert_eq!(
730+
entries[0]["reviewUrl"],
731+
"https://linear.app/acme/review/7ffd27854fd2"
732+
);
733+
assert_eq!(entries[0]["number"], 183);
734+
assert_eq!(entries[0]["url"], "https://github.com/acme/app/pull/183");
735+
}
736+
737+
#[test]
738+
fn test_review_entries_dedupes_a_pr_linked_by_several_sessions() {
739+
let issue = issue_with_sessions(json!([
740+
{ "pullRequests": { "nodes": [pr_link("aaa111", 7)] } },
741+
{ "pullRequests": { "nodes": [pr_link("aaa111", 7), pr_link("bbb222", 8)] } }
742+
]));
743+
744+
let entries = review_entries("acme", &issue);
745+
746+
assert_eq!(entries.len(), 2, "the repeated pull request is listed once");
747+
assert_eq!(
748+
entries[0]["reviewUrl"],
749+
"https://linear.app/acme/review/aaa111"
750+
);
751+
assert_eq!(
752+
entries[1]["reviewUrl"],
753+
"https://linear.app/acme/review/bbb222"
754+
);
755+
}
756+
757+
#[test]
758+
fn test_review_entries_empty_without_sessions_or_slug() {
759+
assert!(review_entries("acme", &issue_with_sessions(json!([]))).is_empty());
760+
761+
// A session with no linked pull request, and a link whose slug is missing or
762+
// blank: all unresolvable, and none of them may produce a bogus URL.
763+
let unresolvable = issue_with_sessions(json!([
764+
{ "pullRequests": { "nodes": [] } },
765+
{ "pullRequests": { "nodes": [{ "pullRequest": { "number": 1 } }] } },
766+
{ "pullRequests": { "nodes": [{ "pullRequest": { "slugId": "", "number": 2 } }] } }
767+
]));
768+
assert!(review_entries("acme", &unresolvable).is_empty());
769+
}
770+
605771
#[test]
606772
fn test_generate_branch_name_simple() {
607773
assert_eq!(

‎src/main.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1160,7 +1160,7 @@ async fn run_command(
11601160
Commands::Search { action } => search::handle(action, output).await?,
11611161
Commands::Sync { action } => sync::handle(action, output).await?,
11621162
Commands::Statuses { action } => statuses::handle(action, output).await?,
1163-
Commands::Git { action } => git::handle(action).await?,
1163+
Commands::Git { action } => git::handle(action, output).await?,
11641164
Commands::Bulk { action } => bulk::handle(action, output).await?,
11651165
Commands::Cache { action } => commands::cache::handle(action).await?,
11661166
Commands::Notifications { action } => notifications::handle(action, output).await?,

0 commit comments

Comments
 (0)