Skip to content

Commit 0080b31

Browse files
committed
refactor: split PR command helpers
Separate GitHub CLI lookups and PR comment posting so the PR review command stays focused on the review flow while preserving behavior. Made-with: Cursor
1 parent d2b833a commit 0080b31

3 files changed

Lines changed: 305 additions & 270 deletions

File tree

src/commands/pr.rs

Lines changed: 16 additions & 270 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
11
use anyhow::Result;
2-
use serde::Deserialize;
32
use 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

610
use crate::adapters;
711
use crate::config;
812
use crate::core;
913
use crate::output::OutputFormat;
1014
use crate::review;
1115

16+
use comments::post_review_comments;
17+
use gh::{fetch_pr_diff, fetch_pr_metadata, resolve_pr_number};
18+
1219
pub 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

Comments
 (0)