Skip to main content

garbage_code_hunter/pr_title_hunter/
mod.rs

1//! PR Title Hunter module.
2//!
3//! Scans PR titles from git merge commits or GitHub API,
4//! checks them against quality rules, and generates a roasting report.
5
6pub mod github;
7pub mod report;
8pub mod rules;
9pub mod types;
10
11use anyhow::{Context, Result};
12use git2::Repository;
13use report::{format_json, format_terminal};
14use rules::check_prs;
15use std::path::Path;
16
17use types::{PrEntry, PrSource};
18
19pub use crate::common::OutputFormat;
20
21/// Run PR title analysis on a local git repository.
22///
23/// Extracts PR titles from merge commits and checks them.
24pub fn run(repo_path: &Path, limit: usize, format: &OutputFormat) -> Result<String> {
25    let prs = extract_prs_from_merges(repo_path, limit)?;
26
27    if prs.is_empty() {
28        return match format {
29            OutputFormat::Terminal => Ok(format!(
30                "\n{}\n\n  No merge commits found in {}\n",
31                "\u{1f3af} PR Title Roast Report \u{1f3af}",
32                repo_path.display()
33            )),
34            OutputFormat::Json => {
35                Ok(r#"{"score":100,"total_prs":0,"issues":[],"stats":{"avg_title_length":0.0,"issues_found":0}}"#.to_string())
36            }
37        };
38    }
39
40    let issues = check_prs(&prs);
41
42    let output = match format {
43        OutputFormat::Terminal => format_terminal(&prs, &issues),
44        OutputFormat::Json => format_json(&prs, &issues),
45    };
46
47    Ok(output)
48}
49
50/// Extract PR entries from merge commits in the repository.
51fn extract_prs_from_merges(repo_path: &Path, limit: usize) -> Result<Vec<PrEntry>> {
52    let repo = Repository::open(repo_path)
53        .with_context(|| format!("Failed to open repo at {}", repo_path.display()))?;
54
55    let mut revwalk = repo.revwalk()?;
56    revwalk.set_sorting(git2::Sort::TIME)?;
57
58    let mut prs = Vec::new();
59
60    for oid_result in revwalk {
61        let oid = oid_result?;
62        let commit = repo.find_commit(oid)?;
63
64        // Only look at merge commits (2+ parents)
65        if commit.parent_count() < 2 {
66            continue;
67        }
68
69        let message = commit.message().unwrap_or("");
70        let title = message.lines().next().unwrap_or("").trim();
71
72        // Skip empty merge commit messages
73        if title.is_empty() {
74            continue;
75        }
76
77        let short_hash = format!("{:.7}", commit.id());
78        let author = commit.author().name().ok().map(|s| s.to_string());
79
80        prs.push(PrEntry {
81            id: short_hash,
82            title: title.to_string(),
83            author,
84            source: PrSource::Local,
85        });
86
87        if prs.len() >= limit {
88            break;
89        }
90    }
91
92    Ok(prs)
93}
94
95/// Analyze PRs and return structured data (for CLI integration).
96pub fn analyze(repo_path: &Path, limit: usize) -> Result<(Vec<PrEntry>, Vec<types::PrIssue>)> {
97    let prs = extract_prs_from_merges(repo_path, limit)?;
98    let issues = check_prs(&prs);
99    Ok((prs, issues))
100}
101
102/// Run PR title analysis on a remote GitHub repository.
103pub fn run_remote(config: &github::GitHubConfig, format: &OutputFormat) -> Result<String> {
104    let prs = github::fetch_prs(config)?;
105
106    if prs.is_empty() {
107        return match format {
108            OutputFormat::Terminal => Ok(format!(
109                "\n{}\n\n  No PRs found for {}\n",
110                "\u{1f3af} PR Title Roast Report \u{1f3af}",
111                config.repo
112            )),
113            OutputFormat::Json => {
114                Ok(r#"{"score":100,"total_prs":0,"issues":[],"stats":{"avg_title_length":0.0,"issues_found":0}}"#.to_string())
115            }
116        };
117    }
118
119    let issues = check_prs(&prs);
120
121    let output = match format {
122        OutputFormat::Terminal => format_terminal(&prs, &issues),
123        OutputFormat::Json => format_json(&prs, &issues),
124    };
125
126    Ok(output)
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_run_on_nonexistent_path() {
135        let path = Path::new("/nonexistent/repo");
136        let result = run(path, 10, &OutputFormat::Terminal);
137        assert!(result.is_err());
138    }
139
140    #[test]
141    fn test_run_on_real_repo() {
142        // Use the current repo — should have merge commits
143        let path = Path::new(".");
144        let result = run(path, 5, &OutputFormat::Terminal);
145        // Should succeed even if no merge commits found
146        assert!(result.is_ok());
147    }
148
149    #[test]
150    fn test_run_json_format() {
151        let path = Path::new(".");
152        let result = run(path, 5, &OutputFormat::Json);
153        assert!(result.is_ok());
154        let json_str = result.unwrap();
155        let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
156        assert!(parsed["score"].as_f64().is_some());
157    }
158}