Skip to main content

autom8/claude/
pr_review.rs

1//! PR review analysis.
2//!
3//! Analyzes PR comments and fixes real issues while ignoring red herrings.
4
5use 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/// Summary of the PR review analysis
16#[derive(Debug, Clone, PartialEq, Default)]
17pub struct PRReviewSummary {
18    /// Total number of comments analyzed
19    pub total_comments: usize,
20    /// Number of real issues that were fixed
21    pub real_issues_fixed: usize,
22    /// Number of red herrings identified
23    pub red_herrings: usize,
24    /// Number of legitimate suggestions (no action taken)
25    pub legitimate_suggestions: usize,
26}
27
28impl PRReviewSummary {
29    /// Parse summary from Claude's output text.
30    pub fn parse_from_output(output: &str) -> Self {
31        let mut summary = PRReviewSummary::default();
32
33        // Look for summary section
34        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
48/// Parse a number from summary text matching a pattern like "**Label:** X"
49fn 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/// Result from running the PR review agent
68#[derive(Debug, Clone, PartialEq)]
69pub enum PRReviewResult {
70    /// Review completed successfully with summary of findings
71    Complete(PRReviewSummary),
72    /// Review completed but no fixes were needed
73    NoFixesNeeded(PRReviewSummary),
74    /// Error occurred during review
75    Error(ClaudeErrorInfo),
76}
77
78/// Run the PR review agent to analyze PR comments and fix real issues.
79pub 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    // Build spec context section
158    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    // Build commit history section
190    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    // Build unresolved comments section
202    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}