11use anyhow:: Result ;
22use clap:: { Subcommand , ValueEnum } ;
33use colored:: Colorize ;
4- use serde_json:: json;
4+ use serde_json:: { json, Value } ;
55use std:: path:: Path ;
66use std:: process:: Command ;
77
88use crate :: api:: LinearClient ;
99use crate :: display_options;
10+ use crate :: output:: { print_json, OutputOptions } ;
1011use crate :: text:: truncate;
1112use 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+
170271async 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
602703mod 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 ! (
0 commit comments