git_iris/changes/
change_analyzer.rs1use super::models::{ChangeMetrics, ChangelogType};
2use crate::context::{ChangeType, RecentCommit};
3use crate::git::GitRepo;
4use anyhow::Result;
5use git2::{Diff, Oid};
6use regex::Regex;
7use std::sync::Arc;
8
9#[derive(Debug, Clone)]
11pub struct AnalyzedChange {
12 pub commit_hash: String,
13 pub commit_message: String,
14 pub author: String,
15 pub file_changes: Vec<FileChange>,
16 pub metrics: ChangeMetrics,
17 pub impact_score: f32,
18 pub change_type: ChangelogType,
19 pub is_breaking_change: bool,
20 pub associated_issues: Vec<String>,
21 pub pull_request: Option<String>,
22}
23
24#[derive(Debug, Clone)]
26pub struct FileChange {
27 pub old_path: String,
28 pub new_path: String,
29 pub change_type: ChangeType,
30 pub analysis: Vec<String>,
31}
32
33pub struct ChangeAnalyzer {
35 git_repo: Arc<GitRepo>,
36}
37
38impl ChangeAnalyzer {
39 pub fn new(git_repo: Arc<GitRepo>) -> Result<Self> {
41 Ok(Self { git_repo })
42 }
43
44 pub fn analyze_commits(&self, from: &str, to: &str) -> Result<Vec<AnalyzedChange>> {
46 self.git_repo
47 .get_commits_between_with_callback(from, to, |commit| self.analyze_commit(commit))
48 }
49
50 pub fn analyze_changes(
52 &self,
53 from: &str,
54 to: &str,
55 ) -> Result<(Vec<AnalyzedChange>, ChangeMetrics)> {
56 let analyzed_changes = self.analyze_commits(from, to)?;
57 let total_metrics = self.calculate_total_metrics(&analyzed_changes);
58 Ok((analyzed_changes, total_metrics))
59 }
60
61 fn analyze_commit(&self, commit: &RecentCommit) -> Result<AnalyzedChange> {
63 let repo = self.git_repo.open_repo()?;
64 let commit_obj = repo.find_commit(Oid::from_str(&commit.hash)?)?;
65
66 let parent_tree = if commit_obj.parent_count() > 0 {
67 Some(commit_obj.parent(0)?.tree()?)
68 } else {
69 None
70 };
71
72 let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_obj.tree()?), None)?;
73
74 let file_changes = Self::analyze_file_changes(&diff)?;
75 let metrics = Self::calculate_metrics(&diff)?;
76 let change_type = Self::classify_change(&commit.message, &file_changes);
77 let is_breaking_change = Self::detect_breaking_change(&commit.message, &file_changes);
78 let associated_issues = Self::extract_associated_issues(&commit.message);
79 let pull_request = Self::extract_pull_request(&commit.message);
80 let impact_score =
81 Self::calculate_impact_score(&metrics, &file_changes, is_breaking_change);
82
83 Ok(AnalyzedChange {
84 commit_hash: commit.hash.clone(),
85 commit_message: commit.message.clone(),
86 author: commit.author.clone(),
87 file_changes,
88 metrics,
89 impact_score,
90 change_type,
91 is_breaking_change,
92 associated_issues,
93 pull_request,
94 })
95 }
96
97 fn analyze_file_changes(diff: &Diff) -> Result<Vec<FileChange>> {
99 let mut file_changes = Vec::new();
100
101 diff.foreach(
102 &mut |delta, _| {
103 let old_file = delta.old_file();
104 let new_file = delta.new_file();
105 let change_type = match delta.status() {
106 git2::Delta::Added => ChangeType::Added,
107 git2::Delta::Deleted => ChangeType::Deleted,
108 _ => ChangeType::Modified,
109 };
110
111 let file_change = FileChange {
112 old_path: old_file
113 .path()
114 .map(|p| p.to_string_lossy().into_owned())
115 .unwrap_or_default(),
116 new_path: new_file
117 .path()
118 .map(|p| p.to_string_lossy().into_owned())
119 .unwrap_or_default(),
120 change_type,
121 analysis: vec![], };
123
124 file_changes.push(file_change);
125 true
126 },
127 None,
128 None,
129 None,
130 )?;
131
132 Ok(file_changes)
133 }
134
135 fn calculate_metrics(diff: &Diff) -> Result<ChangeMetrics> {
137 let stats = diff.stats()?;
138 Ok(ChangeMetrics {
139 total_commits: 1,
140 files_changed: stats.files_changed(),
141 insertions: stats.insertions(),
142 deletions: stats.deletions(),
143 total_lines_changed: stats.insertions() + stats.deletions(),
144 })
145 }
146
147 fn classify_change(commit_message: &str, file_changes: &[FileChange]) -> ChangelogType {
149 let message_lower = commit_message.to_lowercase();
150
151 if message_lower.contains("add") || message_lower.contains("new") {
153 return ChangelogType::Added;
154 } else if message_lower.contains("deprecat") {
155 return ChangelogType::Deprecated;
156 } else if message_lower.contains("remov") || message_lower.contains("delet") {
157 return ChangelogType::Removed;
158 } else if message_lower.contains("fix") || message_lower.contains("bug") {
159 return ChangelogType::Fixed;
160 } else if message_lower.contains("secur") || message_lower.contains("vulnerab") {
161 return ChangelogType::Security;
162 }
163
164 let has_additions = file_changes
166 .iter()
167 .any(|fc| fc.change_type == ChangeType::Added);
168 let has_deletions = file_changes
169 .iter()
170 .any(|fc| fc.change_type == ChangeType::Deleted);
171
172 if has_additions && !has_deletions {
173 ChangelogType::Added
174 } else if has_deletions && !has_additions {
175 ChangelogType::Removed
176 } else {
177 ChangelogType::Changed
178 }
179 }
180
181 fn detect_breaking_change(commit_message: &str, file_changes: &[FileChange]) -> bool {
183 let message_lower = commit_message.to_lowercase();
184 if message_lower.contains("breaking change")
185 || message_lower.contains("breaking-change")
186 || message_lower.contains("major version")
187 {
188 return true;
189 }
190
191 file_changes.iter().any(|fc| {
193 fc.analysis.iter().any(|analysis| {
194 analysis.to_lowercase().contains("breaking change")
195 || analysis.to_lowercase().contains("api change")
196 || analysis.to_lowercase().contains("incompatible")
197 })
198 })
199 }
200
201 fn extract_associated_issues(commit_message: &str) -> Vec<String> {
203 let re = Regex::new(r"(?:#|GH-)(\d+)")
204 .expect("Failed to compile issue number regex pattern - this is a bug");
205 re.captures_iter(commit_message)
206 .map(|cap| format!("#{}", &cap[1]))
207 .collect()
208 }
209
210 fn extract_pull_request(commit_message: &str) -> Option<String> {
212 let re = Regex::new(r"(?i)(?:pull request|PR)\s*#?(\d+)")
213 .expect("Failed to compile pull request regex pattern - this is a bug");
214 re.captures(commit_message)
215 .map(|cap| format!("PR #{}", &cap[1]))
216 }
217
218 #[allow(clippy::cast_precision_loss)]
220 #[allow(clippy::as_conversions)]
221 fn calculate_impact_score(
222 metrics: &ChangeMetrics,
223 file_changes: &[FileChange],
224 is_breaking_change: bool,
225 ) -> f32 {
226 let base_score = (metrics.total_lines_changed as f32) / 100.0;
227 let file_score = file_changes.len() as f32 / 10.0;
228 let breaking_change_score = if is_breaking_change { 5.0 } else { 0.0 };
229
230 base_score + file_score + breaking_change_score
231 }
232
233 pub fn calculate_total_metrics(&self, changes: &[AnalyzedChange]) -> ChangeMetrics {
235 changes.iter().fold(
236 ChangeMetrics {
237 total_commits: changes.len(),
238 files_changed: 0,
239 insertions: 0,
240 deletions: 0,
241 total_lines_changed: 0,
242 },
243 |mut acc, change| {
244 acc.files_changed += change.metrics.files_changed;
245 acc.insertions += change.metrics.insertions;
246 acc.deletions += change.metrics.deletions;
247 acc.total_lines_changed += change.metrics.total_lines_changed;
248 acc
249 },
250 )
251 }
252}