Skip to main content

oxi_agent/tools/
github_search.rs

1use super::http_client::shared_http_client;
2use super::search_cache::{SearchCache, SearchResult};
3/// GitHub search tool — search GitHub repositories, issues, and code via the GitHub REST API.
4///
5/// Features:
6/// - Search repositories by topic, language, stars, etc.
7/// - Sort by stars, forks, or recently updated
8/// - Optional GitHub token for higher rate limits (via GITHUB_TOKEN env var)
9/// - Structured JSON results — no HTML scraping
10/// - Result caching with the shared SearchCache
11use 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
20/// Default results per response — referenced by `default_gs_limit`.
21const DEFAULT_MAX_RESULTS: u64 = 10;
22
23/// Maximum results allowed (GitHub API max is 100 per page).
24const MAX_RESULTS: usize = 30;
25
26// ── GitHub API response types ─────────────────────────────────────
27
28/// Top-level GitHub search response.
29#[derive(Debug, Deserialize)]
30
31struct GitHubSearchResponse {
32    total_count: u64,
33    _incomplete_results: bool,
34    items: Vec<GitHubRepo>,
35}
36
37/// A single repository from GitHub search.
38#[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// ── GitHub search result (our public type) ────────────────────────
61
62/// A single GitHub repository result.
63#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
64pub struct GitHubSearchResult {
65    /// Full repo name (e.g. "owner/repo").
66    pub full_name: String,
67    /// Repository URL.
68    pub url: String,
69    /// Repository description.
70    pub description: String,
71    /// Primary programming language.
72    pub language: String,
73    /// Star count.
74    pub stars: u64,
75    /// Fork count.
76    pub forks: u64,
77    /// Open issues count.
78    pub open_issues: u64,
79    /// Last update timestamp.
80    pub updated_at: String,
81    /// Repository topics/tags.
82    pub topics: Vec<String>,
83    /// License name.
84    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
99// ── API call ──────────────────────────────────────────────────────
100
101/// Resolve a GitHub API token from the environment.
102fn resolve_github_token() -> Option<String> {
103    // 1. GITHUB_SEARCH_TOKEN (explicit for this tool)
104    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
110/// Search GitHub repositories via the REST API.
111async 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    // Add language filter if specified
127    if let Some(lang) = language {
128        // Append language:xxx to the query
129        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    // Attach token if available (raises rate limit from 10/min to 5000/hr)
145    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
199/// URL-encode a string for query parameters.
200fn 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
216// ── Formatting ────────────────────────────────────────────────────
217
218/// Format GitHub search results for display.
219fn 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], // Just the date part
259        ));
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
275// ── GitHubSearchTool ──────────────────────────────────────────────
276
277/// GitHub repository search tool using the GitHub REST API.
278pub struct GitHubSearchTool {
279    cache: Arc<SearchCache>,
280}
281
282impl GitHubSearchTool {
283    /// Create a new GitHubSearchTool with the given cache.
284    pub fn new(cache: Arc<SearchCache>) -> Self {
285        Self { cache }
286    }
287}
288
289/// Arguments accepted by the GitHub code-search tool.
290#[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// ── Tests ─────────────────────────────────────────────────────────
416
417#[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        // Integration test — requires network. Skip if offline.
493        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}