Skip to main content

gn_cli/commands/
summarize.rs

1use anyhow::{Context, Result};
2use clap::Args;
3use std::env;
4use std::process::Command;
5
6#[derive(Args, Debug)]
7pub struct SummarizeArgs {
8    /// Namespace to summarize (comments, review, todos, or all)
9    #[arg(short, long, default_value = "all")]
10    pub namespace: String,
11
12    /// Gemini API key (or set GEMINI_API_KEY env var)
13    #[arg(long)]
14    pub api_key: Option<String>,
15
16    /// Only include open notes (default: true)
17    #[arg(long, default_value = "true")]
18    pub open_only: bool,
19
20    /// Output format: text or markdown
21    #[arg(long, default_value = "markdown")]
22    pub format: String,
23}
24
25pub fn run(args: &SummarizeArgs) -> Result<()> {
26    let api_key = args
27        .api_key
28        .clone()
29        .or_else(|| env::var("GEMINI_API_KEY").ok())
30        .context("No Gemini API key found. Set GEMINI_API_KEY or pass --api-key <key>")?;
31
32    let repo_path = std::path::Path::new(".");
33
34    // Collect notes from requested namespaces
35    let namespaces: Vec<&str> = if args.namespace == "all" {
36        vec!["comments", "review", "todos"]
37    } else {
38        vec![args.namespace.as_str()]
39    };
40
41    let mut all_notes_text = String::new();
42    let mut total_count = 0;
43
44    for ns in &namespaces {
45        let output = Command::new("git")
46            .args(["notes", "--ref", &format!("refs/notes/{}", ns), "list"])
47            .current_dir(repo_path)
48            .output();
49
50        if let Ok(out) = output {
51            if out.status.success() {
52                let list = String::from_utf8_lossy(&out.stdout);
53                for line in list.lines() {
54                    let parts: Vec<&str> = line.splitn(2, ' ').collect();
55                    if parts.len() < 2 {
56                        continue;
57                    }
58                    let note_blob = parts[0];
59                    let commit = parts[1];
60
61                    let content = Command::new("git")
62                        .args(["cat-file", "blob", note_blob])
63                        .current_dir(repo_path)
64                        .output()
65                        .ok()
66                        .and_then(|o| String::from_utf8(o.stdout).ok())
67                        .unwrap_or_default();
68
69                    if content.trim().is_empty() {
70                        continue;
71                    }
72
73                    // Try to parse as JSON Note, fall back to raw text
74                    if let Ok(note) = serde_json::from_str::<serde_json::Value>(&content) {
75                        let status = note["status"].as_str().unwrap_or("Open");
76                        if args.open_only && status != "Open" {
77                            continue;
78                        }
79
80                        let body = note["body"].as_str().unwrap_or("").trim().to_string();
81                        let author = note["author"].as_str().unwrap_or("Unknown");
82                        let file = note["file"].as_str().unwrap_or("");
83                        let line = note["line_start"].as_u64().unwrap_or(0);
84                        let note_id = note["id"].as_str().unwrap_or(&note_blob[..8.min(note_blob.len())]);
85
86                        all_notes_text.push_str(&format!(
87                            "- [{}] [{}/{}:{}] {} — by {} (status: {})\n",
88                            &note_id[..8.min(note_id.len())],
89                            ns, file, line,
90                            body,
91                            author,
92                            status
93                        ));
94                        total_count += 1;
95                    } else {
96                        // Raw note content
97                        all_notes_text.push_str(&format!(
98                            "- [{}] [{}] {} (commit: {})\n",
99                            &note_blob[..8.min(note_blob.len())],
100                            ns,
101                            content.lines().next().unwrap_or("").trim(),
102                            &commit[..8.min(commit.len())]
103                        ));
104                        total_count += 1;
105                    }
106                }
107            }
108        }
109    }
110
111    if total_count == 0 {
112        println!("No open notes found in namespace(s): {}", args.namespace);
113        println!("Try: git-notes sync pull  to fetch notes from remote");
114        return Ok(());
115    }
116
117    eprintln!("Summarizing {} note(s) via Gemini API...", total_count);
118
119    // Build prompt
120    let prompt = format!(
121        r#"You are a senior engineering lead reviewing a codebase before a release.
122
123Here are the open discussion threads and code annotations from the git-notes system:
124
125{}
126
127Please provide:
1281. **Executive Summary** — 2-3 sentences: what is the overall health of the open discussions?
1292. **Critical Issues** — Any notes that look like blockers, security concerns, or bugs (if none, say "None identified").
1303. **Open Discussions** — Group and summarize the active conversations by theme or file area.
1314. **Recommended Actions** — Specific next steps for the team, ordered by priority.
1325. **Quick Stats** — Total open: {}, breakdown by namespace if multiple.
133
134Be concise, technical, and actionable. Use markdown formatting."#,
135        all_notes_text,
136        total_count
137    );
138
139    let summary = call_gemini_api(&api_key, &prompt)?;
140
141    println!("\n{}\n", summary);
142
143    Ok(())
144}
145
146fn call_gemini_api(api_key: &str, prompt: &str) -> Result<String> {
147    let model = "gemini-2.0-flash";
148    let host = "generativelanguage.googleapis.com";
149    let path = format!(
150        "/v1beta/models/{}:generateContent?key={}",
151        model, api_key
152    );
153
154    let body = serde_json::json!({
155        "contents": [{
156            "parts": [{"text": prompt}]
157        }],
158        "generationConfig": {
159            "temperature": 0.3,
160            "maxOutputTokens": 2048
161        }
162    });
163
164    let body_str = serde_json::to_string(&body)?;
165
166    let output = Command::new("curl")
167        .args([
168            "-fsSL",
169            "-X", "POST",
170            "-H", "Content-Type: application/json",
171            "-d", &body_str,
172            &format!("https://{}{}", host, path),
173        ])
174        .output()
175        .context("curl not found — install curl to use git-notes summarize")?;
176
177    if !output.status.success() {
178        let err = String::from_utf8_lossy(&output.stderr);
179        anyhow::bail!("Gemini API request failed: {}", err);
180    }
181
182    let resp: serde_json::Value = serde_json::from_slice(&output.stdout)
183        .context("Failed to parse Gemini API response")?;
184
185    let text = resp["candidates"][0]["content"]["parts"][0]["text"]
186        .as_str()
187        .context("Unexpected Gemini API response format")?
188        .to_string();
189
190    Ok(text)
191}