1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4use reposcry_context::ContextPack;
5use reposcry_git::{GitChange, GitIntegration};
6use reposcry_graph::edge::EdgeKind;
7use reposcry_graph::graph::CodeGraph;
8use reposcry_rules::{RuleViolation, RulesEngine};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ReviewReport {
12 pub summary: ReportSummary,
13 pub changed_files: Vec<GitChange>,
14 pub high_risk_changes: Vec<RiskItem>,
15 pub new_dependencies: Vec<String>,
16 pub new_cycles: Vec<Vec<String>>,
17 pub violations: Vec<RuleViolation>,
18 pub suggested_reviewers: Vec<String>,
19 pub suggested_tests: Vec<String>,
20 pub context: Option<ContextPack>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ReportSummary {
25 pub changed_files_count: u32,
26 pub impacted_files_count: u32,
27 pub risk: RiskLevel,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub enum RiskLevel {
32 Low,
33 Medium,
34 High,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct RiskItem {
39 pub file: String,
40 pub reason: String,
41}
42
43pub fn generate_report(
44 graph: &CodeGraph,
45 git: &GitIntegration,
46 rules_engine: &RulesEngine,
47 base: &str,
48 head: &str,
49) -> Result<ReviewReport> {
50 let changes = git.diff_files(base, head)?;
52 let changed_paths: Vec<String> = changes.iter().map(|c| c.path.clone()).collect();
53
54 let mut impacted = Vec::new();
56 for edge in graph
57 .edges
58 .iter()
59 .filter(|edge| edge.kind == EdgeKind::Imports)
60 {
61 let source_path = graph
62 .nodes
63 .get(&edge.source_id)
64 .and_then(|n| n.file_path.as_deref());
65 let target_path = graph
66 .nodes
67 .get(&edge.target_id)
68 .and_then(|n| n.file_path.as_deref());
69 if let Some(tp) = target_path {
70 if changed_paths.contains(&tp.to_string()) {
71 if let Some(sp) = source_path {
72 if !changed_paths.contains(&sp.to_string()) {
73 impacted.push(sp.to_string());
74 }
75 }
76 }
77 }
78 }
79 impacted.sort();
80 impacted.dedup();
81
82 let risk = if impacted.len() > 20 {
84 RiskLevel::High
85 } else if impacted.len() > 5 {
86 RiskLevel::Medium
87 } else {
88 RiskLevel::Low
89 };
90
91 let mut high_risk = Vec::new();
93 for path in &changed_paths {
94 let rdeps = graph
95 .edges
96 .iter()
97 .filter(|e| e.kind == EdgeKind::Imports)
98 .filter(|e| {
99 graph
100 .nodes
101 .get(&e.target_id)
102 .and_then(|n| n.file_path.as_deref())
103 == Some(path.as_str())
104 })
105 .count();
106 if rdeps > 5 {
107 high_risk.push(RiskItem {
108 file: path.clone(),
109 reason: format!("high fan-in ({} dependents), changed", rdeps),
110 });
111 }
112 }
113
114 let cycles = graph.detect_cycles();
116 let new_cycles: Vec<Vec<String>> = cycles
117 .into_iter()
118 .map(|cycle| {
119 cycle
120 .iter()
121 .filter_map(|id| graph.get_node(*id))
122 .map(|n| n.name.clone())
123 .collect()
124 })
125 .collect();
126
127 let violations = rules_engine.check_graph(graph);
129
130 let mut suggested_reviewers = Vec::new();
132 for path in &changed_paths {
133 if let Ok(owner) = git.file_owner(path) {
134 if !suggested_reviewers.contains(&owner) {
135 suggested_reviewers.push(owner);
136 }
137 }
138 }
139
140 Ok(ReviewReport {
141 summary: ReportSummary {
142 changed_files_count: changes.len() as u32,
143 impacted_files_count: impacted.len() as u32,
144 risk,
145 },
146 changed_files: changes,
147 high_risk_changes: high_risk,
148 new_dependencies: Vec::new(),
149 new_cycles,
150 violations,
151 suggested_reviewers,
152 suggested_tests: Vec::new(),
153 context: None,
154 })
155}
156
157pub fn render_markdown(report: &ReviewReport) -> String {
158 let mut md = String::new();
159 md.push_str("# RepoScry Report\n\n");
160 md.push_str(&format!(
161 "## Summary\n\nChanged files: {}\nImpacted files: {}\nRisk: {:?}\n\n",
162 report.summary.changed_files_count,
163 report.summary.impacted_files_count,
164 report.summary.risk
165 ));
166 if !report.high_risk_changes.is_empty() {
167 md.push_str("## High-risk changes\n\n| File | Reason |\n|---|---|\n");
168 for item in &report.high_risk_changes {
169 md.push_str(&format!("| {} | {} |\n", item.file, item.reason));
170 }
171 md.push('\n');
172 }
173 if !report.new_cycles.is_empty() {
174 md.push_str("## New dependency cycles\n\n");
175 for cycle in &report.new_cycles {
176 md.push_str(&format!("- {}\n", cycle.join(" → ")));
177 }
178 md.push('\n');
179 }
180 if !report.violations.is_empty() {
181 md.push_str("## Architecture violations\n\n");
182 for v in &report.violations {
183 md.push_str(&format!(
184 "- [{}] {}: {}\n",
185 v.severity.as_str(),
186 v.rule,
187 v.message
188 ));
189 }
190 md.push('\n');
191 }
192 if !report.suggested_reviewers.is_empty() {
193 md.push_str("## Suggested reviewers\n\n");
194 for reviewer in &report.suggested_reviewers {
195 md.push_str(&format!("- {}\n", reviewer));
196 }
197 md.push('\n');
198 }
199 md
200}