Skip to main content

oxi_agent/tools/
github.rs

1use super::search_cache::{SearchCache, SearchResult};
2/// GitHub tool — unified GitHub integration via `gh` CLI.
3///
4/// Sub-commands:
5///   search   — search repos, issues, code, commits (gh search repos/issues/code/commits)
6///   issue    — list, view, create, close issues (gh issue)
7///   pr       — list, view, create, merge PRs (gh pr)
8///   repo     — view repo info (gh repo view)
9///   run      — list/view workflow runs (gh run)
10///
11/// Prerequisites: `gh` CLI installed and authenticated (`gh auth status`).
12/// Disable via `disabled_tools = ["github"]` or `OXI_DISABLED_TOOLS=github`.
13use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
14use async_trait::async_trait;
15use serde_json::{Value, json};
16use std::sync::Arc;
17use tokio::sync::oneshot;
18
19/// Maximum results to return by default.
20const DEFAULT_MAX_RESULTS: usize = 10;
21
22// ── gh CLI helpers ────────────────────────────────────────────────
23
24/// Check if `gh` CLI is available and authenticated.
25async fn check_gh_auth() -> Result<(), ToolError> {
26    let output = tokio::process::Command::new("gh")
27        .args(["auth", "status"])
28        .output()
29        .await
30        .map_err(|e| {
31            format!(
32                "gh CLI not found: {}. Install from https://cli.github.com",
33                e
34            )
35        })?;
36
37    if !output.status.success() {
38        let stderr = String::from_utf8_lossy(&output.stderr);
39        return Err(format!(
40            "gh CLI not authenticated. Run `gh auth login`. Details: {}",
41            stderr.chars().take(200).collect::<String>()
42        ));
43    }
44    Ok(())
45}
46
47/// Run a `gh` command and return stdout as String.
48async fn gh_exec(args: &[&str]) -> Result<String, ToolError> {
49    let output = tokio::process::Command::new("gh")
50        .args(args)
51        .env("GH_FORMAT", "json")
52        .output()
53        .await
54        .map_err(|e| format!("Failed to execute gh: {}", e))?;
55
56    let stdout = String::from_utf8_lossy(&output.stdout);
57    let stderr = String::from_utf8_lossy(&output.stderr);
58
59    if !output.status.success() {
60        return Err(format!(
61            "gh {} failed (exit {}): {}",
62            args.join(" "),
63            output.status.code().unwrap_or(-1),
64            if stderr.is_empty() { &stdout } else { &stderr }
65                .chars()
66                .take(500)
67                .collect::<String>(),
68        ));
69    }
70
71    Ok(stdout.trim().to_string())
72}
73
74// ── Search ────────────────────────────────────────────────────────
75
76async fn gh_search(params: &Value) -> Result<AgentToolResult, ToolError> {
77    check_gh_auth().await?;
78
79    let query = params["query"]
80        .as_str()
81        .ok_or_else(|| "Missing required parameter: query".to_string())?;
82
83    let kind = params["kind"].as_str().unwrap_or("repos");
84    let limit = params["limit"]
85        .as_u64()
86        .unwrap_or(DEFAULT_MAX_RESULTS as u64)
87        .min(30) as usize;
88
89    let json_fields = match kind {
90        "repos" => {
91            "--json=name,fullName,url,description,language,stargazersCount,forksCount,issues,updatedAt,repositoryTopics,licenseInfo"
92        }
93        "issues" => "--json=title,url,state,body,author,labels,createdAt,updatedAt,comments,number",
94        "code" => "--json=path,repository,textMatches",
95        "commits" => "--json=sha,url,message,author,date",
96        _ => {
97            return Err(format!(
98                "Unknown search kind '{}'. Use: repos, issues, code, commits",
99                kind
100            ));
101        }
102    };
103
104    let output = gh_exec(&[
105        "search",
106        kind,
107        query,
108        "--limit",
109        &limit.to_string(),
110        json_fields,
111    ])
112    .await?;
113
114    let items: Vec<Value> = serde_json::from_str(&output).unwrap_or_else(|_| {
115        serde_json::from_str::<Value>(&output)
116            .map(|v| {
117                if v.is_array() {
118                    v.as_array().unwrap_or(&Vec::new()).clone()
119                } else {
120                    vec![v]
121                }
122            })
123            .unwrap_or_default()
124    });
125
126    let text = format_search_results(kind, &items, query);
127
128    Ok(AgentToolResult::success(text).with_metadata(json!({
129        "action": "search",
130        "kind": kind,
131        "query": query,
132        "results": items,
133        "count": items.len(),
134    })))
135}
136
137fn format_search_results(kind: &str, items: &[Value], query: &str) -> String {
138    if items.is_empty() {
139        return format!("No GitHub {} found for: {}", kind, query);
140    }
141
142    let mut out = format!("Found {} GitHub {} for '{}':\n\n", items.len(), kind, query);
143
144    match kind {
145        "repos" => {
146            for (i, item) in items.iter().enumerate() {
147                let name = item["fullName"]
148                    .as_str()
149                    .or_else(|| item["name"].as_str())
150                    .unwrap_or("?");
151                let url = item["url"].as_str().unwrap_or("");
152                let desc = item["description"]
153                    .as_str()
154                    .unwrap_or("")
155                    .chars()
156                    .take(150)
157                    .collect::<String>();
158                let stars = item["stargazersCount"].as_u64().unwrap_or(0);
159                let lang = item["language"].as_str().unwrap_or("Unknown");
160                let forks = item["forksCount"].as_u64().unwrap_or(0);
161                let stars_str = if stars >= 1000 {
162                    format!("{:.1}k", stars as f64 / 1000.0)
163                } else {
164                    stars.to_string()
165                };
166                let topics = item["repositoryTopics"]
167                    .as_array()
168                    .map(|arr| {
169                        arr.iter()
170                            .filter_map(|t| t["name"].as_str().or(t.as_str()))
171                            .collect::<Vec<_>>()
172                            .join(", ")
173                    })
174                    .unwrap_or_default();
175                let license = item["licenseInfo"]["spdxId"].as_str().unwrap_or("");
176
177                out.push_str(&format!(
178                    "{}. **{}** ⭐{}\n   {}\n   {} | 🔀 {} forks\n",
179                    i + 1,
180                    name,
181                    stars_str,
182                    url,
183                    lang,
184                    forks
185                ));
186                if !desc.is_empty() {
187                    out.push_str(&format!("   {}\n", desc));
188                }
189                if !topics.is_empty() {
190                    out.push_str(&format!("   Topics: {}\n", topics));
191                }
192                if !license.is_empty() {
193                    out.push_str(&format!("   License: {}\n", license));
194                }
195                out.push('\n');
196            }
197        }
198        "issues" => {
199            for (i, item) in items.iter().enumerate() {
200                let title = item["title"].as_str().unwrap_or("?");
201                let url = item["url"].as_str().unwrap_or("");
202                let state = item["state"].as_str().unwrap_or("OPEN");
203                let number = item["number"].as_u64().unwrap_or(0);
204                let labels = item["labels"]
205                    .as_array()
206                    .map(|arr| {
207                        arr.iter()
208                            .filter_map(|l| l["name"].as_str().or(l.as_str()))
209                            .collect::<Vec<_>>()
210                            .join(", ")
211                    })
212                    .unwrap_or_default();
213                out.push_str(&format!(
214                    "{}. #{} {} [{}] {}\n",
215                    i + 1,
216                    number,
217                    title,
218                    state,
219                    url
220                ));
221                if !labels.is_empty() {
222                    out.push_str(&format!("   Labels: {}\n", labels));
223                }
224                out.push('\n');
225            }
226        }
227        "code" => {
228            for (i, item) in items.iter().enumerate() {
229                let path = item["path"].as_str().unwrap_or("?");
230                let repo = item["repository"]["fullName"]
231                    .as_str()
232                    .or_else(|| item["repository"].as_str())
233                    .unwrap_or("?");
234                out.push_str(&format!("{}. {} in {}\n", i + 1, path, repo));
235                if let Some(matches) = item["textMatches"].as_array() {
236                    for m in matches.iter().take(3) {
237                        if let Some(frag) = m["fragment"].as_str() {
238                            out.push_str(&format!(
239                                "   > {}\n",
240                                frag.chars().take(120).collect::<String>()
241                            ));
242                        }
243                    }
244                }
245                out.push('\n');
246            }
247        }
248        "commits" => {
249            for (i, item) in items.iter().enumerate() {
250                let sha = item["sha"].as_str().unwrap_or("?").get(..7).unwrap_or("?");
251                let msg = item["message"]
252                    .as_str()
253                    .unwrap_or("")
254                    .lines()
255                    .next()
256                    .unwrap_or("");
257                let author = item["author"]["name"]
258                    .as_str()
259                    .or_else(|| item["author"].as_str())
260                    .unwrap_or("?");
261                out.push_str(&format!("{}. {} {} — {}\n", i + 1, sha, msg, author));
262                out.push('\n');
263            }
264        }
265        _ => {
266            for (i, item) in items.iter().enumerate() {
267                out.push_str(&format!("{}. {}\n", i + 1, item));
268            }
269        }
270    }
271
272    out
273}
274
275// ── Issue ─────────────────────────────────────────────────────────
276
277async fn gh_issue(params: &Value) -> Result<AgentToolResult, ToolError> {
278    check_gh_auth().await?;
279
280    let action = params["action"].as_str().unwrap_or("list");
281
282    match action {
283        "list" => {
284            let limit = params["limit"].as_u64().unwrap_or(10).min(30);
285            let state = params["state"].as_str().unwrap_or("open");
286            let label = params["label"].as_str();
287
288            let limit_str = limit.to_string();
289            let mut args = vec![
290                "issue",
291                "list",
292                "--state",
293                state,
294                "--limit",
295                &limit_str,
296                "--json",
297                "number,title,url,state,labels,createdAt,updatedAt,author",
298            ];
299            let label_arg;
300            if let Some(l) = label {
301                label_arg = format!("--label={}", l);
302                args.push(&label_arg);
303            }
304
305            let output = gh_exec(&args).await?;
306            let items: Vec<Value> = serde_json::from_str(&output).unwrap_or_default();
307            let text = format_issue_list(&items);
308            Ok(AgentToolResult::success(text)
309                .with_metadata(json!({ "action": "issue", "sub": "list", "results": items })))
310        }
311        "view" => {
312            let number = params["number"]
313                .as_u64()
314                .ok_or_else(|| "Missing parameter: number".to_string())?;
315            let output = gh_exec(&[
316                "issue",
317                "view",
318                &number.to_string(),
319                "--json",
320                "number,title,body,state,author,labels,comments,createdAt,updatedAt",
321            ])
322            .await?;
323            let issue: Value =
324                serde_json::from_str(&output).map_err(|e| format!("Parse error: {}", e))?;
325            let text = format_issue_view(&issue);
326            Ok(AgentToolResult::success(text)
327                .with_metadata(json!({ "action": "issue", "sub": "view", "issue": issue })))
328        }
329        "create" => {
330            let title = params["title"]
331                .as_str()
332                .ok_or_else(|| "Missing parameter: title".to_string())?;
333            let body = params["body"].as_str().unwrap_or("");
334            let mut args = vec!["issue", "create", "--title", title];
335            let body_arg;
336            if !body.is_empty() {
337                body_arg = format!("--body={}", body);
338                args.push(&body_arg);
339            }
340            let output = gh_exec(&args).await?;
341            Ok(AgentToolResult::success(format!(
342                "Created issue: {}",
343                output
344            )))
345        }
346        "close" => {
347            let number = params["number"]
348                .as_u64()
349                .ok_or_else(|| "Missing parameter: number".to_string())?;
350            let output = gh_exec(&["issue", "close", &number.to_string()]).await?;
351            Ok(AgentToolResult::success(format!(
352                "Closed issue: {}",
353                output
354            )))
355        }
356        other => Err(format!(
357            "Unknown issue action '{}'. Use: list, view, create, close",
358            other
359        )),
360    }
361}
362
363fn format_issue_list(items: &[Value]) -> String {
364    if items.is_empty() {
365        return "No issues found.".to_string();
366    }
367    let mut out = format!("{} issues:\n\n", items.len());
368    for (i, item) in items.iter().enumerate() {
369        let num = item["number"].as_u64().unwrap_or(0);
370        let title = item["title"].as_str().unwrap_or("?");
371        let state = item["state"].as_str().unwrap_or("OPEN");
372        let url = item["url"].as_str().unwrap_or("");
373        let labels = item["labels"]
374            .as_array()
375            .map(|arr| {
376                arr.iter()
377                    .filter_map(|l| l["name"].as_str().or(l.as_str()))
378                    .collect::<Vec<_>>()
379                    .join(", ")
380            })
381            .unwrap_or_default();
382        out.push_str(&format!(
383            "{}. #{} {} [{}] {}\n",
384            i + 1,
385            num,
386            title,
387            state,
388            url
389        ));
390        if !labels.is_empty() {
391            out.push_str(&format!("   Labels: {}\n", labels));
392        }
393        out.push('\n');
394    }
395    out
396}
397
398fn format_issue_view(issue: &Value) -> String {
399    let title = issue["title"].as_str().unwrap_or("?");
400    let num = issue["number"].as_u64().unwrap_or(0);
401    let state = issue["state"].as_str().unwrap_or("OPEN");
402    let body = issue["body"].as_str().unwrap_or("");
403    let url = issue["url"].as_str().unwrap_or("");
404    let labels = issue["labels"]
405        .as_array()
406        .map(|arr| {
407            arr.iter()
408                .filter_map(|l| l["name"].as_str().or(l.as_str()))
409                .collect::<Vec<_>>()
410                .join(", ")
411        })
412        .unwrap_or_default();
413    let comments = issue["comments"].as_array().map(|a| a.len()).unwrap_or(0);
414
415    let mut out = format!("#{} {} [{}]\n{}\n\n", num, title, state, url);
416    if !labels.is_empty() {
417        out.push_str(&format!("Labels: {}\n\n", labels));
418    }
419    if !body.is_empty() {
420        out.push_str(&format!(
421            "{}\n\n",
422            body.chars().take(1000).collect::<String>()
423        ));
424    }
425    out.push_str(&format!("Comments: {}\n", comments));
426    out
427}
428
429// ── PR ────────────────────────────────────────────────────────────
430
431async fn gh_pr(params: &Value) -> Result<AgentToolResult, ToolError> {
432    check_gh_auth().await?;
433
434    let action = params["action"].as_str().unwrap_or("list");
435
436    match action {
437        "list" => {
438            let limit = params["limit"].as_u64().unwrap_or(10).min(30);
439            let state = params["state"].as_str().unwrap_or("open");
440            let output = gh_exec(&[
441                "pr",
442                "list",
443                "--state",
444                state,
445                "--limit",
446                &limit.to_string(),
447                "--json",
448                "number,title,url,state,author,createdAt,updatedAt,labels",
449            ])
450            .await?;
451            let items: Vec<Value> = serde_json::from_str(&output).unwrap_or_default();
452            let text = format_pr_list(&items);
453            Ok(AgentToolResult::success(text)
454                .with_metadata(json!({ "action": "pr", "sub": "list", "results": items })))
455        }
456        "view" => {
457            let number = params["number"]
458                .as_u64()
459                .ok_or_else(|| "Missing parameter: number".to_string())?;
460            let output = gh_exec(&["pr", "view", &number.to_string(),
461                "--json", "number,title,body,state,author,labels,additions,deletions,commits,reviews,createdAt"]).await?;
462            let pr: Value =
463                serde_json::from_str(&output).map_err(|e| format!("Parse error: {}", e))?;
464            let text = format_pr_view(&pr);
465            Ok(AgentToolResult::success(text)
466                .with_metadata(json!({ "action": "pr", "sub": "view", "pr": pr })))
467        }
468        "create" => {
469            let title = params["title"]
470                .as_str()
471                .ok_or_else(|| "Missing parameter: title".to_string())?;
472            let body = params["body"].as_str().unwrap_or("");
473            let base = params["base"].as_str().unwrap_or("main");
474            let head = params["head"].as_str().unwrap_or("");
475            let mut args = vec!["pr", "create", "--title", title, "--base", base];
476            let body_arg;
477            let head_arg;
478            if !body.is_empty() {
479                body_arg = format!("--body={}", body);
480                args.push(&body_arg);
481            }
482            if !head.is_empty() {
483                head_arg = format!("--head={}", head);
484                args.push(&head_arg);
485            }
486            let output = gh_exec(&args).await?;
487            Ok(AgentToolResult::success(format!("Created PR: {}", output)))
488        }
489        "merge" => {
490            let number = params["number"]
491                .as_u64()
492                .ok_or_else(|| "Missing parameter: number".to_string())?;
493            let strategy = params["strategy"].as_str().unwrap_or("merge");
494            let output = gh_exec(&["pr", "merge", &number.to_string(), "--", strategy]).await?;
495            Ok(AgentToolResult::success(format!("Merged PR: {}", output)))
496        }
497        other => Err(format!(
498            "Unknown PR action '{}'. Use: list, view, create, merge",
499            other
500        )),
501    }
502}
503
504fn format_pr_list(items: &[Value]) -> String {
505    if items.is_empty() {
506        return "No pull requests found.".to_string();
507    }
508    let mut out = format!("{} pull requests:\n\n", items.len());
509    for (i, item) in items.iter().enumerate() {
510        let num = item["number"].as_u64().unwrap_or(0);
511        let title = item["title"].as_str().unwrap_or("?");
512        let state = item["state"].as_str().unwrap_or("OPEN");
513        let url = item["url"].as_str().unwrap_or("");
514        out.push_str(&format!(
515            "{}. #{} {} [{}] {}\n\n",
516            i + 1,
517            num,
518            title,
519            state,
520            url
521        ));
522    }
523    out
524}
525
526fn format_pr_view(pr: &Value) -> String {
527    let title = pr["title"].as_str().unwrap_or("?");
528    let num = pr["number"].as_u64().unwrap_or(0);
529    let state = pr["state"].as_str().unwrap_or("OPEN");
530    let url = pr["url"].as_str().unwrap_or("");
531    let body = pr["body"].as_str().unwrap_or("");
532    let additions = pr["additions"].as_u64().unwrap_or(0);
533    let deletions = pr["deletions"].as_u64().unwrap_or(0);
534    let commits = pr["commits"].as_u64().unwrap_or(0);
535
536    let mut out = format!("#{} {} [{}]\n{}\n\n", num, title, state, url);
537    out.push_str(&format!(
538        "+{} / -{} across {} commits\n\n",
539        additions, deletions, commits
540    ));
541    if !body.is_empty() {
542        out.push_str(&format!(
543            "{}\n\n",
544            body.chars().take(1000).collect::<String>()
545        ));
546    }
547    out
548}
549
550// ── Repo ──────────────────────────────────────────────────────────
551
552async fn gh_repo(params: &Value) -> Result<AgentToolResult, ToolError> {
553    check_gh_auth().await?;
554
555    let repo = params["repo"].as_str().unwrap_or("");
556    let output = gh_exec(&[
557        "repo", "view", repo,
558        "--json", "name,fullName,url,description,language,stargazersCount,forksCount,issues,defaultBranchRef,createdAt,updatedAt,repositoryTopics,licenseInfo",
559    ]).await?;
560
561    let info: Value = serde_json::from_str(&output).map_err(|e| format!("Parse error: {}", e))?;
562
563    let text = format_repo_view(&info);
564    Ok(AgentToolResult::success(text).with_metadata(json!({ "action": "repo", "repo": info })))
565}
566
567fn format_repo_view(info: &Value) -> String {
568    let name = info["fullName"].as_str().unwrap_or("?");
569    let desc = info["description"].as_str().unwrap_or("");
570    let url = info["url"].as_str().unwrap_or("");
571    let stars = info["stargazersCount"].as_u64().unwrap_or(0);
572    let forks = info["forksCount"].as_u64().unwrap_or(0);
573    let lang = info["language"].as_str().unwrap_or("Unknown");
574    let default_branch = info["defaultBranchRef"]["name"].as_str().unwrap_or("main");
575    let topics = info["repositoryTopics"]
576        .as_array()
577        .map(|arr| {
578            arr.iter()
579                .filter_map(|t| t["name"].as_str().or(t.as_str()))
580                .collect::<Vec<_>>()
581                .join(", ")
582        })
583        .unwrap_or_default();
584    let license = info["licenseInfo"]["spdxId"].as_str().unwrap_or("None");
585
586    let stars_str = if stars >= 1000 {
587        format!("{:.1}k", stars as f64 / 1000.0)
588    } else {
589        stars.to_string()
590    };
591
592    let mut out = format!("**{}** ⭐{}\n{}\n\n", name, stars_str, url);
593    if !desc.is_empty() {
594        out.push_str(&format!("{}\n\n", desc));
595    }
596    out.push_str(&format!(
597        "Language: {} | Forks: {} | Branch: {} | License: {}\n",
598        lang, forks, default_branch, license
599    ));
600    if !topics.is_empty() {
601        out.push_str(&format!("Topics: {}\n", topics));
602    }
603    out
604}
605
606// ── Run (Actions) ─────────────────────────────────────────────────
607
608async fn gh_run(params: &Value) -> Result<AgentToolResult, ToolError> {
609    check_gh_auth().await?;
610
611    let action = params["action"].as_str().unwrap_or("list");
612
613    match action {
614        "list" => {
615            let limit = params["limit"].as_u64().unwrap_or(5).min(20);
616            let output = gh_exec(&[
617                "run",
618                "list",
619                "--limit",
620                &limit.to_string(),
621                "--json",
622                "databaseId,name,status,conclusion,headBranch,createdAt,event",
623            ])
624            .await?;
625            let items: Vec<Value> = serde_json::from_str(&output).unwrap_or_default();
626            let text = format_run_list(&items);
627            Ok(AgentToolResult::success(text)
628                .with_metadata(json!({ "action": "run", "sub": "list", "results": items })))
629        }
630        "view" => {
631            let id = params["id"]
632                .as_u64()
633                .ok_or_else(|| "Missing parameter: id".to_string())?;
634            let output = gh_exec(&[
635                "run",
636                "view",
637                &id.to_string(),
638                "--json",
639                "databaseId,name,status,conclusion,headBranch,createdAt,jobs",
640            ])
641            .await?;
642            let run: Value =
643                serde_json::from_str(&output).map_err(|e| format!("Parse error: {}", e))?;
644            let text = format_run_view(&run);
645            Ok(AgentToolResult::success(text)
646                .with_metadata(json!({ "action": "run", "sub": "view", "run": run })))
647        }
648        other => Err(format!("Unknown run action '{}'. Use: list, view", other)),
649    }
650}
651
652fn format_run_list(items: &[Value]) -> String {
653    if items.is_empty() {
654        return "No workflow runs found.".to_string();
655    }
656    let mut out = format!("{} workflow runs:\n\n", items.len());
657    for (i, item) in items.iter().enumerate() {
658        let name = item["name"].as_str().unwrap_or("?");
659        let status = item["status"].as_str().unwrap_or("?");
660        let conclusion = item["conclusion"].as_str().unwrap_or("in progress");
661        let branch = item["headBranch"].as_str().unwrap_or("?");
662        let id = item["databaseId"].as_u64().unwrap_or(0);
663        out.push_str(&format!(
664            "{}. {} — {} ({}) branch: {} id: {}\n",
665            i + 1,
666            name,
667            status,
668            conclusion,
669            branch,
670            id
671        ));
672    }
673    out
674}
675
676fn format_run_view(run: &Value) -> String {
677    let name = run["name"].as_str().unwrap_or("?");
678    let status = run["status"].as_str().unwrap_or("?");
679    let conclusion = run["conclusion"].as_str().unwrap_or("in progress");
680    let branch = run["headBranch"].as_str().unwrap_or("?");
681    let id = run["databaseId"].as_u64().unwrap_or(0);
682
683    let mut out = format!(
684        "**{}** — {} ({})\nBranch: {} | ID: {}\n\n",
685        name, status, conclusion, branch, id
686    );
687    if let Some(jobs) = run["jobs"].as_array() {
688        out.push_str(&format!("Jobs ({}):\n", jobs.len()));
689        for job in jobs {
690            let jname = job["name"].as_str().unwrap_or("?");
691            let jstatus = job["status"].as_str().unwrap_or("?");
692            let jconclusion = job["conclusion"].as_str().unwrap_or("in progress");
693            out.push_str(&format!("  - {} — {} ({})\n", jname, jstatus, jconclusion));
694        }
695    }
696    out
697}
698
699// ── GitHubTool ────────────────────────────────────────────────────
700
701/// Unified GitHub tool using `gh` CLI.
702pub struct GitHubTool {
703    cache: Arc<SearchCache>,
704}
705
706impl GitHubTool {
707    /// Create a new GitHubTool with the given search cache.
708    pub fn new(cache: Arc<SearchCache>) -> Self {
709        Self { cache }
710    }
711}
712
713#[async_trait]
714impl AgentTool for GitHubTool {
715    fn name(&self) -> &str {
716        "github"
717    }
718
719    fn label(&self) -> &str {
720        "GitHub"
721    }
722
723    fn description(&self) -> &str {
724        "GitHub integration via gh CLI. Actions: search (repos/issues/code/commits), issue (list/view/create/close), pr (list/view/create/merge), repo (view info), run (workflow runs). Requires gh CLI installed and authenticated."
725    }
726
727    fn parameters_schema(&self) -> Value {
728        json!({
729            "type": "object",
730            "properties": {
731                "action": {
732                    "type": "string",
733                    "description": "Top-level action: search, issue, pr, repo, run",
734                    "enum": ["search", "issue", "pr", "repo", "run"],
735                    "default": "search"
736                },
737                "query": {
738                    "type": "string",
739                    "description": "Search query (for action=search)"
740                },
741                "kind": {
742                    "type": "string",
743                    "description": "Search kind (for action=search): repos, issues, code, commits",
744                    "enum": ["repos", "issues", "code", "commits"],
745                    "default": "repos"
746                },
747                "number": {
748                    "type": "integer",
749                    "description": "Issue/PR number (for issue view/close, pr view/merge)"
750                },
751                "title": {
752                    "type": "string",
753                    "description": "Title (for issue create, pr create)"
754                },
755                "body": {
756                    "type": "string",
757                    "description": "Body text (for issue create, pr create)"
758                },
759                "state": {
760                    "type": "string",
761                    "description": "Filter by state: open, closed, all",
762                    "enum": ["open", "closed", "all"],
763                    "default": "open"
764                },
765                "limit": {
766                    "type": "integer",
767                    "description": "Max results (default 10, max 30)",
768                    "default": 10
769                },
770                "repo": {
771                    "type": "string",
772                    "description": "Repository (owner/repo format, for action=repo)"
773                },
774                "base": {
775                    "type": "string",
776                    "description": "Base branch (for pr create, default: main)"
777                },
778                "head": {
779                    "type": "string",
780                    "description": "Head branch (for pr create)"
781                },
782                "strategy": {
783                    "type": "string",
784                    "description": "Merge strategy (for pr merge): merge, squash, rebase",
785                    "enum": ["merge", "squash", "rebase"],
786                    "default": "merge"
787                },
788                "id": {
789                    "type": "integer",
790                    "description": "Workflow run ID (for action=run view)"
791                },
792                "label": {
793                    "type": "string",
794                    "description": "Filter by label (for issue list)"
795                },
796                "language": {
797                    "type": "string",
798                    "description": "Filter by language (for search repos)"
799                }
800            },
801            "required": []
802        })
803    }
804
805    async fn execute(
806        &self,
807        _tool_call_id: &str,
808        params: Value,
809        _signal: Option<oneshot::Receiver<()>>,
810        _ctx: &ToolContext,
811    ) -> Result<AgentToolResult, ToolError> {
812        let action = params["action"].as_str().unwrap_or("search");
813
814        match action {
815            "search" => {
816                let result = gh_search(&params).await?;
817                // Cache search results
818                if let Some(query) = params["query"].as_str() {
819                    let kind = params["kind"].as_str().unwrap_or("repos");
820                    let search_id = self.cache.insert(
821                        &format!("github:{}:{}", kind, query),
822                        vec![SearchResult {
823                            title: format!("GitHub {} search: {}", kind, query),
824                            url: String::new(),
825                            snippet: result.output.chars().take(200).collect(),
826                            source: "GitHub".to_string(),
827                            extra: None,
828                        }],
829                    );
830                    return Ok(result.with_metadata(json!({
831                        "searchId": search_id,
832                    })));
833                }
834                Ok(result)
835            }
836            "issue" => gh_issue(&params).await,
837            "pr" => gh_pr(&params).await,
838            "repo" => gh_repo(&params).await,
839            "run" => gh_run(&params).await,
840            other => Err(format!(
841                "Unknown action '{}'. Use: search, issue, pr, repo, run",
842                other
843            )),
844        }
845    }
846}
847
848// ── Tests ─────────────────────────────────────────────────────────
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853
854    #[test]
855    fn test_format_search_repos_empty() {
856        let text = format_search_results("repos", &[], "test");
857        assert!(text.contains("No GitHub repos"));
858    }
859
860    #[test]
861    fn test_format_search_repos() {
862        let items = vec![json!({
863            "fullName": "rust-lang/rust",
864            "url": "https://github.com/rust-lang/rust",
865            "description": "Empowering everyone to build reliable and efficient software.",
866            "language": "Rust",
867            "stargazersCount": 95000,
868            "forksCount": 12000,
869            "repositoryTopics": [{"name": "programming-language"}, {"name": "systems"}],
870            "licenseInfo": {"spdxId": "MIT/Apache-2.0"}
871        })];
872        let text = format_search_results("repos", &items, "rust");
873        assert!(text.contains("**rust-lang/rust**"));
874        assert!(text.contains("95.0k"));
875        assert!(text.contains("programming-language, systems"));
876    }
877
878    #[test]
879    fn test_format_issue_list() {
880        let items = vec![json!({
881            "number": 42,
882            "title": "Bug in parser",
883            "state": "OPEN",
884            "url": "https://github.com/test/repo/issues/42",
885            "labels": [{"name": "bug"}]
886        })];
887        let text = format_issue_list(&items);
888        assert!(text.contains("#42"));
889        assert!(text.contains("Bug in parser"));
890        assert!(text.contains("bug"));
891    }
892
893    #[test]
894    fn test_format_pr_list() {
895        let items = vec![json!({
896            "number": 7,
897            "title": "Fix typo",
898            "state": "OPEN",
899            "url": "https://github.com/test/repo/pull/7"
900        })];
901        let text = format_pr_list(&items);
902        assert!(text.contains("#7"));
903        assert!(text.contains("Fix typo"));
904    }
905
906    #[test]
907    fn test_format_repo_view() {
908        let info = json!({
909            "fullName": "test/repo",
910            "url": "https://github.com/test/repo",
911            "description": "A test repo",
912            "language": "Rust",
913            "stargazersCount": 1500,
914            "forksCount": 100,
915            "defaultBranchRef": {"name": "main"},
916            "repositoryTopics": [{"name": "test"}],
917            "licenseInfo": {"spdxId": "MIT"}
918        });
919        let text = format_repo_view(&info);
920        assert!(text.contains("**test/repo**"));
921        assert!(text.contains("1.5k"));
922        assert!(text.contains("MIT"));
923    }
924
925    #[test]
926    fn test_format_run_list() {
927        let items = vec![json!({
928            "databaseId": 12345,
929            "name": "CI",
930            "status": "completed",
931            "conclusion": "success",
932            "headBranch": "main"
933        })];
934        let text = format_run_list(&items);
935        assert!(text.contains("CI"));
936        assert!(text.contains("success"));
937    }
938
939    #[test]
940    fn test_schema() {
941        let cache = Arc::new(SearchCache::new());
942        let tool = GitHubTool::new(cache);
943        let schema = tool.parameters_schema();
944        assert_eq!(schema["type"], "object");
945        assert!(schema["properties"]["action"].is_object());
946        assert!(schema["properties"]["query"].is_object());
947        assert_eq!(tool.name(), "github");
948    }
949}