use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use smooth_operator_core::tool::ToolSchema;
use smooth_operator_core::Tool;
const DEFAULT_RESULTS: usize = 5;
const MAX_RESULTS: usize = 20;
#[derive(Clone)]
pub enum GithubAuth {
Token(String),
AppInstallation {
app_id: u64,
private_key: String,
installation_id: u64,
},
Unauthenticated,
}
impl std::fmt::Debug for GithubAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Token(_) => f.write_str("GithubAuth::Token(***)"),
Self::AppInstallation {
app_id,
installation_id,
..
} => f
.debug_struct("GithubAuth::AppInstallation")
.field("app_id", app_id)
.field("installation_id", installation_id)
.field("private_key", &"***")
.finish(),
Self::Unauthenticated => f.write_str("GithubAuth::Unauthenticated"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GithubSearchKind {
Code,
Issues,
}
impl GithubSearchKind {
fn parse(raw: Option<&str>) -> Self {
match raw.map(str::to_ascii_lowercase).as_deref() {
Some("issue" | "issues" | "pr" | "prs") => Self::Issues,
_ => Self::Code,
}
}
fn label(self) -> &'static str {
match self {
Self::Code => "code",
Self::Issues => "issues",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct GithubSearchResult {
pub title: String,
pub url: String,
pub snippet: String,
}
impl GithubSearchResult {
pub fn new(
title: impl Into<String>,
url: impl Into<String>,
snippet: impl Into<String>,
) -> Self {
Self {
title: title.into(),
url: url.into(),
snippet: snippet.into(),
}
}
}
#[async_trait]
pub trait GithubSearchBackend: Send + Sync {
async fn search(
&self,
query: &str,
kind: GithubSearchKind,
k: usize,
) -> anyhow::Result<Vec<GithubSearchResult>>;
}
fn ensure_crypto_provider() {
use std::sync::Once;
static INIT: Once = Once::new();
INIT.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
pub struct OctocrabGithubSearch {
auth: GithubAuth,
}
impl OctocrabGithubSearch {
#[must_use]
pub fn new(auth: GithubAuth) -> Self {
Self { auth }
}
fn client(&self) -> anyhow::Result<octocrab::Octocrab> {
ensure_crypto_provider();
let mut builder = octocrab::Octocrab::builder();
builder = match &self.auth {
GithubAuth::Token(token) => builder.personal_token(token.clone()),
GithubAuth::AppInstallation {
app_id,
private_key,
..
} => {
let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key.as_bytes())
.map_err(|e| anyhow::anyhow!("GitHub App private key invalid: {e}"))?;
builder.app((*app_id).into(), key)
}
GithubAuth::Unauthenticated => builder,
};
let client = builder.build()?;
if let GithubAuth::AppInstallation {
installation_id, ..
} = &self.auth
{
return Ok(client.installation((*installation_id).into())?);
}
Ok(client)
}
}
#[async_trait]
impl GithubSearchBackend for OctocrabGithubSearch {
async fn search(
&self,
query: &str,
kind: GithubSearchKind,
k: usize,
) -> anyhow::Result<Vec<GithubSearchResult>> {
let client = self.client()?;
match kind {
GithubSearchKind::Code => {
let page = client
.search()
.code(query)
.per_page(k as u8)
.send()
.await
.map_err(|e| map_github_err(e, "code"))?;
Ok(page
.items
.into_iter()
.map(|item| {
GithubSearchResult::new(
item.path.clone(),
item.html_url.to_string(),
format!(
"{} in {}",
item.name,
item.repository.full_name.unwrap_or_default()
),
)
})
.collect())
}
GithubSearchKind::Issues => {
let page = client
.search()
.issues_and_pull_requests(query)
.per_page(k as u8)
.send()
.await
.map_err(|e| map_github_err(e, "issues"))?;
Ok(page
.items
.into_iter()
.map(|item| {
let snippet = item.body.unwrap_or_default();
let snippet: String = snippet.chars().take(200).collect();
GithubSearchResult::new(item.title, item.html_url.to_string(), snippet)
})
.collect())
}
}
}
}
fn map_github_err(err: octocrab::Error, what: &str) -> anyhow::Error {
let msg = err.to_string();
if msg.contains("403") || msg.to_ascii_lowercase().contains("rate limit") {
anyhow::anyhow!("GitHub {what} search hit a rate limit (HTTP 403): {msg}")
} else {
anyhow::anyhow!("GitHub {what} search failed: {msg}")
}
}
pub struct GithubSearchTool {
backend: Arc<dyn GithubSearchBackend>,
owner: String,
repo: String,
}
impl GithubSearchTool {
#[must_use]
pub fn new(auth: GithubAuth, owner: impl Into<String>, repo: impl Into<String>) -> Self {
Self::with_backend(Arc::new(OctocrabGithubSearch::new(auth)), owner, repo)
}
#[must_use]
pub fn with_backend(
backend: Arc<dyn GithubSearchBackend>,
owner: impl Into<String>,
repo: impl Into<String>,
) -> Self {
Self {
backend,
owner: owner.into(),
repo: repo.into(),
}
}
fn scoped_query(&self, query: &str) -> String {
let lower = query.to_ascii_lowercase();
if lower.contains("repo:") || lower.contains("org:") || lower.contains("user:") {
query.to_string()
} else {
format!("{query} repo:{}/{}", self.owner, self.repo)
}
}
}
#[async_trait]
impl Tool for GithubSearchTool {
fn schema(&self) -> ToolSchema {
ToolSchema {
name: "github_search".to_string(),
description: format!(
"Search GitHub live for code or issues — fresh lookups beyond the indexed \
knowledge snapshot (newly-merged code, recent issues/PRs). Defaults to scoping \
results to the {}/{} repository; include a `repo:owner/name` qualifier in the \
query to search elsewhere. Use knowledge_search for already-indexed content; use \
this when you need the current state of the codebase or issue tracker. Returns \
results with title, URL, and snippet.",
self.owner, self.repo
),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The GitHub search query (GitHub search qualifiers allowed)."
},
"kind": {
"type": "string",
"enum": ["code", "issues"],
"description": "Search source code ('code') or issues + PRs ('issues'). Defaults to 'code'."
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default 5, max 20).",
"minimum": 1,
"maximum": 20
}
},
"required": ["query"]
}),
}
}
async fn execute(&self, arguments: serde_json::Value) -> anyhow::Result<String> {
let query = arguments
.get("query")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| anyhow::anyhow!("github_search requires a string 'query' argument"))?;
let kind =
GithubSearchKind::parse(arguments.get("kind").and_then(serde_json::Value::as_str));
let k = arguments
.get("limit")
.and_then(serde_json::Value::as_u64)
.map_or(DEFAULT_RESULTS, |n| (n as usize).clamp(1, MAX_RESULTS));
let scoped = self.scoped_query(query);
let results = self.backend.search(&scoped, kind, k).await?;
if results.is_empty() {
return Ok(format!(
"No GitHub {} results found for {scoped:?}.",
kind.label()
));
}
let mut out = format!(
"Found {} GitHub {} result(s) for {scoped:?}:\n",
results.len(),
kind.label()
);
for (i, r) in results.iter().enumerate() {
out.push_str(&format!(
"{}. {} — {}\n {}\n",
i + 1,
r.title,
r.url,
r.snippet
));
}
Ok(out)
}
fn is_read_only(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
struct StubBackend {
last: std::sync::Mutex<Option<(String, GithubSearchKind, usize)>>,
}
impl StubBackend {
fn new() -> Self {
Self {
last: std::sync::Mutex::new(None),
}
}
}
#[async_trait]
impl GithubSearchBackend for StubBackend {
async fn search(
&self,
query: &str,
kind: GithubSearchKind,
k: usize,
) -> anyhow::Result<Vec<GithubSearchResult>> {
*self.last.lock().unwrap() = Some((query.to_string(), kind, k));
Ok((0..k.min(2))
.map(|i| {
GithubSearchResult::new(
format!("result-{i}.rs"),
format!("https://github.com/acme/app/blob/main/result-{i}.rs"),
format!("snippet {i}"),
)
})
.collect())
}
}
fn tool() -> (GithubSearchTool, Arc<StubBackend>) {
let backend = Arc::new(StubBackend::new());
let tool = GithubSearchTool::with_backend(backend.clone(), "acme", "app");
(tool, backend)
}
#[test]
fn kind_parses_and_defaults_to_code() {
assert_eq!(GithubSearchKind::parse(None), GithubSearchKind::Code);
assert_eq!(
GithubSearchKind::parse(Some("code")),
GithubSearchKind::Code
);
assert_eq!(
GithubSearchKind::parse(Some("issues")),
GithubSearchKind::Issues
);
assert_eq!(
GithubSearchKind::parse(Some("issue")),
GithubSearchKind::Issues
);
assert_eq!(
GithubSearchKind::parse(Some("PRs")),
GithubSearchKind::Issues
);
assert_eq!(
GithubSearchKind::parse(Some("nonsense")),
GithubSearchKind::Code
);
}
#[test]
fn scoped_query_appends_repo_scope() {
let (tool, _) = tool();
assert_eq!(tool.scoped_query("foo bar"), "foo bar repo:acme/app");
}
#[test]
fn scoped_query_respects_explicit_repo_qualifier() {
let (tool, _) = tool();
assert_eq!(
tool.scoped_query("foo repo:other/thing"),
"foo repo:other/thing"
);
assert_eq!(tool.scoped_query("bar org:acme"), "bar org:acme");
}
#[tokio::test]
async fn execute_requires_query() {
let (tool, _) = tool();
let err = tool
.execute(serde_json::json!({ "kind": "code" }))
.await
.expect_err("missing query should error");
assert!(err.to_string().contains("query"));
}
#[tokio::test]
async fn execute_scopes_query_and_formats_results() {
let (tool, backend) = tool();
let out = tool
.execute(serde_json::json!({ "query": "fn main", "limit": 2 }))
.await
.expect("execute");
let (q, kind, k) = backend
.last
.lock()
.unwrap()
.clone()
.expect("backend called");
assert_eq!(q, "fn main repo:acme/app");
assert_eq!(kind, GithubSearchKind::Code);
assert_eq!(k, 2);
assert!(out.contains("Found 2 GitHub code result(s)"), "got: {out}");
assert!(out.contains("result-0.rs"), "got: {out}");
assert!(
out.contains("https://github.com/acme/app/blob/main/result-1.rs"),
"got: {out}"
);
assert!(tool.is_read_only());
}
#[tokio::test]
async fn execute_routes_issues_kind() {
let (tool, backend) = tool();
let out = tool
.execute(serde_json::json!({ "query": "login broken", "kind": "issues" }))
.await
.expect("execute");
let (_, kind, _) = backend.last.lock().unwrap().clone().unwrap();
assert_eq!(kind, GithubSearchKind::Issues);
assert!(out.contains("GitHub issues result(s)"), "got: {out}");
}
#[tokio::test]
async fn execute_clamps_limit_to_max() {
let (tool, backend) = tool();
tool.execute(serde_json::json!({ "query": "x", "limit": 9999 }))
.await
.expect("execute");
let (_, _, k) = backend.last.lock().unwrap().clone().unwrap();
assert_eq!(k, MAX_RESULTS);
}
#[tokio::test]
async fn empty_results_render_a_clear_message() {
struct Empty;
#[async_trait]
impl GithubSearchBackend for Empty {
async fn search(
&self,
_q: &str,
_kind: GithubSearchKind,
_k: usize,
) -> anyhow::Result<Vec<GithubSearchResult>> {
Ok(vec![])
}
}
let tool = GithubSearchTool::with_backend(Arc::new(Empty), "acme", "app");
let out = tool
.execute(serde_json::json!({ "query": "zzz" }))
.await
.unwrap();
assert!(out.contains("No GitHub code results found"), "got: {out}");
}
#[test]
fn auth_debug_never_leaks_secrets() {
let token = GithubAuth::Token("ghp_secretvalue".to_string());
assert!(!format!("{token:?}").contains("secretvalue"));
}
#[tokio::test]
#[ignore = "network: gated on SMOOTH_AGENT_E2E"]
async fn live_search() {
if std::env::var("SMOOTH_AGENT_E2E").as_deref() != Ok("1") {
eprintln!("skipping live GitHub search: set SMOOTH_AGENT_E2E=1 to run");
return;
}
let auth = std::env::var("GITHUB_TOKEN")
.map(GithubAuth::Token)
.unwrap_or(GithubAuth::Unauthenticated);
let tool = GithubSearchTool::new(auth, "rust-lang", "rust");
let out = tool
.execute(serde_json::json!({ "query": "fn main", "kind": "code", "limit": 3 }))
.await
.expect("live search");
eprintln!("{out}");
assert!(out.contains("github.com"), "expected GitHub URLs: {out}");
}
}