Skip to main content

deepseek_rust_cli/tools/
github_ops.rs

1use std::env;
2
3use anyhow::Result;
4
5// ─── GitHub API Client ──────────────────────────────────────────────
6
7fn get_github_token() -> Result<String> {
8    // Load from ~/.deep/.env
9    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
94// ─── Helper: parse owner/repo ───────────────────────────────────────
95
96fn 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
104// ─── Repository Operations ──────────────────────────────────────────
105
106pub 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    // Simplify the JSON output
127    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
143// ─── Issue Operations ───────────────────────────────────────────────
144
145pub 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
199// ─── Pull Request Operations ────────────────────────────────────────
200
201pub 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
305// ─── Search ─────────────────────────────────────────────────────────
306
307pub 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
406// ─── File Content ───────────────────────────────────────────────────
407
408pub 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
432// ─── Actions / Workflows ────────────────────────────────────────────
433
434pub 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
514// ─── Utilities ──────────────────────────────────────────────────────
515
516fn 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}