1use super::http_client::shared_http_client;
2use super::search_cache::{SearchCache, SearchResult};
3use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
12use crate::tools::typed::TypedTool;
13use async_trait::async_trait;
14use schemars::JsonSchema;
15use serde::Deserialize;
16use serde_json::{Value, json};
17use std::sync::Arc;
18use tokio::sync::oneshot;
19
20const DEFAULT_MAX_RESULTS: u64 = 10;
22
23const MAX_RESULTS: usize = 30;
25
26#[derive(Debug, Deserialize)]
30
31struct GitHubSearchResponse {
32 total_count: u64,
33 _incomplete_results: bool,
34 items: Vec<GitHubRepo>,
35}
36
37#[derive(Debug, Deserialize)]
39struct GitHubRepo {
40 full_name: String,
41 html_url: String,
42 description: Option<String>,
43 language: Option<String>,
44 stargazers_count: u64,
45 forks_count: u64,
46 open_issues_count: u64,
47 updated_at: String,
48
49 _archived: bool,
50 topics: Vec<String>,
51 license: Option<GitHubLicense>,
52}
53
54#[derive(Debug, Deserialize)]
55struct GitHubLicense {
56 spdx_id: Option<String>,
57 name: Option<String>,
58}
59
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64pub struct GitHubSearchResult {
65 pub full_name: String,
67 pub url: String,
69 pub description: String,
71 pub language: String,
73 pub stars: u64,
75 pub forks: u64,
77 pub open_issues: u64,
79 pub updated_at: String,
81 pub topics: Vec<String>,
83 pub license: String,
85}
86
87impl From<&GitHubSearchResult> for SearchResult {
88 fn from(r: &GitHubSearchResult) -> Self {
89 SearchResult {
90 title: r.full_name.clone(),
91 url: r.url.clone(),
92 snippet: r.description.clone(),
93 source: "GitHub".to_string(),
94 extra: None,
95 }
96 }
97}
98
99fn resolve_github_token() -> Option<String> {
103 std::env::var("GITHUB_SEARCH_TOKEN")
105 .ok()
106 .or_else(|| std::env::var("GITHUB_TOKEN").ok())
107 .or_else(|| std::env::var("GH_TOKEN").ok())
108}
109
110async fn search_github_repos(
112 query: &str,
113 sort: &str,
114 order: &str,
115 limit: usize,
116 language: Option<&str>,
117) -> Result<(u64, Vec<GitHubSearchResult>), ToolError> {
118 let mut url = format!(
119 "https://api.github.com/search/repositories?q={}&sort={}&order={}&per_page={}",
120 urlencoding(query),
121 sort,
122 order,
123 limit.min(MAX_RESULTS),
124 );
125
126 if let Some(lang) = language {
128 url = format!(
130 "https://api.github.com/search/repositories?q={}+language%3A{}&sort={}&order={}&per_page={}",
131 urlencoding(query),
132 urlencoding(lang),
133 sort,
134 order,
135 limit.min(MAX_RESULTS),
136 );
137 }
138
139 let mut builder = shared_http_client()
140 .get(&url)
141 .header("Accept", "application/vnd.github.v3+json")
142 .header("User-Agent", "oxi-agent");
143
144 if let Some(token) = resolve_github_token() {
146 builder = builder.header("Authorization", format!("Bearer {}", token));
147 }
148
149 let response = builder
150 .send()
151 .await
152 .map_err(|e| format!("GitHub API request failed: {}", e))?;
153
154 let status = response.status();
155 if status.as_u16() == 403 {
156 let body = response.text().await.unwrap_or_default();
157 return Err(format!(
158 "GitHub API rate limit exceeded. Set GITHUB_TOKEN env var for higher limits. Body: {}",
159 body.chars().take(200).collect::<String>()
160 ));
161 }
162 if !status.is_success() {
163 let body = response.text().await.unwrap_or_default();
164 return Err(format!(
165 "GitHub API returned status {}: {}",
166 status,
167 body.chars().take(300).collect::<String>()
168 ));
169 }
170
171 let search_response: GitHubSearchResponse = response
172 .json()
173 .await
174 .map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
175
176 let results = search_response
177 .items
178 .into_iter()
179 .map(|repo| GitHubSearchResult {
180 full_name: repo.full_name,
181 url: repo.html_url,
182 description: repo.description.unwrap_or_default(),
183 language: repo.language.unwrap_or_default(),
184 stars: repo.stargazers_count,
185 forks: repo.forks_count,
186 open_issues: repo.open_issues_count,
187 updated_at: repo.updated_at,
188 topics: repo.topics,
189 license: repo
190 .license
191 .and_then(|l| l.spdx_id.or(l.name))
192 .unwrap_or_default(),
193 })
194 .collect();
195
196 Ok((search_response.total_count, results))
197}
198
199fn urlencoding(s: &str) -> String {
201 let mut result = String::with_capacity(s.len() * 3);
202 for byte in s.bytes() {
203 match byte {
204 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
205 result.push(byte as char);
206 }
207 _ => {
208 result.push('%');
209 result.push_str(&format!("{:02X}", byte));
210 }
211 }
212 }
213 result
214}
215
216fn format_github_results(total: u64, results: &[GitHubSearchResult]) -> String {
220 if results.is_empty() {
221 return "No repositories found.".to_string();
222 }
223
224 let mut output = format!(
225 "Found {} repositories (showing {}):\n\n",
226 total,
227 results.len()
228 );
229
230 for (i, r) in results.iter().enumerate() {
231 let stars = if r.stars >= 1000 {
232 format!("{:.1}k", r.stars as f64 / 1000.0)
233 } else {
234 r.stars.to_string()
235 };
236
237 let desc = if r.description.chars().count() > 150 {
238 let truncated: String = r.description.chars().take(150).collect();
239 format!("{}...", truncated)
240 } else {
241 r.description.clone()
242 };
243
244 output.push_str(&format!(
245 "{}. **{}** ⭐{}\n {}\n {} {} | 🔀 {} forks | 📦 {} issues\n Updated: {}\n",
246 i + 1,
247 r.full_name,
248 stars,
249 r.url,
250 desc,
251 if r.language.is_empty() {
252 "Unknown".to_string()
253 } else {
254 r.language.clone()
255 },
256 r.forks,
257 r.open_issues,
258 &r.updated_at[..10], ));
260
261 if !r.topics.is_empty() {
262 output.push_str(&format!(" Topics: {}\n", r.topics.join(", ")));
263 }
264
265 if !r.license.is_empty() {
266 output.push_str(&format!(" License: {}\n", r.license));
267 }
268
269 output.push('\n');
270 }
271
272 output
273}
274
275pub struct GitHubSearchTool {
279 cache: Arc<SearchCache>,
280}
281
282impl GitHubSearchTool {
283 pub fn new(cache: Arc<SearchCache>) -> Self {
285 Self { cache }
286 }
287}
288
289#[derive(Deserialize, JsonSchema)]
291pub struct GitHubSearchArgs {
292 query: String,
293 #[serde(default = "default_gs_sort")]
294 sort: String,
295 #[serde(default = "default_gs_order")]
296 order: String,
297 language: Option<String>,
298 #[serde(default = "default_gs_limit")]
299 limit: u64,
300}
301
302fn default_gs_sort() -> String {
303 "stars".to_string()
304}
305fn default_gs_order() -> String {
306 "desc".to_string()
307}
308fn default_gs_limit() -> u64 {
309 DEFAULT_MAX_RESULTS
310}
311
312#[async_trait]
313impl AgentTool for GitHubSearchTool {
314 fn name(&self) -> &str {
315 "github_search"
316 }
317
318 fn label(&self) -> &str {
319 "GitHub Search"
320 }
321
322 fn description(&self) -> &str {
323 "Search GitHub repositories by query. Returns repos with stars, forks, language, description, and topics. Supports sorting by stars, forks, or recently updated. No API key required (set GITHUB_TOKEN for higher rate limits)."
324 }
325
326 fn parameters_schema(&self) -> Value {
327 json!({
328 "type": "object",
329 "properties": {
330 "query": {
331 "type": "string",
332 "description": "Search query (e.g. 'rust web framework', 'machine learning', 'owner:mariozechner')"
333 },
334 "sort": {
335 "type": "string",
336 "description": "Sort results by: 'stars' (default), 'forks', or 'updated'",
337 "enum": ["stars", "forks", "updated"],
338 "default": "stars"
339 },
340 "order": {
341 "type": "string",
342 "description": "Sort order: 'desc' (default) or 'asc'",
343 "enum": ["desc", "asc"],
344 "default": "desc"
345 },
346 "language": {
347 "type": "string",
348 "description": "Filter by programming language (e.g. 'rust', 'python', 'typescript')"
349 },
350 "limit": {
351 "type": "integer",
352 "description": "Maximum number of results to return (default: 10, max: 30)",
353 "default": 10
354 }
355 },
356 "required": ["query"]
357 })
358 }
359
360 async fn execute(
361 &self,
362 _tool_call_id: &str,
363 params: Value,
364 _signal: Option<oneshot::Receiver<()>>,
365 _ctx: &ToolContext,
366 ) -> Result<AgentToolResult, ToolError> {
367 let args: GitHubSearchArgs =
368 serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
369 self.execute_typed(_tool_call_id, args, _signal, _ctx).await
370 }
371}
372
373#[async_trait]
374impl TypedTool for GitHubSearchTool {
375 type Args = GitHubSearchArgs;
376
377 async fn execute_typed(
378 &self,
379 _tool_call_id: &str,
380 args: Self::Args,
381 _signal: Option<oneshot::Receiver<()>>,
382 _ctx: &ToolContext,
383 ) -> Result<AgentToolResult, ToolError> {
384 let sort = match args.sort.as_str() {
385 "forks" | "updated" => args.sort.as_str(),
386 _ => "stars",
387 };
388 let order = match args.order.as_str() {
389 "asc" => "asc",
390 _ => "desc",
391 };
392 let limit = (args.limit as usize).min(MAX_RESULTS);
393 let (total, results) =
394 search_github_repos(&args.query, sort, order, limit, args.language.as_deref()).await?;
395 if results.is_empty() {
396 return Ok(AgentToolResult::success(format!(
397 "No GitHub repositories found for: {}",
398 args.query
399 )));
400 }
401 let search_id = self.cache.insert(
402 &format!("github:{}", args.query),
403 results.iter().map(|r| r.into()).collect(),
404 );
405 let output = format_github_results(total, &results);
406 let results_json: Vec<Value> = results.iter().map(|r| {
407 json!({"full_name": r.full_name, "url": r.url, "description": r.description, "language": r.language, "stars": r.stars, "forks": r.forks, "open_issues": r.open_issues, "updated_at": r.updated_at, "topics": r.topics, "license": r.license})
408 }).collect();
409 Ok(AgentToolResult::success(output).with_metadata(json!({
410 "results": results_json, "query": args.query, "searchId": search_id, "totalCount": total, "resultCount": results.len()
411 })))
412 }
413}
414
415#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn test_urlencoding() {
423 assert_eq!(urlencoding("hello world"), "hello%20world");
424 assert_eq!(urlencoding("rust&cargo"), "rust%26cargo");
425 assert_eq!(urlencoding("c++"), "c%2B%2B");
426 }
427
428 #[test]
429 fn test_format_github_results_empty() {
430 assert_eq!(format_github_results(0, &[]), "No repositories found.");
431 }
432
433 #[test]
434 fn test_format_github_results() {
435 let results = vec![GitHubSearchResult {
436 full_name: "rust-lang/rust".to_string(),
437 url: "https://github.com/rust-lang/rust".to_string(),
438 description: "Empowering everyone to build reliable and efficient software."
439 .to_string(),
440 language: "Rust".to_string(),
441 stars: 95000,
442 forks: 12000,
443 open_issues: 9000,
444 updated_at: "2026-05-08T12:00:00Z".to_string(),
445 topics: vec!["programming-language".to_string(), "systems".to_string()],
446 license: "MIT/Apache-2.0".to_string(),
447 }];
448 let formatted = format_github_results(1, &results);
449 assert!(formatted.contains("**rust-lang/rust**"));
450 assert!(formatted.contains("95.0k"));
451 assert!(formatted.contains("Rust"));
452 assert!(formatted.contains("Topics: programming-language, systems"));
453 }
454
455 #[test]
456 fn test_format_stars_under_1k() {
457 let results = vec![GitHubSearchResult {
458 full_name: "test/repo".to_string(),
459 url: "https://github.com/test/repo".to_string(),
460 description: "A test".to_string(),
461 language: "Python".to_string(),
462 stars: 500,
463 forks: 20,
464 open_issues: 3,
465 updated_at: "2026-05-01T00:00:00Z".to_string(),
466 topics: vec![],
467 license: String::new(),
468 }];
469 let formatted = format_github_results(1, &results);
470 assert!(formatted.contains("⭐500"));
471 }
472
473 #[test]
474 fn test_schema() {
475 let cache = Arc::new(SearchCache::new());
476 let tool = GitHubSearchTool::new(cache);
477 let schema = tool.parameters_schema();
478 assert_eq!(schema["type"], "object");
479 assert!(schema["properties"]["query"].is_object());
480 assert!(schema["properties"]["sort"].is_object());
481 assert!(schema["properties"]["language"].is_object());
482 assert!(
483 schema["required"]
484 .as_array()
485 .unwrap()
486 .contains(&json!("query"))
487 );
488 }
489
490 #[tokio::test]
491 async fn test_github_search_live() {
492 let result = search_github_repos("rust web framework", "stars", "desc", 3, None).await;
494 if let Ok((total, results)) = result {
495 assert!(total > 0);
496 assert!(!results.is_empty());
497 assert!(results[0].stars > 0);
498 assert!(results[0].url.starts_with("https://github.com/"));
499 }
500 }
501}