1use std::env;
2
3use anyhow::Result;
4
5fn get_github_token() -> Result<String> {
8 if let Some(mut home) = dirs::home_dir() {
10 home.push(".deep/.env");
11 if home.exists() {
12 let _ = dotenvy::from_path(&home);
13 }
14 }
15
16 env::var("GITHUB_TOKEN")
17 .or_else(|_| env::var("GH_TOKEN"))
18 .map_err(|_| {
19 anyhow::anyhow!(
20 "GITHUB_TOKEN not found in ~/.deep/.env.\nPlease add: GITHUB_TOKEN=your_token"
21 )
22 })
23}
24
25fn create_client() -> Result<reqwest::Client> {
26 Ok(reqwest::Client::builder()
27 .timeout(std::time::Duration::from_secs(30))
28 .user_agent("deepseek-cli-agent")
29 .build()?)
30}
31
32async fn github_get(url: &str) -> Result<String> {
33 let token = get_github_token()?;
34 let client = create_client()?;
35 let resp = client
36 .get(url)
37 .header("Authorization", format!("Bearer {}", token))
38 .header("Accept", "application/vnd.github+json")
39 .header("X-GitHub-Api-Version", "2022-11-28")
40 .send()
41 .await?;
42
43 let status = resp.status();
44 let body = resp.text().await?;
45
46 if !status.is_success() {
47 anyhow::bail!("GitHub API error ({}): {}", status.as_u16(), body);
48 }
49 Ok(body)
50}
51
52async fn github_post(url: &str, body: &serde_json::Value) -> Result<String> {
53 let token = get_github_token()?;
54 let client = create_client()?;
55 let resp = client
56 .post(url)
57 .header("Authorization", format!("Bearer {}", token))
58 .header("Accept", "application/vnd.github+json")
59 .header("X-GitHub-Api-Version", "2022-11-28")
60 .json(body)
61 .send()
62 .await?;
63
64 let status = resp.status();
65 let body_text = resp.text().await?;
66
67 if !status.is_success() {
68 anyhow::bail!("GitHub API error ({}): {}", status.as_u16(), body_text);
69 }
70 Ok(body_text)
71}
72
73async fn github_patch(url: &str, body: &serde_json::Value) -> Result<String> {
74 let token = get_github_token()?;
75 let client = create_client()?;
76 let resp = client
77 .patch(url)
78 .header("Authorization", format!("Bearer {}", token))
79 .header("Accept", "application/vnd.github+json")
80 .header("X-GitHub-Api-Version", "2022-11-28")
81 .json(body)
82 .send()
83 .await?;
84
85 let status = resp.status();
86 let body_text = resp.text().await?;
87
88 if !status.is_success() {
89 anyhow::bail!("GitHub API error ({}): {}", status.as_u16(), body_text);
90 }
91 Ok(body_text)
92}
93
94fn parse_repo(repo: &str) -> Result<(&str, &str)> {
97 let parts: Vec<&str> = repo.split('/').collect();
98 if parts.len() != 2 {
99 anyhow::bail!("Invalid repo format. Use 'owner/repo'.");
100 }
101 Ok((parts[0], parts[1]))
102}
103
104pub async fn github_repo_info(repo: &str) -> Result<String> {
107 let (owner, name) = parse_repo(repo)?;
108 let url = format!("https://api.github.com/repos/{}/{}", owner, name);
109 github_get(&url).await
110}
111
112pub async fn github_repo_list_issues(
113 repo: &str,
114 state: Option<&str>,
115 limit: Option<usize>,
116) -> Result<String> {
117 let (owner, name) = parse_repo(repo)?;
118 let s = state.unwrap_or("open");
119 let per_page = limit.unwrap_or(10);
120 let url = format!(
121 "https://api.github.com/repos/{}/{}/issues?state={}&per_page={}",
122 owner, name, s, per_page
123 );
124 let body = github_get(&url).await?;
125
126 let issues: Vec<serde_json::Value> = serde_json::from_str(&body)?;
128 let summary: Vec<String> = issues
129 .iter()
130 .map(|i| {
131 format!(
132 "#{} {} [{}] ({})",
133 i["number"].as_u64().unwrap_or(0),
134 i["title"].as_str().unwrap_or(""),
135 i["state"].as_str().unwrap_or(""),
136 i["html_url"].as_str().unwrap_or(""),
137 )
138 })
139 .collect();
140 Ok(summary.join("\n"))
141}
142
143pub async fn github_issue_create(
146 repo: &str,
147 title: &str,
148 body: Option<&str>,
149 labels: Option<&str>,
150) -> Result<String> {
151 let (owner, name) = parse_repo(repo)?;
152 let url = format!("https://api.github.com/repos/{}/{}/issues", owner, name);
153
154 let mut json = serde_json::json!({ "title": title });
155 if let Some(b) = body {
156 json["body"] = serde_json::Value::String(b.to_string());
157 }
158 if let Some(l) = labels {
159 let label_vec: Vec<&str> = l.split(',').map(|s| s.trim()).collect();
160 json["labels"] = serde_json::json!(label_vec);
161 }
162
163 let resp = github_post(&url, &json).await?;
164 let issue: serde_json::Value = serde_json::from_str(&resp).unwrap_or_default();
165 Ok(format!(
166 "Issue #{} created: {}",
167 issue["number"].as_u64().unwrap_or(0),
168 issue["html_url"].as_str().unwrap_or(""),
169 ))
170}
171
172pub async fn github_issue_update(
173 repo: &str,
174 issue_number: u64,
175 title: Option<&str>,
176 body: Option<&str>,
177 state: Option<&str>,
178) -> Result<String> {
179 let (owner, name) = parse_repo(repo)?;
180 let url = format!(
181 "https://api.github.com/repos/{}/{}/issues/{}",
182 owner, name, issue_number
183 );
184
185 let mut json = serde_json::json!({});
186 if let Some(t) = title {
187 json["title"] = serde_json::Value::String(t.to_string());
188 }
189 if let Some(b) = body {
190 json["body"] = serde_json::Value::String(b.to_string());
191 }
192 if let Some(s) = state {
193 json["state"] = serde_json::Value::String(s.to_string());
194 }
195
196 github_patch(&url, &json).await
197}
198
199pub async fn github_pr_list(
202 repo: &str,
203 state: Option<&str>,
204 limit: Option<usize>,
205) -> Result<String> {
206 let (owner, name) = parse_repo(repo)?;
207 let s = state.unwrap_or("open");
208 let per_page = limit.unwrap_or(10);
209 let url = format!(
210 "https://api.github.com/repos/{}/{}/pulls?state={}&per_page={}",
211 owner, name, s, per_page
212 );
213 let body = github_get(&url).await?;
214
215 let prs: Vec<serde_json::Value> = serde_json::from_str(&body)?;
216 let summary: Vec<String> = prs
217 .iter()
218 .map(|pr| {
219 format!(
220 "#{} {} [{}] -> [{}] ({})",
221 pr["number"].as_u64().unwrap_or(0),
222 pr["title"].as_str().unwrap_or(""),
223 pr["head"]["ref"].as_str().unwrap_or(""),
224 pr["base"]["ref"].as_str().unwrap_or(""),
225 pr["html_url"].as_str().unwrap_or(""),
226 )
227 })
228 .collect();
229
230 if summary.is_empty() {
231 Ok("No pull requests found.".to_string())
232 } else {
233 Ok(summary.join("\n"))
234 }
235}
236
237pub async fn github_pr_create(
238 repo: &str,
239 title: &str,
240 head: &str,
241 base: &str,
242 body: Option<&str>,
243 draft: bool,
244) -> Result<String> {
245 let (owner, name) = parse_repo(repo)?;
246 let url = format!("https://api.github.com/repos/{}/{}/pulls", owner, name);
247
248 let mut json = serde_json::json!({
249 "title": title,
250 "head": head,
251 "base": base,
252 });
253 if let Some(b) = body {
254 json["body"] = serde_json::Value::String(b.to_string());
255 }
256 if draft {
257 json["draft"] = serde_json::Value::Bool(true);
258 }
259
260 let resp = github_post(&url, &json).await?;
261 let pr: serde_json::Value = serde_json::from_str(&resp).unwrap_or_default();
262 Ok(format!(
263 "PR #{} created: {}",
264 pr["number"].as_u64().unwrap_or(0),
265 pr["html_url"].as_str().unwrap_or(""),
266 ))
267}
268
269pub async fn github_pr_info(repo: &str, pr_number: u64) -> Result<String> {
270 let (owner, name) = parse_repo(repo)?;
271 let url = format!(
272 "https://api.github.com/repos/{}/{}/pulls/{}",
273 owner, name, pr_number
274 );
275 github_get(&url).await
276}
277
278pub async fn github_pr_merge(repo: &str, pr_number: u64, method: Option<&str>) -> Result<String> {
279 let (owner, name) = parse_repo(repo)?;
280 let url = format!(
281 "https://api.github.com/repos/{}/{}/pulls/{}/merge",
282 owner, name, pr_number
283 );
284
285 let merge_method = method.unwrap_or("merge");
286 let json = serde_json::json!({ "merge_method": merge_method });
287
288 let resp = github_post(&url, &json).await?;
289 let merge_result: serde_json::Value = serde_json::from_str(&resp)?;
290 if merge_result["merged"].as_bool().unwrap_or(false) {
291 Ok(format!(
292 "PR #{} merged: {}",
293 pr_number,
294 merge_result["message"].as_str().unwrap_or("Success")
295 ))
296 } else {
297 Ok(format!(
298 "PR #{} merge failed: {}",
299 pr_number,
300 merge_result["message"].as_str().unwrap_or("Unknown error")
301 ))
302 }
303}
304
305pub async fn github_search_code(
308 query: &str,
309 repo: Option<&str>,
310 limit: Option<usize>,
311) -> Result<String> {
312 let token = get_github_token()?;
313 let client = create_client()?;
314 let per_page = limit.unwrap_or(10);
315
316 let q = if let Some(r) = repo {
317 format!("{} repo:{}", query, r)
318 } else {
319 query.to_string()
320 };
321
322 let url = format!(
323 "https://api.github.com/search/code?q={}&per_page={}",
324 urlencoding(&q),
325 per_page
326 );
327
328 let resp = client
329 .get(&url)
330 .header("Authorization", format!("Bearer {}", token))
331 .header("Accept", "application/vnd.github+json")
332 .header("X-GitHub-Api-Version", "2022-11-28")
333 .send()
334 .await?;
335
336 let body = resp.text().await?;
337 let search_result: serde_json::Value = serde_json::from_str(&body)?;
338 let items = search_result["items"]
339 .as_array()
340 .cloned()
341 .unwrap_or_default();
342
343 let summary: Vec<String> = items
344 .iter()
345 .map(|item| {
346 format!(
347 "{} ({}) - {}",
348 item["path"].as_str().unwrap_or(""),
349 item["repository"]["full_name"].as_str().unwrap_or(""),
350 item["html_url"].as_str().unwrap_or(""),
351 )
352 })
353 .collect();
354
355 let total = search_result["total_count"].as_u64().unwrap_or(0);
356 Ok(format!("Found {} results:\n{}", total, summary.join("\n")))
357}
358
359pub async fn github_search_repos(query: &str, limit: Option<usize>) -> Result<String> {
360 let token = get_github_token()?;
361 let client = create_client()?;
362 let per_page = limit.unwrap_or(10);
363
364 let url = format!(
365 "https://api.github.com/search/repositories?q={}&per_page={}",
366 urlencoding(query),
367 per_page
368 );
369
370 let resp = client
371 .get(&url)
372 .header("Authorization", format!("Bearer {}", token))
373 .header("Accept", "application/vnd.github+json")
374 .header("X-GitHub-Api-Version", "2022-11-28")
375 .send()
376 .await?;
377
378 let body = resp.text().await?;
379 let search_result: serde_json::Value = serde_json::from_str(&body)?;
380 let items = search_result["items"]
381 .as_array()
382 .cloned()
383 .unwrap_or_default();
384
385 let summary: Vec<String> = items
386 .iter()
387 .map(|repo| {
388 format!(
389 "{} ⭐{} {} - {}",
390 repo["full_name"].as_str().unwrap_or(""),
391 repo["stargazers_count"].as_u64().unwrap_or(0),
392 repo["language"].as_str().unwrap_or(""),
393 repo["html_url"].as_str().unwrap_or(""),
394 )
395 })
396 .collect();
397
398 let total = search_result["total_count"].as_u64().unwrap_or(0);
399 Ok(format!(
400 "Found {} repositories:\n{}",
401 total,
402 summary.join("\n")
403 ))
404}
405
406pub async fn github_get_file(repo: &str, path: &str, ref_: Option<&str>) -> Result<String> {
409 let (owner, name) = parse_repo(repo)?;
410 let r = ref_.unwrap_or("main");
411 let url = format!(
412 "https://api.github.com/repos/{}/{}/contents/{}?ref={}",
413 owner, name, path, r
414 );
415
416 let body = github_get(&url).await?;
417 let file_info: serde_json::Value = serde_json::from_str(&body)?;
418
419 if let Some(content) = file_info["content"].as_str() {
420 let cleaned: String = content.chars().filter(|c| !c.is_whitespace()).collect();
421 use base64::{engine::general_purpose, Engine as _};
422 let bytes = general_purpose::STANDARD.decode(cleaned)?;
423 let decoded = String::from_utf8(bytes)?;
424 Ok(decoded)
425 } else if file_info.is_array() {
426 Ok(format!("Path '{}' is a directory listing.", path))
427 } else {
428 anyhow::bail!("Could not retrieve content for path '{}'.", path);
429 }
430}
431
432pub async fn github_workflow_list(repo: &str) -> Result<String> {
435 let (owner, name) = parse_repo(repo)?;
436 let url = format!(
437 "https://api.github.com/repos/{}/{}/actions/workflows",
438 owner, name
439 );
440 let body = github_get(&url).await?;
441 let workflows: serde_json::Value = serde_json::from_str(&body)?;
442 let items = workflows["workflows"]
443 .as_array()
444 .cloned()
445 .unwrap_or_default();
446
447 let summary: Vec<String> = items
448 .iter()
449 .map(|w| {
450 format!(
451 "{} ({}) - {}",
452 w["name"].as_str().unwrap_or(""),
453 w["id"].as_u64().unwrap_or(0),
454 w["state"].as_str().unwrap_or(""),
455 )
456 })
457 .collect();
458
459 if summary.is_empty() {
460 Ok("No workflows found.".to_string())
461 } else {
462 Ok(summary.join("\n"))
463 }
464}
465
466pub async fn github_workflow_runs(
467 repo: &str,
468 workflow_id: Option<&str>,
469 limit: Option<usize>,
470) -> Result<String> {
471 let (owner, name) = parse_repo(repo)?;
472 let per_page = limit.unwrap_or(10);
473
474 let url = if let Some(wf) = workflow_id {
475 format!(
476 "https://api.github.com/repos/{}/{}/actions/workflows/{}/runs?per_page={}",
477 owner, name, wf, per_page
478 )
479 } else {
480 format!(
481 "https://api.github.com/repos/{}/{}/actions/runs?per_page={}",
482 owner, name, per_page
483 )
484 };
485
486 let body = github_get(&url).await?;
487 let runs: serde_json::Value = serde_json::from_str(&body)?;
488 let items = runs["workflow_runs"]
489 .as_array()
490 .cloned()
491 .unwrap_or_default();
492
493 let summary: Vec<String> = items
494 .iter()
495 .map(|run| {
496 format!(
497 "#{} {} [{}] {} - {}",
498 run["run_number"].as_u64().unwrap_or(0),
499 run["name"].as_str().unwrap_or(""),
500 run["status"].as_str().unwrap_or(""),
501 run["conclusion"].as_str().unwrap_or("pending"),
502 run["html_url"].as_str().unwrap_or(""),
503 )
504 })
505 .collect();
506
507 if summary.is_empty() {
508 Ok("No workflow runs found.".to_string())
509 } else {
510 Ok(summary.join("\n"))
511 }
512}
513
514fn urlencoding(s: &str) -> String {
517 let mut result = String::with_capacity(s.len() * 3);
518 for byte in s.bytes() {
519 match byte {
520 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
521 result.push(byte as char)
522 }
523 b' ' => result.push('+'),
524 _ => result.push_str(&format!("%{:02X}", byte)),
525 }
526 }
527 result
528}