1use super::search_cache::{SearchCache, SearchResult};
2use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
14use async_trait::async_trait;
15use serde_json::{json, Value};
16use std::sync::Arc;
17use tokio::sync::oneshot;
18
19const DEFAULT_MAX_RESULTS: usize = 10;
21
22async 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
47async 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
74async 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
268async 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
422async 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
543async 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
599async 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
692pub struct GitHubTool {
696 cache: Arc<SearchCache>,
697}
698
699impl GitHubTool {
700 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(¶ms).await?;
810 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(¶ms).await,
830 "pr" => gh_pr(¶ms).await,
831 "repo" => gh_repo(¶ms).await,
832 "run" => gh_run(¶ms).await,
833 other => Err(format!(
834 "Unknown action '{}'. Use: search, issue, pr, repo, run",
835 other
836 )),
837 }
838 }
839}
840
841#[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}