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