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