gn_cli/commands/
summarize.rs1use anyhow::{Context, Result};
2use clap::Args;
3use std::env;
4use std::process::Command;
5
6#[derive(Args, Debug)]
7pub struct SummarizeArgs {
8 #[arg(short, long, default_value = "all")]
10 pub namespace: String,
11
12 #[arg(long)]
14 pub api_key: Option<String>,
15
16 #[arg(long, default_value = "true")]
18 pub open_only: bool,
19
20 #[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 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 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(¬e_blob[..8.min(note_blob.len())]);
85
86 all_notes_text.push_str(&format!(
87 "- [{}] [{}/{}:{}] {} — by {} (status: {})\n",
88 ¬e_id[..8.min(note_id.len())],
89 ns, file, line,
90 body,
91 author,
92 status
93 ));
94 total_count += 1;
95 } else {
96 all_notes_text.push_str(&format!(
98 "- [{}] [{}] {} (commit: {})\n",
99 ¬e_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 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}