|
1 | | -use crate::core::diff_parser::UnifiedDiff; |
2 | | - |
3 | | -#[derive(Debug, Clone, Copy, PartialEq, Eq)] |
4 | | -pub enum TriageResult { |
5 | | - NeedsReview, |
6 | | - SkipLockFile, |
7 | | - SkipWhitespaceOnly, |
8 | | - SkipDeletionOnly, |
9 | | - SkipGenerated, |
10 | | - SkipCommentOnly, |
11 | | -} |
12 | | - |
13 | | -impl TriageResult { |
14 | | - pub fn should_skip(&self) -> bool { |
15 | | - !matches!(self, TriageResult::NeedsReview) |
16 | | - } |
17 | | - |
18 | | - pub fn reason(&self) -> &'static str { |
19 | | - match self { |
20 | | - TriageResult::NeedsReview => "needs review", |
21 | | - TriageResult::SkipLockFile => "lock file", |
22 | | - TriageResult::SkipWhitespaceOnly => "whitespace-only changes", |
23 | | - TriageResult::SkipDeletionOnly => "deletion-only changes", |
24 | | - TriageResult::SkipGenerated => "generated file", |
25 | | - TriageResult::SkipCommentOnly => "comment-only changes", |
26 | | - } |
27 | | - } |
28 | | -} |
29 | | - |
30 | | -/// Lock file names that should be auto-skipped. |
31 | | -const LOCK_FILES: &[&str] = &[ |
32 | | - "Cargo.lock", |
33 | | - "package-lock.json", |
34 | | - "yarn.lock", |
35 | | - "pnpm-lock.yaml", |
36 | | - "Gemfile.lock", |
37 | | - "poetry.lock", |
38 | | - "composer.lock", |
39 | | - "go.sum", |
40 | | - "Pipfile.lock", |
41 | | -]; |
42 | | - |
43 | | -/// Comment line prefixes (after trimming leading whitespace). |
44 | | -const COMMENT_PREFIXES: &[&str] = &["//", "# ", "/*", "*/", "* ", "--", "<!--", "\"\"\"", "'''"]; |
45 | | - |
46 | | -/// Patterns that start with `#` but are NOT comments (Rust attributes, C preprocessor, etc.). |
47 | | -const HASH_NON_COMMENT_PREFIXES: &[&str] = &[ |
48 | | - "#[", "#![", "#!/", "#include", "#define", "#ifdef", "#ifndef", "#endif", "#pragma", "#undef", |
49 | | - "#elif", "#else", "#if ", "#error", "#warning", "#line", |
50 | | -]; |
51 | | - |
52 | | -/// Classify a diff using fast heuristics (no LLM call). |
53 | | -/// |
54 | | -/// Checks are applied in priority order: |
55 | | -/// 1. Lock files |
56 | | -/// 2. Generated files |
57 | | -/// 3. Deletion-only changes |
58 | | -/// 4. Whitespace-only changes |
59 | | -/// 5. Comment-only changes |
60 | | -/// 6. Default → NeedsReview |
61 | | -pub fn triage_diff(diff: &UnifiedDiff) -> TriageResult { |
62 | | - let path_str = diff.file_path.to_string_lossy(); |
63 | | - |
64 | | - // 1. Lock files — match by file name (final component) |
65 | | - if let Some(file_name) = diff.file_path.file_name().and_then(|n| n.to_str()) { |
66 | | - if LOCK_FILES.contains(&file_name) { |
67 | | - return TriageResult::SkipLockFile; |
68 | | - } |
69 | | - } |
70 | | - |
71 | | - // 2. Generated files — match by path patterns and extensions |
72 | | - if is_generated_file(&path_str) { |
73 | | - return TriageResult::SkipGenerated; |
74 | | - } |
75 | | - |
76 | | - // Collect all non-context changes across all hunks |
77 | | - let all_changes: Vec<&DiffLine> = diff |
78 | | - .hunks |
79 | | - .iter() |
80 | | - .flat_map(|h| h.changes.iter()) |
81 | | - .filter(|c| !matches!(c.change_type, ChangeType::Context)) |
82 | | - .collect(); |
83 | | - |
84 | | - // If there are no actual changes, default to NeedsReview |
85 | | - if all_changes.is_empty() { |
86 | | - return TriageResult::NeedsReview; |
87 | | - } |
88 | | - |
89 | | - // 3. Deletion-only — all non-context lines are Removed |
90 | | - if all_changes |
91 | | - .iter() |
92 | | - .all(|c| matches!(c.change_type, ChangeType::Removed)) |
93 | | - { |
94 | | - return TriageResult::SkipDeletionOnly; |
95 | | - } |
96 | | - |
97 | | - // 4. Whitespace-only — every added line has a corresponding removed line |
98 | | - // that differs only in whitespace |
99 | | - if is_whitespace_only_change(&all_changes) { |
100 | | - return TriageResult::SkipWhitespaceOnly; |
101 | | - } |
102 | | - |
103 | | - // 5. Comment-only — all changed lines are comment lines |
104 | | - if all_changes.iter().all(|c| is_comment_line(&c.content)) { |
105 | | - return TriageResult::SkipCommentOnly; |
106 | | - } |
107 | | - |
108 | | - // 6. Default |
109 | | - TriageResult::NeedsReview |
110 | | -} |
111 | | - |
112 | | -/// Check if a file path matches generated-file patterns. |
113 | | -fn is_generated_file(path: &str) -> bool { |
114 | | - // Path contains marker segments |
115 | | - if path.contains(".generated.") |
116 | | - || path.contains(".g.") |
117 | | - || path.starts_with("_generated/") |
118 | | - || path.contains("/_generated/") |
119 | | - || path.starts_with("generated/") |
120 | | - || path.contains("/generated/") |
121 | | - { |
122 | | - return true; |
123 | | - } |
124 | | - |
125 | | - // File extension patterns |
126 | | - if path.ends_with(".pb.go") |
127 | | - || path.ends_with(".pb.rs") |
128 | | - || path.ends_with(".swagger.json") |
129 | | - || path.ends_with(".min.js") |
130 | | - || path.ends_with(".min.css") |
131 | | - { |
132 | | - return true; |
133 | | - } |
134 | | - |
135 | | - // Vendor prefix |
136 | | - if path.starts_with("vendor/") { |
137 | | - return true; |
138 | | - } |
139 | | - |
140 | | - false |
141 | | -} |
142 | | - |
143 | | -/// Check if all changes are whitespace-only by comparing stripped content |
144 | | -/// of removed vs added lines. |
145 | | -fn is_whitespace_only_change(changes: &[&DiffLine]) -> bool { |
146 | | - let removed: Vec<&str> = changes |
147 | | - .iter() |
148 | | - .filter(|c| matches!(c.change_type, ChangeType::Removed)) |
149 | | - .map(|c| c.content.as_str()) |
150 | | - .collect(); |
151 | | - |
152 | | - let added: Vec<&str> = changes |
153 | | - .iter() |
154 | | - .filter(|c| matches!(c.change_type, ChangeType::Added)) |
155 | | - .map(|c| c.content.as_str()) |
156 | | - .collect(); |
157 | | - |
158 | | - // Must have the same number of added and removed lines |
159 | | - if removed.len() != added.len() { |
160 | | - return false; |
161 | | - } |
162 | | - |
163 | | - // Each pair must differ only in whitespace |
164 | | - removed |
165 | | - .iter() |
166 | | - .zip(added.iter()) |
167 | | - .all(|(r, a)| strip_whitespace(r) == strip_whitespace(a)) |
168 | | -} |
169 | | - |
170 | | -/// Remove all whitespace characters from a string for comparison. |
171 | | -fn strip_whitespace(s: &str) -> String { |
172 | | - s.chars().filter(|c| !c.is_whitespace()).collect() |
173 | | -} |
174 | | - |
175 | | -/// Check if a line (after trimming leading whitespace) starts with a comment prefix. |
176 | | -fn is_comment_line(content: &str) -> bool { |
177 | | - let trimmed = content.trim(); |
178 | | - if trimmed.is_empty() { |
179 | | - // Blank lines in a comment-only change are fine |
180 | | - return true; |
181 | | - } |
182 | | - // Handle `#` lines: treat as comment UNLESS it matches a known non-comment prefix |
183 | | - // (Rust attributes, C preprocessor, etc.) |
184 | | - if trimmed.starts_with('#') { |
185 | | - // If it matches a non-comment prefix like #[, #include, etc., it's code |
186 | | - if HASH_NON_COMMENT_PREFIXES |
187 | | - .iter() |
188 | | - .any(|p| trimmed.starts_with(p)) |
189 | | - { |
190 | | - return false; |
191 | | - } |
192 | | - // Otherwise bare `#`, `#comment`, `# comment` are all comments |
193 | | - return true; |
194 | | - } |
195 | | - COMMENT_PREFIXES |
196 | | - .iter() |
197 | | - .any(|prefix| trimmed.starts_with(prefix)) |
198 | | -} |
199 | | - |
200 | | -use crate::core::diff_parser::{ChangeType, DiffLine}; |
| 1 | +#[path = "triage/changes.rs"] |
| 2 | +mod changes; |
| 3 | +#[path = "triage/comments.rs"] |
| 4 | +mod comments; |
| 5 | +#[path = "triage/files.rs"] |
| 6 | +mod files; |
| 7 | +#[path = "triage/result.rs"] |
| 8 | +mod result; |
| 9 | +#[path = "triage/run.rs"] |
| 10 | +mod run; |
| 11 | + |
| 12 | +#[allow(unused_imports)] |
| 13 | +pub use result::TriageResult; |
| 14 | +pub use run::triage_diff; |
201 | 15 |
|
202 | 16 | #[cfg(test)] |
203 | 17 | mod tests { |
|
0 commit comments