use super::http_client::shared_http_client;
use super::search_cache::{SearchCache, SearchResult};
use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::oneshot;
const DEFAULT_MAX_RESULTS: usize = 10;
const MAX_RESULTS: usize = 30;
#[derive(Debug, Deserialize)]
struct GitHubSearchResponse {
total_count: u64,
_incomplete_results: bool,
items: Vec<GitHubRepo>,
}
#[derive(Debug, Deserialize)]
struct GitHubRepo {
full_name: String,
html_url: String,
description: Option<String>,
language: Option<String>,
stargazers_count: u64,
forks_count: u64,
open_issues_count: u64,
updated_at: String,
_archived: bool,
topics: Vec<String>,
license: Option<GitHubLicense>,
}
#[derive(Debug, Deserialize)]
struct GitHubLicense {
spdx_id: Option<String>,
name: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GitHubSearchResult {
pub full_name: String,
pub url: String,
pub description: String,
pub language: String,
pub stars: u64,
pub forks: u64,
pub open_issues: u64,
pub updated_at: String,
pub topics: Vec<String>,
pub license: String,
}
impl From<&GitHubSearchResult> for SearchResult {
fn from(r: &GitHubSearchResult) -> Self {
SearchResult {
title: r.full_name.clone(),
url: r.url.clone(),
snippet: r.description.clone(),
engines: vec!["GitHub".to_string()],
score: r.stars as f64,
}
}
}
fn resolve_github_token() -> Option<String> {
std::env::var("GITHUB_SEARCH_TOKEN")
.ok()
.or_else(|| std::env::var("GITHUB_TOKEN").ok())
.or_else(|| std::env::var("GH_TOKEN").ok())
}
async fn search_github_repos(
query: &str,
sort: &str,
order: &str,
limit: usize,
language: Option<&str>,
) -> Result<(u64, Vec<GitHubSearchResult>), ToolError> {
let mut url = format!(
"https://api.github.com/search/repositories?q={}&sort={}&order={}&per_page={}",
urlencoding(query),
sort,
order,
limit.min(MAX_RESULTS),
);
if let Some(lang) = language {
url = format!(
"https://api.github.com/search/repositories?q={}+language%3A{}&sort={}&order={}&per_page={}",
urlencoding(query),
urlencoding(lang),
sort,
order,
limit.min(MAX_RESULTS),
);
}
let mut builder = shared_http_client()
.get(&url)
.header("Accept", "application/vnd.github.v3+json")
.header("User-Agent", "oxi-agent");
if let Some(token) = resolve_github_token() {
builder = builder.header("Authorization", format!("Bearer {}", token));
}
let response = builder
.send()
.await
.map_err(|e| format!("GitHub API request failed: {}", e))?;
let status = response.status();
if status.as_u16() == 403 {
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub API rate limit exceeded. Set GITHUB_TOKEN env var for higher limits. Body: {}",
body.chars().take(200).collect::<String>()
));
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!(
"GitHub API returned status {}: {}",
status,
body.chars().take(300).collect::<String>()
));
}
let search_response: GitHubSearchResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse GitHub response: {}", e))?;
let results = search_response
.items
.into_iter()
.map(|repo| GitHubSearchResult {
full_name: repo.full_name,
url: repo.html_url,
description: repo.description.unwrap_or_default(),
language: repo.language.unwrap_or_default(),
stars: repo.stargazers_count,
forks: repo.forks_count,
open_issues: repo.open_issues_count,
updated_at: repo.updated_at,
topics: repo.topics,
license: repo
.license
.and_then(|l| l.spdx_id.or(l.name))
.unwrap_or_default(),
})
.collect();
Ok((search_response.total_count, results))
}
fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 3);
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
result.push(byte as char);
}
_ => {
result.push('%');
result.push_str(&format!("{:02X}", byte));
}
}
}
result
}
fn format_github_results(total: u64, results: &[GitHubSearchResult]) -> String {
if results.is_empty() {
return "No repositories found.".to_string();
}
let mut output = format!(
"Found {} repositories (showing {}):\n\n",
total,
results.len()
);
for (i, r) in results.iter().enumerate() {
let stars = if r.stars >= 1000 {
format!("{:.1}k", r.stars as f64 / 1000.0)
} else {
r.stars.to_string()
};
let desc = if r.description.chars().count() > 150 {
let truncated: String = r.description.chars().take(150).collect();
format!("{}...", truncated)
} else {
r.description.clone()
};
output.push_str(&format!(
"{}. **{}** ⭐{}\n {}\n {} {} | 🔀 {} forks | 📦 {} issues\n Updated: {}\n",
i + 1,
r.full_name,
stars,
r.url,
desc,
if r.language.is_empty() {
"Unknown".to_string()
} else {
r.language.clone()
},
r.forks,
r.open_issues,
&r.updated_at[..10], ));
if !r.topics.is_empty() {
output.push_str(&format!(" Topics: {}\n", r.topics.join(", ")));
}
if !r.license.is_empty() {
output.push_str(&format!(" License: {}\n", r.license));
}
output.push('\n');
}
output
}
pub struct GitHubSearchTool {
cache: Arc<SearchCache>,
}
impl GitHubSearchTool {
pub fn new(cache: Arc<SearchCache>) -> Self {
Self { cache }
}
}
#[async_trait]
impl AgentTool for GitHubSearchTool {
fn name(&self) -> &str {
"github_search"
}
fn label(&self) -> &str {
"GitHub Search"
}
fn description(&self) -> &str {
"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)."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (e.g. 'rust web framework', 'machine learning', 'owner:mariozechner')"
},
"sort": {
"type": "string",
"description": "Sort results by: 'stars' (default), 'forks', or 'updated'",
"enum": ["stars", "forks", "updated"],
"default": "stars"
},
"order": {
"type": "string",
"description": "Sort order: 'desc' (default) or 'asc'",
"enum": ["desc", "asc"],
"default": "desc"
},
"language": {
"type": "string",
"description": "Filter by programming language (e.g. 'rust', 'python', 'typescript')"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return (default: 10, max: 30)",
"default": 10
}
},
"required": ["query"]
})
}
async fn execute(
&self,
_tool_call_id: &str,
params: Value,
_signal: Option<oneshot::Receiver<()>>,
_ctx: &ToolContext,
) -> Result<AgentToolResult, ToolError> {
let query = params["query"]
.as_str()
.ok_or_else(|| "Missing required parameter: query".to_string())?;
let sort = params["sort"].as_str().unwrap_or("stars");
let sort = match sort {
"forks" | "updated" => sort,
_ => "stars",
};
let order = params["order"].as_str().unwrap_or("desc");
let order = match order {
"asc" => "asc",
_ => "desc",
};
let language = params["language"].as_str();
let limit = params["limit"]
.as_u64()
.unwrap_or(DEFAULT_MAX_RESULTS as u64)
.min(MAX_RESULTS as u64) as usize;
let (total, results) = search_github_repos(query, sort, order, limit, language).await?;
if results.is_empty() {
return Ok(AgentToolResult::success(format!(
"No GitHub repositories found for: {}",
query
)));
}
let search_id = self.cache.insert(
&format!("github:{}", query),
results.iter().map(|r| r.into()).collect(),
);
let output = format_github_results(total, &results);
let results_json: Vec<Value> = results
.iter()
.map(|r| {
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
})
})
.collect();
Ok(AgentToolResult::success(output).with_metadata(json!({
"results": results_json,
"query": query,
"searchId": search_id,
"totalCount": total,
"resultCount": results.len()
})))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_urlencoding() {
assert_eq!(urlencoding("hello world"), "hello%20world");
assert_eq!(urlencoding("rust&cargo"), "rust%26cargo");
assert_eq!(urlencoding("c++"), "c%2B%2B");
}
#[test]
fn test_format_github_results_empty() {
assert_eq!(format_github_results(0, &[]), "No repositories found.");
}
#[test]
fn test_format_github_results() {
let results = vec![GitHubSearchResult {
full_name: "rust-lang/rust".to_string(),
url: "https://github.com/rust-lang/rust".to_string(),
description: "Empowering everyone to build reliable and efficient software."
.to_string(),
language: "Rust".to_string(),
stars: 95000,
forks: 12000,
open_issues: 9000,
updated_at: "2026-05-08T12:00:00Z".to_string(),
topics: vec!["programming-language".to_string(), "systems".to_string()],
license: "MIT/Apache-2.0".to_string(),
}];
let formatted = format_github_results(1, &results);
assert!(formatted.contains("**rust-lang/rust**"));
assert!(formatted.contains("95.0k"));
assert!(formatted.contains("Rust"));
assert!(formatted.contains("Topics: programming-language, systems"));
}
#[test]
fn test_format_stars_under_1k() {
let results = vec![GitHubSearchResult {
full_name: "test/repo".to_string(),
url: "https://github.com/test/repo".to_string(),
description: "A test".to_string(),
language: "Python".to_string(),
stars: 500,
forks: 20,
open_issues: 3,
updated_at: "2026-05-01T00:00:00Z".to_string(),
topics: vec![],
license: String::new(),
}];
let formatted = format_github_results(1, &results);
assert!(formatted.contains("⭐500"));
}
#[test]
fn test_schema() {
let cache = Arc::new(SearchCache::new());
let tool = GitHubSearchTool::new(cache);
let schema = tool.parameters_schema();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["query"].is_object());
assert!(schema["properties"]["sort"].is_object());
assert!(schema["properties"]["language"].is_object());
assert!(schema["required"]
.as_array()
.unwrap()
.contains(&json!("query")));
}
#[tokio::test]
async fn test_github_search_live() {
let result = search_github_repos("rust web framework", "stars", "desc", 3, None).await;
if let Ok((total, results)) = result {
assert!(total > 0);
assert!(!results.is_empty());
assert!(results[0].stars > 0);
assert!(results[0].url.starts_with("https://github.com/"));
}
}
}