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