autom8/claude/
pr_review.rs1use std::io::{BufRead, BufReader, Write};
6use std::process::{Command, Stdio};
7
8use crate::error::{Autom8Error, Result};
9use crate::gh::{BranchContext, PRContext};
10use crate::prompts::PR_REVIEW_PROMPT;
11
12use super::stream::extract_text_from_stream_line;
13use super::types::ClaudeErrorInfo;
14
15#[derive(Debug, Clone, PartialEq, Default)]
17pub struct PRReviewSummary {
18 pub total_comments: usize,
20 pub real_issues_fixed: usize,
22 pub red_herrings: usize,
24 pub legitimate_suggestions: usize,
26}
27
28impl PRReviewSummary {
29 pub fn parse_from_output(output: &str) -> Self {
31 let mut summary = PRReviewSummary::default();
32
33 if let Some(summary_start) = output.find("## Summary") {
35 let summary_text = &output[summary_start..];
36
37 summary.total_comments = parse_summary_number(summary_text, "total comments analyzed");
38 summary.real_issues_fixed = parse_summary_number(summary_text, "real issues fixed");
39 summary.red_herrings = parse_summary_number(summary_text, "red herrings identified");
40 summary.legitimate_suggestions =
41 parse_summary_number(summary_text, "legitimate suggestions");
42 }
43
44 summary
45 }
46}
47
48fn parse_summary_number(text: &str, label: &str) -> usize {
50 let label_lower = label.to_lowercase();
51 for line in text.lines() {
52 let line_lower = line.to_lowercase();
53 if line_lower.contains(&label_lower) {
54 for word in line.split_whitespace() {
55 if let Ok(num) = word
56 .trim_matches(|c: char| !c.is_ascii_digit())
57 .parse::<usize>()
58 {
59 return num;
60 }
61 }
62 }
63 }
64 0
65}
66
67#[derive(Debug, Clone, PartialEq)]
69pub enum PRReviewResult {
70 Complete(PRReviewSummary),
72 NoFixesNeeded(PRReviewSummary),
74 Error(ClaudeErrorInfo),
76}
77
78pub fn run_pr_review<F>(
80 pr_context: &PRContext,
81 branch_context: &BranchContext,
82 mut on_output: F,
83) -> Result<PRReviewResult>
84where
85 F: FnMut(&str),
86{
87 let prompt = build_pr_review_prompt(pr_context, branch_context);
88
89 let mut child = Command::new("claude")
90 .args([
91 "--dangerously-skip-permissions",
92 "--print",
93 "--output-format",
94 "stream-json",
95 "--verbose",
96 ])
97 .stdin(Stdio::piped())
98 .stdout(Stdio::piped())
99 .stderr(Stdio::piped())
100 .spawn()
101 .map_err(|e| Autom8Error::ClaudeError(format!("Failed to spawn claude: {}", e)))?;
102
103 if let Some(mut stdin) = child.stdin.take() {
104 stdin
105 .write_all(prompt.as_bytes())
106 .map_err(|e| Autom8Error::ClaudeError(format!("Failed to write to stdin: {}", e)))?;
107 }
108
109 let stderr = child.stderr.take();
110
111 let stdout = child
112 .stdout
113 .take()
114 .ok_or_else(|| Autom8Error::ClaudeError("Failed to capture stdout".into()))?;
115
116 let reader = BufReader::new(stdout);
117 let mut accumulated_text = String::new();
118
119 for line in reader.lines() {
120 let line = line.map_err(|e| Autom8Error::ClaudeError(format!("Read error: {}", e)))?;
121
122 if let Some(text) = extract_text_from_stream_line(&line) {
123 on_output(&text);
124 accumulated_text.push_str(&text);
125 }
126 }
127
128 let status = child
129 .wait()
130 .map_err(|e| Autom8Error::ClaudeError(format!("Wait error: {}", e)))?;
131
132 if !status.success() {
133 let stderr_content = stderr
134 .map(|s| std::io::read_to_string(s).unwrap_or_default())
135 .unwrap_or_default();
136 let error_info = ClaudeErrorInfo::from_process_failure(
137 status,
138 if stderr_content.is_empty() {
139 None
140 } else {
141 Some(stderr_content)
142 },
143 );
144 return Ok(PRReviewResult::Error(error_info));
145 }
146
147 let summary = PRReviewSummary::parse_from_output(&accumulated_text);
148
149 if summary.real_issues_fixed > 0 {
150 Ok(PRReviewResult::Complete(summary))
151 } else {
152 Ok(PRReviewResult::NoFixesNeeded(summary))
153 }
154}
155
156fn build_pr_review_prompt(pr_context: &PRContext, branch_context: &BranchContext) -> String {
157 let spec_context = match &branch_context.spec {
159 Some(spec) => {
160 let stories = spec
161 .user_stories
162 .iter()
163 .map(|s| {
164 let criteria = s
165 .acceptance_criteria
166 .iter()
167 .map(|c| format!(" - {}", c))
168 .collect::<Vec<_>>()
169 .join("\n");
170 format!(
171 "### {}: {}\n{}\n\n**Acceptance Criteria:**\n{}",
172 s.id, s.title, s.description, criteria
173 )
174 })
175 .collect::<Vec<_>>()
176 .join("\n\n");
177
178 format!(
179 "### Spec: {}\n\n**Description:**\n{}\n\n**User Stories:**\n\n{}",
180 spec.project, spec.description, stories
181 )
182 }
183 None => format!(
184 "*No spec file found for branch `{}`*\n\nThe review will proceed with reduced context.",
185 branch_context.branch_name
186 ),
187 };
188
189 let commit_history = if branch_context.commits.is_empty() {
191 "No commits found specific to this branch.".to_string()
192 } else {
193 branch_context
194 .commits
195 .iter()
196 .map(|c| format!("{} - {} ({})", c.short_hash, c.message, c.author))
197 .collect::<Vec<_>>()
198 .join("\n")
199 };
200
201 let unresolved_comments = pr_context
203 .unresolved_comments
204 .iter()
205 .enumerate()
206 .map(|(i, comment)| {
207 let location = match (&comment.file_path, comment.line) {
208 (Some(path), Some(line)) => format!("{}:{}", path, line),
209 (Some(path), None) => path.clone(),
210 _ => "PR conversation".to_string(),
211 };
212
213 format!(
214 "### Comment {} from @{} ({})\n\n> {}\n",
215 i + 1,
216 comment.author,
217 location,
218 comment.body.lines().collect::<Vec<_>>().join("\n> ")
219 )
220 })
221 .collect::<Vec<_>>()
222 .join("\n");
223
224 PR_REVIEW_PROMPT
225 .replace("{spec_context}", &spec_context)
226 .replace("{pr_description}", &pr_context.body)
227 .replace("{commit_history}", &commit_history)
228 .replace("{unresolved_comments}", &unresolved_comments)
229}