1use anyhow::{bail, Context, Result};
2use clap::Args;
3use gn_core::note::NoteStatus;
4use gn_core::{Namespace, Note, NotesEngine};
5use serde::Serialize;
6use std::path::Path;
7use std::process::Command;
8
9#[derive(Args, Debug, Clone)]
10pub struct CheckArgs {
11 #[arg(long, default_value_t = 1)]
13 pub min_approvals: usize,
14
15 #[arg(long)]
17 pub no_unresolved: bool,
18
19 #[arg(short, long)]
21 pub namespace: Option<String>,
22
23 #[arg(short, long)]
25 pub commit: Option<String>,
26
27 #[arg(long)]
29 pub json: bool,
30}
31
32#[derive(Serialize, Debug, Clone)]
33pub struct CheckSummary {
34 pub commit: String,
35 pub passed: bool,
36 pub total_notes: usize,
37 pub open_count: usize,
38 pub approved_count: usize,
39 pub rejected_count: usize,
40 pub resolved_count: usize,
41 pub min_approvals: usize,
42 pub no_unresolved: bool,
43 pub blocking_notes: Vec<BlockingNoteInfo>,
44 pub failure_reasons: Vec<String>,
45}
46
47#[derive(Serialize, Debug, Clone)]
48pub struct BlockingNoteInfo {
49 pub id: String,
50 pub file: Option<String>,
51 pub line_start: Option<u32>,
52 pub author: String,
53 pub status: String,
54 pub body: String,
55 pub reason: String,
56}
57
58pub fn resolve_commit(repo_path: &Path, commit_arg: Option<&str>) -> Result<String> {
60 let target = commit_arg.unwrap_or("HEAD");
61 let output = Command::new("git")
62 .args(["rev-parse", target])
63 .current_dir(repo_path)
64 .output()
65 .with_context(|| format!("Failed to execute 'git rev-parse {}'", target))?;
66
67 if !output.status.success() {
68 bail!("Failed to resolve commit '{}': git rev-parse returned non-zero exit code", target);
69 }
70
71 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
72 if sha.is_empty() {
73 bail!("Failed to resolve commit '{}': output was empty", target);
74 }
75 Ok(sha)
76}
77
78pub fn get_ancestor_commits(repo_path: &Path, target_commit: &str) -> Vec<String> {
80 if let Ok(output) = Command::new("git")
81 .args(["rev-list", "-n", "100", target_commit])
82 .current_dir(repo_path)
83 .output()
84 {
85 if output.status.success() {
86 return String::from_utf8_lossy(&output.stdout)
87 .lines()
88 .map(|s| s.trim().to_string())
89 .filter(|s| !s.is_empty())
90 .collect();
91 }
92 }
93 vec![target_commit.to_string()]
94}
95
96pub fn run(args: &CheckArgs) -> Result<()> {
97 run_in_repo(Path::new("."), args)
98}
99
100pub fn run_in_repo(repo_path: &Path, args: &CheckArgs) -> Result<()> {
101 let target_sha = resolve_commit(repo_path, args.commit.as_deref())?;
102 let engine = NotesEngine::new(repo_path);
103
104 let namespaces = match &args.namespace {
105 Some(ns) => vec![Namespace::from_str(ns)],
106 None => vec![
107 Namespace::Comments,
108 Namespace::Review,
109 Namespace::Todos,
110 ],
111 };
112
113 let mut all_notes: Vec<Note> = Vec::new();
114 for ns in &namespaces {
115 if let Ok(notes) = engine.read_notes(ns) {
116 all_notes.extend(notes);
117 }
118 }
119
120 let ancestors = get_ancestor_commits(repo_path, &target_sha);
121
122 all_notes.retain(|n| n.commit == target_sha || ancestors.contains(&n.commit));
124
125 let mut total_notes = 0;
126 let mut open_notes: Vec<&Note> = Vec::new();
127 let mut approved_notes: Vec<&Note> = Vec::new();
128 let mut rejected_notes: Vec<&Note> = Vec::new();
129 let mut resolved_notes: Vec<&Note> = Vec::new();
130
131 for note in &all_notes {
132 total_notes += 1;
133 match note.status {
134 NoteStatus::Open => open_notes.push(note),
135 NoteStatus::Approved => approved_notes.push(note),
136 NoteStatus::Rejected => rejected_notes.push(note),
137 NoteStatus::Resolved => resolved_notes.push(note),
138 }
139 }
140
141 let mut failure_reasons: Vec<String> = Vec::new();
142 let mut blocking_notes: Vec<BlockingNoteInfo> = Vec::new();
143
144 if !rejected_notes.is_empty() {
146 failure_reasons.push(format!(
147 "Found {} rejected note(s) requiring changes",
148 rejected_notes.len()
149 ));
150 for n in &rejected_notes {
151 blocking_notes.push(BlockingNoteInfo {
152 id: n.id.to_string(),
153 file: n.file.clone(),
154 line_start: n.line_start,
155 author: n.author.clone(),
156 status: "Rejected".to_string(),
157 body: n.body.clone(),
158 reason: "Note is marked as Rejected".to_string(),
159 });
160 }
161 }
162
163 if args.no_unresolved && !open_notes.is_empty() {
165 failure_reasons.push(format!(
166 "--no-unresolved specified and {} unresolved open note(s) remain",
167 open_notes.len()
168 ));
169 for n in &open_notes {
170 blocking_notes.push(BlockingNoteInfo {
171 id: n.id.to_string(),
172 file: n.file.clone(),
173 line_start: n.line_start,
174 author: n.author.clone(),
175 status: "Open".to_string(),
176 body: n.body.clone(),
177 reason: "Unresolved note under --no-unresolved rule".to_string(),
178 });
179 }
180 }
181
182 if approved_notes.len() < args.min_approvals {
184 failure_reasons.push(format!(
185 "Approval threshold not met: required {} approval(s), but found {}",
186 args.min_approvals,
187 approved_notes.len()
188 ));
189 }
190
191 let passed = failure_reasons.is_empty();
192
193 let summary = CheckSummary {
194 commit: target_sha.clone(),
195 passed,
196 total_notes,
197 open_count: open_notes.len(),
198 approved_count: approved_notes.len(),
199 rejected_count: rejected_notes.len(),
200 resolved_count: resolved_notes.len(),
201 min_approvals: args.min_approvals,
202 no_unresolved: args.no_unresolved,
203 blocking_notes,
204 failure_reasons: failure_reasons.clone(),
205 };
206
207 if args.json {
208 let json_str = serde_json::to_string_pretty(&summary)?;
209 println!("{}", json_str);
210 } else if passed {
211 println!(
212 "\x1b[32m✔ Quality gate passed: {} notes reviewed ({} approved, 0 blocking)\x1b[0m",
213 total_notes,
214 approved_notes.len()
215 );
216 } else {
217 let target_short = &target_sha[..target_sha.len().min(8)];
218 eprintln!(
219 "\n\x1b[1;31m✖ Quality gate failed for commit {}\x1b[0m",
220 target_short
221 );
222 for reason in &failure_reasons {
223 eprintln!(" \x1b[31m• {}\x1b[0m", reason);
224 }
225
226 if !summary.blocking_notes.is_empty() {
227 eprintln!("\n\x1b[1;33mBlocking Notes:\x1b[0m");
228 for bn in &summary.blocking_notes {
229 let short_id = &bn.id[..bn.id.len().min(8)];
230 let loc = match (&bn.file, bn.line_start) {
231 (Some(f), Some(l)) => format!("{}:{}", f, l),
232 (Some(f), None) => f.clone(),
233 (None, _) => "<global>".to_string(),
234 };
235 let author_short = bn.author.split('<').next().unwrap_or(&bn.author).trim();
236 let body_short = bn.body.replace('\n', " ");
237 let body_snippet = if body_short.len() > 60 {
238 format!("{}...", &body_short[..57])
239 } else {
240 body_short
241 };
242
243 let status_color = if bn.status == "Rejected" {
244 "\x1b[31mRejected\x1b[0m"
245 } else {
246 "\x1b[33mOpen\x1b[0m"
247 };
248
249 eprintln!(
250 " [{}] {} ({}) by {} - \"{}\" [{}]",
251 short_id, loc, status_color, author_short, body_snippet, bn.reason
252 );
253 }
254 }
255 eprintln!();
256 }
257
258 if !passed {
259 bail!("Quality gate failed: {}", failure_reasons.join("; "));
260 }
261
262 Ok(())
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use std::fs::File;
269 use std::io::Write;
270 use tempfile::TempDir;
271
272 fn setup_git_repo(dir: &Path) {
273 let run = |args: &[&str]| {
274 let out = Command::new("git")
275 .args(args)
276 .current_dir(dir)
277 .output()
278 .expect("git failed");
279 assert!(out.status.success());
280 };
281 run(&["init"]);
282 run(&["config", "user.email", "test@test.com"]);
283 run(&["config", "user.name", "Test User"]);
284
285 let file_path = dir.join("main.rs");
286 let mut f = File::create(&file_path).unwrap();
287 writeln!(f, "fn main() {{}}").unwrap();
288 run(&["add", "main.rs"]);
289 run(&["commit", "-m", "Initial commit"]);
290 }
291
292 #[test]
293 fn test_gate_pass_with_approval() {
294 let temp = TempDir::new().unwrap();
295 setup_git_repo(temp.path());
296
297 let sha = resolve_commit(temp.path(), None).unwrap();
298 let engine = NotesEngine::new(temp.path());
299
300 let mut note = Note::new(
301 sha.clone(),
302 Some("main.rs".to_string()),
303 Some(1),
304 Some(1),
305 "LGTM!".to_string(),
306 "Reviewer <rev@example.com>".to_string(),
307 Namespace::Comments,
308 );
309 note.status = NoteStatus::Approved;
310 engine.write_note(¬e).unwrap();
311
312 let args = CheckArgs {
313 min_approvals: 1,
314 no_unresolved: true,
315 namespace: None,
316 commit: None,
317 json: false,
318 };
319
320 let res = run_in_repo(temp.path(), &args);
321 assert!(res.is_ok());
322 }
323
324 #[test]
325 fn test_gate_fail_insufficient_approvals() {
326 let temp = TempDir::new().unwrap();
327 setup_git_repo(temp.path());
328
329 let args = CheckArgs {
330 min_approvals: 1,
331 no_unresolved: false,
332 namespace: None,
333 commit: None,
334 json: false,
335 };
336
337 let res = run_in_repo(temp.path(), &args);
338 assert!(res.is_err());
339 assert!(res.unwrap_err().to_string().contains("Approval threshold not met"));
340 }
341
342 #[test]
343 fn test_gate_fail_rejected_note() {
344 let temp = TempDir::new().unwrap();
345 setup_git_repo(temp.path());
346
347 let sha = resolve_commit(temp.path(), None).unwrap();
348 let engine = NotesEngine::new(temp.path());
349
350 let mut note1 = Note::new(
352 sha.clone(),
353 Some("main.rs".to_string()),
354 Some(1),
355 Some(1),
356 "Approved note".to_string(),
357 "Lead <lead@example.com>".to_string(),
358 Namespace::Comments,
359 );
360 note1.status = NoteStatus::Approved;
361 engine.write_note(¬e1).unwrap();
362
363 let mut note2 = Note::new(
365 sha.clone(),
366 Some("main.rs".to_string()),
367 Some(1),
368 Some(1),
369 "Needs rework!".to_string(),
370 "Senior <snr@example.com>".to_string(),
371 Namespace::Comments,
372 );
373 note2.status = NoteStatus::Rejected;
374 engine.write_note(¬e2).unwrap();
375
376 let args = CheckArgs {
377 min_approvals: 1,
378 no_unresolved: false,
379 namespace: None,
380 commit: None,
381 json: false,
382 };
383
384 let res = run_in_repo(temp.path(), &args);
385 assert!(res.is_err());
386 assert!(res.unwrap_err().to_string().contains("rejected note"));
387 }
388
389 #[test]
390 fn test_gate_fail_unresolved_open_notes() {
391 let temp = TempDir::new().unwrap();
392 setup_git_repo(temp.path());
393
394 let sha = resolve_commit(temp.path(), None).unwrap();
395 let engine = NotesEngine::new(temp.path());
396
397 let mut note1 = Note::new(
398 sha.clone(),
399 Some("main.rs".to_string()),
400 Some(1),
401 Some(1),
402 "Approved note".to_string(),
403 "Lead <lead@example.com>".to_string(),
404 Namespace::Comments,
405 );
406 note1.status = NoteStatus::Approved;
407 engine.write_note(¬e1).unwrap();
408
409 let note2 = Note::new(
410 sha.clone(),
411 Some("main.rs".to_string()),
412 Some(1),
413 Some(1),
414 "Questions about this".to_string(),
415 "Junior <jr@example.com>".to_string(),
416 Namespace::Comments,
417 );
418 engine.write_note(¬e2).unwrap();
420
421 let args = CheckArgs {
422 min_approvals: 1,
423 no_unresolved: true,
424 namespace: None,
425 commit: None,
426 json: false,
427 };
428
429 let res = run_in_repo(temp.path(), &args);
430 assert!(res.is_err());
431 assert!(res.unwrap_err().to_string().contains("--no-unresolved"));
432 }
433}