1use super::search_cache::{SearchCache, SearchResult};
2use 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
22const DEFAULT_MAX_RESULTS: usize = 10;
24
25async 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
50async 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
77async 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
278async 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
432async 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
553async 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
609async 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#[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
731pub struct GitHubTool {
735 cache: Arc<SearchCache>,
736}
737
738impl GitHubTool {
739 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(¶ms).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(¶ms).await,
886 "pr" => gh_pr(¶ms).await,
887 "repo" => gh_repo(¶ms).await,
888 "run" => gh_run(¶ms).await,
889 other => Err(format!(
890 "Unknown action '{}'. Use: search, issue, pr, repo, run",
891 other
892 )),
893 }
894 }
895}
896
897#[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}