11use anyhow:: Result ;
2- use serde:: Deserialize ;
32use std:: path:: PathBuf ;
4- use tracing:: { info, warn} ;
3+ use tracing:: info;
4+
5+ #[ path = "pr/comments.rs" ]
6+ mod comments;
7+ #[ path = "pr/gh.rs" ]
8+ mod gh;
59
610use crate :: adapters;
711use crate :: config;
812use crate :: core;
913use crate :: output:: OutputFormat ;
1014use crate :: review;
1115
16+ use comments:: post_review_comments;
17+ use gh:: { fetch_pr_diff, fetch_pr_metadata, resolve_pr_number} ;
18+
1219pub async fn pr_command (
1320 number : Option < u32 > ,
1421 repo : Option < String > ,
@@ -17,36 +24,7 @@ pub async fn pr_command(
1724 config : config:: Config ,
1825 format : OutputFormat ,
1926) -> Result < ( ) > {
20- use std:: process:: Command ;
21-
22- let pr_number = if let Some ( num) = number {
23- num. to_string ( )
24- } else {
25- let mut args = vec ! [
26- "pr" . to_string( ) ,
27- "view" . to_string( ) ,
28- "--json" . to_string( ) ,
29- "number" . to_string( ) ,
30- "-q" . to_string( ) ,
31- ".number" . to_string( ) ,
32- ] ;
33- if let Some ( repo) = repo. as_ref ( ) {
34- args. push ( "--repo" . to_string ( ) ) ;
35- args. push ( repo. clone ( ) ) ;
36- }
37-
38- let output = Command :: new ( "gh" ) . args ( & args) . output ( ) ?;
39- if !output. status . success ( ) {
40- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
41- anyhow:: bail!( "gh pr view failed: {}" , stderr. trim( ) ) ;
42- }
43-
44- let pr_number = String :: from_utf8 ( output. stdout ) ?. trim ( ) . to_string ( ) ;
45- if pr_number. is_empty ( ) {
46- anyhow:: bail!( "Unable to determine PR number from gh output" ) ;
47- }
48- pr_number
49- } ;
27+ let pr_number = resolve_pr_number ( number, repo. as_deref ( ) ) ?;
5028
5129 info ! ( "Reviewing PR #{}" , pr_number) ;
5230
@@ -59,18 +37,7 @@ pub async fn pr_command(
5937 info ! ( "Remote URL: {}" , remote) ;
6038 }
6139
62- let mut diff_args = vec ! [ "pr" . to_string( ) , "diff" . to_string( ) , pr_number. clone( ) ] ;
63- if let Some ( repo) = repo. as_ref ( ) {
64- diff_args. push ( "--repo" . to_string ( ) ) ;
65- diff_args. push ( repo. clone ( ) ) ;
66- }
67- let diff_output = Command :: new ( "gh" ) . args ( & diff_args) . output ( ) ?;
68- if !diff_output. status . success ( ) {
69- let stderr = String :: from_utf8_lossy ( & diff_output. stderr ) ;
70- anyhow:: bail!( "gh pr diff failed: {}" , stderr. trim( ) ) ;
71- }
72-
73- let diff_content = String :: from_utf8 ( diff_output. stdout ) ?;
40+ let diff_content = fetch_pr_diff ( & pr_number, repo. as_deref ( ) ) ?;
7441
7542 if diff_content. is_empty ( ) {
7643 println ! ( "No changes in PR" ) ;
@@ -81,7 +48,6 @@ pub async fn pr_command(
8148 let diffs = core:: DiffParser :: parse_unified_diff ( & diff_content) ?;
8249 let git = core:: GitIntegration :: new ( "." ) ?;
8350
84- // Use Fast model for PR summary generation (lightweight task)
8551 let fast_config = config. to_model_config_for_role ( config:: ModelRole :: Fast ) ;
8652 let adapter = adapters:: llm:: create_adapter ( & fast_config) ?;
8753 let options = core:: SummaryOptions {
@@ -105,34 +71,10 @@ pub async fn pr_command(
10571
10672 if post_comments {
10773 info ! ( "Posting {} comments to PR" , comments. len( ) ) ;
108- let metadata = fetch_pr_metadata ( & pr_number, repo. as_ref ( ) ) ?;
109- let mut inline_posted = 0usize ;
110- let mut fallback_posted = 0usize ;
111-
112- for comment in & comments {
113- let body = build_github_comment_body ( comment) ;
114- let inline_result =
115- post_inline_pr_comment ( & pr_number, repo. as_ref ( ) , & metadata, comment, & body) ;
116-
117- if inline_result. is_ok ( ) {
118- inline_posted += 1 ;
119- continue ;
120- }
121-
122- if let Err ( err) = inline_result {
123- warn ! (
124- "Inline comment failed for {}:{} (falling back to PR comment): {}" ,
125- comment. file_path. display( ) ,
126- comment. line_number,
127- err
128- ) ;
129- }
130- post_pr_comment ( & pr_number, repo. as_ref ( ) , & body) ?;
131- fallback_posted += 1 ;
132- }
133- upsert_pr_summary_comment (
74+ let metadata = fetch_pr_metadata ( & pr_number, repo. as_deref ( ) ) ?;
75+ let stats = post_review_comments (
13476 & pr_number,
135- repo. as_ref ( ) ,
77+ repo. as_deref ( ) ,
13678 & metadata,
13779 & comments,
13880 & config. rule_priority ,
@@ -142,208 +84,12 @@ pub async fn pr_command(
14284 "Posted {} comments to PR #{} (inline: {}, fallback: {}, summary: updated)" ,
14385 comments. len( ) ,
14486 pr_number,
145- inline_posted,
146- fallback_posted
87+ stats . inline_posted,
88+ stats . fallback_posted
14789 ) ;
14890 } else {
14991 crate :: output:: output_comments ( & comments, None , format, & config. rule_priority ) . await ?;
15092 }
15193
15294 Ok ( ( ) )
15395}
154-
155- #[ derive( Debug , Deserialize ) ]
156- struct GhPrMetadata {
157- #[ serde( rename = "headRefOid" ) ]
158- head_ref_oid : String ,
159- #[ serde( rename = "baseRepository" ) ]
160- base_repository : GhBaseRepository ,
161- }
162-
163- #[ derive( Debug , Deserialize ) ]
164- struct GhBaseRepository {
165- #[ serde( rename = "nameWithOwner" ) ]
166- name_with_owner : String ,
167- }
168-
169- fn fetch_pr_metadata ( pr_number : & str , repo : Option < & String > ) -> Result < GhPrMetadata > {
170- use std:: process:: Command ;
171-
172- let mut args = vec ! [
173- "pr" . to_string( ) ,
174- "view" . to_string( ) ,
175- pr_number. to_string( ) ,
176- "--json" . to_string( ) ,
177- "headRefOid,baseRepository" . to_string( ) ,
178- ] ;
179- if let Some ( repo) = repo {
180- args. push ( "--repo" . to_string ( ) ) ;
181- args. push ( repo. clone ( ) ) ;
182- }
183-
184- let output = Command :: new ( "gh" ) . args ( & args) . output ( ) ?;
185- if !output. status . success ( ) {
186- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
187- anyhow:: bail!( "gh pr view metadata failed: {}" , stderr. trim( ) ) ;
188- }
189-
190- let metadata: GhPrMetadata = serde_json:: from_slice ( & output. stdout ) ?;
191- Ok ( metadata)
192- }
193-
194- fn build_github_comment_body ( comment : & core:: Comment ) -> String {
195- let mut body = format ! (
196- "**{:?} ({:?})**\n \n {}" ,
197- comment. severity, comment. category, comment. content
198- ) ;
199- if let Some ( rule_id) = & comment. rule_id {
200- body. push_str ( & format ! ( "\n \n **Rule:** `{}`" , rule_id) ) ;
201- }
202- if let Some ( suggestion) = & comment. suggestion {
203- body. push_str ( "\n \n **Suggested fix:** " ) ;
204- body. push_str ( suggestion) ;
205- }
206- body. push_str ( & format ! (
207- "\n \n _Confidence: {:.0}%_" ,
208- comment. confidence * 100.0
209- ) ) ;
210- body
211- }
212-
213- fn post_inline_pr_comment (
214- pr_number : & str ,
215- repo : Option < & String > ,
216- metadata : & GhPrMetadata ,
217- comment : & core:: Comment ,
218- body : & str ,
219- ) -> Result < ( ) > {
220- use std:: process:: Command ;
221-
222- if comment. line_number == 0 {
223- anyhow:: bail!( "line number is 0" ) ;
224- }
225-
226- let endpoint = format ! (
227- "repos/{}/pulls/{}/comments" ,
228- metadata. base_repository. name_with_owner, pr_number
229- ) ;
230- let mut args = vec ! [
231- "api" . to_string( ) ,
232- "-X" . to_string( ) ,
233- "POST" . to_string( ) ,
234- endpoint,
235- "-f" . to_string( ) ,
236- format!( "body={}" , body) ,
237- "-f" . to_string( ) ,
238- format!( "commit_id={}" , metadata. head_ref_oid) ,
239- "-f" . to_string( ) ,
240- format!( "path={}" , comment. file_path. display( ) ) ,
241- "-F" . to_string( ) ,
242- format!( "line={}" , comment. line_number) ,
243- "-f" . to_string( ) ,
244- "side=RIGHT" . to_string( ) ,
245- ] ;
246- if let Some ( repo) = repo {
247- args. push ( "--repo" . to_string ( ) ) ;
248- args. push ( repo. clone ( ) ) ;
249- }
250-
251- let output = Command :: new ( "gh" ) . args ( & args) . output ( ) ?;
252- if !output. status . success ( ) {
253- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
254- anyhow:: bail!( "gh api inline comment failed: {}" , stderr. trim( ) ) ;
255- }
256-
257- Ok ( ( ) )
258- }
259-
260- fn post_pr_comment ( pr_number : & str , repo : Option < & String > , body : & str ) -> Result < ( ) > {
261- use std:: process:: Command ;
262-
263- let mut args = vec ! [
264- "pr" . to_string( ) ,
265- "comment" . to_string( ) ,
266- pr_number. to_string( ) ,
267- "--body" . to_string( ) ,
268- body. to_string( ) ,
269- ] ;
270- if let Some ( repo) = repo {
271- args. push ( "--repo" . to_string ( ) ) ;
272- args. push ( repo. clone ( ) ) ;
273- }
274-
275- let output = Command :: new ( "gh" ) . args ( & args) . output ( ) ?;
276- if !output. status . success ( ) {
277- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
278- anyhow:: bail!( "gh pr comment failed: {}" , stderr. trim( ) ) ;
279- }
280- Ok ( ( ) )
281- }
282-
283- #[ derive( Debug , Deserialize ) ]
284- struct GhIssueComment {
285- id : u64 ,
286- body : String ,
287- }
288-
289- fn upsert_pr_summary_comment (
290- pr_number : & str ,
291- repo : Option < & String > ,
292- metadata : & GhPrMetadata ,
293- comments : & [ core:: Comment ] ,
294- rule_priority : & [ String ] ,
295- ) -> Result < ( ) > {
296- use std:: process:: Command ;
297-
298- const SUMMARY_MARKER : & str = "<!-- diffscope:summary -->" ;
299- let summary_body = review:: build_pr_summary_comment_body ( comments, rule_priority) ;
300- let full_body = format ! ( "{}\n \n {}" , SUMMARY_MARKER , summary_body) ;
301-
302- let comments_endpoint = format ! (
303- "repos/{}/issues/{}/comments?per_page=100" ,
304- metadata. base_repository. name_with_owner, pr_number
305- ) ;
306- let mut args = vec ! [ "api" . to_string( ) , comments_endpoint] ;
307- if let Some ( repo) = repo {
308- args. push ( "--repo" . to_string ( ) ) ;
309- args. push ( repo. clone ( ) ) ;
310- }
311-
312- let output = Command :: new ( "gh" ) . args ( & args) . output ( ) ?;
313- if !output. status . success ( ) {
314- let stderr = String :: from_utf8_lossy ( & output. stderr ) ;
315- anyhow:: bail!( "gh api list issue comments failed: {}" , stderr. trim( ) ) ;
316- }
317-
318- let issue_comments: Vec < GhIssueComment > = serde_json:: from_slice ( & output. stdout ) ?;
319- if let Some ( existing) = issue_comments
320- . iter ( )
321- . find ( |comment| comment. body . contains ( SUMMARY_MARKER ) )
322- {
323- let patch_endpoint = format ! (
324- "repos/{}/issues/comments/{}" ,
325- metadata. base_repository. name_with_owner, existing. id
326- ) ;
327- let mut patch_args = vec ! [
328- "api" . to_string( ) ,
329- "-X" . to_string( ) ,
330- "PATCH" . to_string( ) ,
331- patch_endpoint,
332- "-f" . to_string( ) ,
333- format!( "body={}" , full_body) ,
334- ] ;
335- if let Some ( repo) = repo {
336- patch_args. push ( "--repo" . to_string ( ) ) ;
337- patch_args. push ( repo. clone ( ) ) ;
338- }
339-
340- let patch_output = Command :: new ( "gh" ) . args ( & patch_args) . output ( ) ?;
341- if !patch_output. status . success ( ) {
342- let stderr = String :: from_utf8_lossy ( & patch_output. stderr ) ;
343- anyhow:: bail!( "gh api patch summary comment failed: {}" , stderr. trim( ) ) ;
344- }
345- return Ok ( ( ) ) ;
346- }
347-
348- post_pr_comment ( pr_number, repo, & full_body)
349- }
0 commit comments