Skip to main content

gn_cli/commands/
check.rs

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    /// Require at least N Approved notes on target commit/branch
12    #[arg(long, default_value_t = 1)]
13    pub min_approvals: usize,
14
15    /// Fail if ANY note on current commit/branch is in Open status
16    #[arg(long)]
17    pub no_unresolved: bool,
18
19    /// Check a specific namespace or all namespaces
20    #[arg(short, long)]
21    pub namespace: Option<String>,
22
23    /// Check a specific commit or default to HEAD
24    #[arg(short, long)]
25    pub commit: Option<String>,
26
27    /// Output check results as structured JSON
28    #[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
58/// Resolves the target commit SHA (defaults to HEAD via `git rev-parse HEAD`)
59pub 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
78/// Retrieves commits on the branch/history up to target commit if needed, or matches exact commit
79pub 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    // Filter notes anchored to the target commit or its branch ancestors (prioritizing target commit)
123    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    // 1. Rejected notes always block
145    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    // 2. Open notes block if --no-unresolved is set
164    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    // 3. Approval threshold check
183    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(&note).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        // Approved note
351        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(&note1).unwrap();
362
363        // Rejected note
364        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(&note2).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(&note1).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        // NoteStatus::Open by default
419        engine.write_note(&note2).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}