adk-mcp-github 1.1.0

GitHub MCP Server — repositories, files, branches, issues, PRs, diffs, reviews, and releases for ADK-Rust Enterprise agents
use crate::types::*;
use reqwest::Client;
use serde_json::Value;

pub struct GitHubClient {
    client: Client,
    token: String,
}

impl GitHubClient {
    pub fn new(token: String) -> Self {
        Self { client: Client::new(), token }
    }

    async fn get(&self, path: &str) -> Result<Value, String> {
        let url = if path.starts_with("https://") { path.to_string() } else { format!("https://api.github.com{}", path) };
        self.client.get(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .header("User-Agent", "mcp-github/1.0")
            .header("Accept", "application/vnd.github+json")
            .send().await.map_err(|e| e.to_string())?
            .json::<Value>().await.map_err(|e| e.to_string())
    }

    async fn post(&self, path: &str, body: &Value) -> Result<Value, String> {
        let url = format!("https://api.github.com{}", path);
        self.client.post(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .header("User-Agent", "mcp-github/1.0")
            .header("Accept", "application/vnd.github+json")
            .json(body)
            .send().await.map_err(|e| e.to_string())?
            .json::<Value>().await.map_err(|e| e.to_string())
    }

    async fn patch(&self, path: &str, body: &Value) -> Result<Value, String> {
        let url = format!("https://api.github.com{}", path);
        self.client.patch(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .header("User-Agent", "mcp-github/1.0")
            .header("Accept", "application/vnd.github+json")
            .json(body)
            .send().await.map_err(|e| e.to_string())?
            .json::<Value>().await.map_err(|e| e.to_string())
    }

    pub async fn search_repositories(&self, query: &str) -> Result<Vec<SearchResult>, String> {
        let v = self.get(&format!("/search/repositories?q={}&per_page=10", urlencoded(query))).await?;
        Ok(v["items"].as_array().unwrap_or(&vec![]).iter().map(|i| SearchResult {
            full_name: i["full_name"].as_str().unwrap_or("").to_string(),
            description: i["description"].as_str().map(|s| s.to_string()),
            html_url: i["html_url"].as_str().unwrap_or("").to_string(),
            stargazers_count: i["stargazers_count"].as_u64().unwrap_or(0),
        }).collect())
    }

    pub async fn get_repository(&self, owner: &str, repo: &str) -> Result<Repository, String> {
        let v = self.get(&format!("/repos/{}/{}", owner, repo)).await?;
        Ok(Repository {
            full_name: v["full_name"].as_str().unwrap_or("").to_string(),
            description: v["description"].as_str().map(|s| s.to_string()),
            html_url: v["html_url"].as_str().unwrap_or("").to_string(),
            default_branch: v["default_branch"].as_str().unwrap_or("main").to_string(),
            language: v["language"].as_str().map(|s| s.to_string()),
            stargazers_count: v["stargazers_count"].as_u64().unwrap_or(0),
            open_issues_count: v["open_issues_count"].as_u64().unwrap_or(0),
            private: v["private"].as_bool().unwrap_or(false),
        })
    }

    pub async fn list_branches(&self, owner: &str, repo: &str) -> Result<Vec<Branch>, String> {
        let v = self.get(&format!("/repos/{}/{}/branches?per_page=30", owner, repo)).await?;
        Ok(v.as_array().unwrap_or(&vec![]).iter().map(|b| Branch {
            name: b["name"].as_str().unwrap_or("").to_string(),
            protected: b["protected"].as_bool().unwrap_or(false),
        }).collect())
    }

    pub async fn get_file_contents(&self, owner: &str, repo: &str, path: &str, branch: Option<&str>) -> Result<FileContent, String> {
        let ref_param = branch.map(|b| format!("?ref={}", b)).unwrap_or_default();
        let v = self.get(&format!("/repos/{}/{}/contents/{}{}", owner, repo, path, ref_param)).await?;
        let content = v["content"].as_str().unwrap_or("").replace('\n', "");
        let decoded = String::from_utf8(base64_decode(&content)).unwrap_or_else(|_| content.clone());
        Ok(FileContent {
            path: v["path"].as_str().unwrap_or(path).to_string(),
            content: decoded,
            sha: v["sha"].as_str().unwrap_or("").to_string(),
            size: v["size"].as_u64().unwrap_or(0),
        })
    }

    pub async fn search_code(&self, query: &str) -> Result<Vec<CodeSearchResult>, String> {
        let v = self.get(&format!("/search/code?q={}&per_page=10", urlencoded(query))).await?;
        Ok(v["items"].as_array().unwrap_or(&vec![]).iter().map(|i| CodeSearchResult {
            path: i["path"].as_str().unwrap_or("").to_string(),
            repository: i["repository"]["full_name"].as_str().unwrap_or("").to_string(),
            html_url: i["html_url"].as_str().unwrap_or("").to_string(),
            fragment: i["text_matches"].as_array().and_then(|m| m.first()).and_then(|m| m["fragment"].as_str()).map(|s| s.to_string()),
        }).collect())
    }

    pub async fn list_pull_requests(&self, owner: &str, repo: &str, state: Option<&str>) -> Result<Vec<PullRequest>, String> {
        let st = state.unwrap_or("open");
        let v = self.get(&format!("/repos/{}/{}/pulls?state={}&per_page=20", owner, repo, st)).await?;
        Ok(v.as_array().unwrap_or(&vec![]).iter().map(|p| PullRequest {
            number: p["number"].as_u64().unwrap_or(0),
            title: p["title"].as_str().unwrap_or("").to_string(),
            state: p["state"].as_str().unwrap_or("").to_string(),
            html_url: p["html_url"].as_str().unwrap_or("").to_string(),
            head: p["head"]["ref"].as_str().unwrap_or("").to_string(),
            base: p["base"]["ref"].as_str().unwrap_or("").to_string(),
            user: p["user"]["login"].as_str().map(|s| s.to_string()),
            mergeable: p["mergeable"].as_bool(),
            additions: p["additions"].as_u64(),
            deletions: p["deletions"].as_u64(),
            changed_files: p["changed_files"].as_u64(),
        }).collect())
    }

    pub async fn get_pull_request(&self, owner: &str, repo: &str, number: u64) -> Result<PullRequest, String> {
        let p = self.get(&format!("/repos/{}/{}/pulls/{}", owner, repo, number)).await?;
        Ok(PullRequest {
            number: p["number"].as_u64().unwrap_or(0),
            title: p["title"].as_str().unwrap_or("").to_string(),
            state: p["state"].as_str().unwrap_or("").to_string(),
            html_url: p["html_url"].as_str().unwrap_or("").to_string(),
            head: p["head"]["ref"].as_str().unwrap_or("").to_string(),
            base: p["base"]["ref"].as_str().unwrap_or("").to_string(),
            user: p["user"]["login"].as_str().map(|s| s.to_string()),
            mergeable: p["mergeable"].as_bool(),
            additions: p["additions"].as_u64(),
            deletions: p["deletions"].as_u64(),
            changed_files: p["changed_files"].as_u64(),
        })
    }

    pub async fn get_pull_request_diff(&self, owner: &str, repo: &str, number: u64) -> Result<String, String> {
        let url = format!("https://api.github.com/repos/{}/{}/pulls/{}", owner, repo, number);
        let resp = self.client.get(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .header("User-Agent", "mcp-github/1.0")
            .header("Accept", "application/vnd.github.diff")
            .send().await.map_err(|e| e.to_string())?;
        resp.text().await.map_err(|e| e.to_string())
    }

    pub async fn create_pull_request_review(&self, owner: &str, repo: &str, number: u64, body: &str, event: &str) -> Result<Value, String> {
        self.post(&format!("/repos/{}/{}/pulls/{}/reviews", owner, repo, number), &serde_json::json!({"body": body, "event": event})).await
    }

    pub async fn create_issue(&self, owner: &str, repo: &str, title: &str, body: Option<&str>, labels: Vec<String>) -> Result<Issue, String> {
        let mut payload = serde_json::json!({"title": title});
        if let Some(b) = body { payload["body"] = serde_json::Value::String(b.to_string()); }
        if !labels.is_empty() { payload["labels"] = serde_json::json!(labels); }
        let v = self.post(&format!("/repos/{}/{}/issues", owner, repo), &payload).await?;
        Ok(Issue {
            number: v["number"].as_u64().unwrap_or(0),
            title: v["title"].as_str().unwrap_or("").to_string(),
            state: v["state"].as_str().unwrap_or("open").to_string(),
            html_url: v["html_url"].as_str().unwrap_or("").to_string(),
            body: v["body"].as_str().map(|s| s.to_string()),
            user: v["user"]["login"].as_str().map(|s| s.to_string()),
            labels: v["labels"].as_array().unwrap_or(&vec![]).iter().filter_map(|l| l["name"].as_str().map(|s| s.to_string())).collect(),
        })
    }

    pub async fn update_issue(&self, owner: &str, repo: &str, number: u64, title: Option<&str>, body: Option<&str>, state: Option<&str>, labels: Option<Vec<String>>) -> Result<Issue, String> {
        let mut payload = serde_json::json!({});
        if let Some(t) = title { payload["title"] = serde_json::Value::String(t.to_string()); }
        if let Some(b) = body { payload["body"] = serde_json::Value::String(b.to_string()); }
        if let Some(s) = state { payload["state"] = serde_json::Value::String(s.to_string()); }
        if let Some(l) = labels { payload["labels"] = serde_json::json!(l); }
        let v = self.patch(&format!("/repos/{}/{}/issues/{}", owner, repo, number), &payload).await?;
        Ok(Issue {
            number: v["number"].as_u64().unwrap_or(0),
            title: v["title"].as_str().unwrap_or("").to_string(),
            state: v["state"].as_str().unwrap_or("").to_string(),
            html_url: v["html_url"].as_str().unwrap_or("").to_string(),
            body: v["body"].as_str().map(|s| s.to_string()),
            user: v["user"]["login"].as_str().map(|s| s.to_string()),
            labels: v["labels"].as_array().unwrap_or(&vec![]).iter().filter_map(|l| l["name"].as_str().map(|s| s.to_string())).collect(),
        })
    }

    pub async fn list_releases(&self, owner: &str, repo: &str) -> Result<Vec<Release>, String> {
        let v = self.get(&format!("/repos/{}/{}/releases?per_page=10", owner, repo)).await?;
        Ok(v.as_array().unwrap_or(&vec![]).iter().map(|r| Release {
            tag_name: r["tag_name"].as_str().unwrap_or("").to_string(),
            name: r["name"].as_str().map(|s| s.to_string()),
            html_url: r["html_url"].as_str().unwrap_or("").to_string(),
            published_at: r["published_at"].as_str().map(|s| s.to_string()),
            draft: r["draft"].as_bool().unwrap_or(false),
            prerelease: r["prerelease"].as_bool().unwrap_or(false),
        }).collect())
    }

    pub async fn create_pull_request(&self, owner: &str, repo: &str, title: &str, head: &str, base: &str, body: Option<&str>) -> Result<Value, String> {
        let mut payload = serde_json::json!({"title": title, "head": head, "base": base});
        if let Some(b) = body { payload["body"] = serde_json::Value::String(b.to_string()); }
        self.post(&format!("/repos/{}/{}/pulls", owner, repo), &payload).await
    }

    pub async fn merge_pull_request(&self, owner: &str, repo: &str, number: u64, merge_method: Option<&str>) -> Result<Value, String> {
        let method = merge_method.unwrap_or("merge");
        let url = format!("https://api.github.com/repos/{}/{}/pulls/{}/merge", owner, repo, number);
        self.client.put(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .header("User-Agent", "mcp-github/1.0")
            .header("Accept", "application/vnd.github+json")
            .json(&serde_json::json!({"merge_method": method}))
            .send().await.map_err(|e| e.to_string())?
            .json::<Value>().await.map_err(|e| e.to_string())
    }

    pub async fn list_pr_comments(&self, owner: &str, repo: &str, number: u64) -> Result<Value, String> {
        self.get(&format!("/repos/{}/{}/issues/{}/comments?per_page=30", owner, repo, number)).await
    }

    pub async fn add_pr_comment(&self, owner: &str, repo: &str, number: u64, body: &str) -> Result<Value, String> {
        self.post(&format!("/repos/{}/{}/issues/{}/comments", owner, repo, number), &serde_json::json!({"body": body})).await
    }

    pub async fn create_branch(&self, owner: &str, repo: &str, branch: &str, from_sha: &str) -> Result<Value, String> {
        self.post(&format!("/repos/{}/{}/git/refs", owner, repo), &serde_json::json!({"ref": format!("refs/heads/{}", branch), "sha": from_sha})).await
    }

    pub async fn delete_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<(), String> {
        let url = format!("https://api.github.com/repos/{}/{}/git/refs/heads/{}", owner, repo, branch);
        let resp = self.client.delete(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .header("User-Agent", "mcp-github/1.0")
            .send().await.map_err(|e| e.to_string())?;
        if resp.status().is_success() { Ok(()) } else { Err(format!("HTTP {}", resp.status())) }
    }

    pub async fn list_commits(&self, owner: &str, repo: &str, branch: Option<&str>, per_page: Option<u64>) -> Result<Value, String> {
        let sha = branch.map(|b| format!("&sha={}", b)).unwrap_or_default();
        let pp = per_page.unwrap_or(20);
        self.get(&format!("/repos/{}/{}/commits?per_page={}{}", owner, repo, pp, sha)).await
    }

    pub async fn get_commit(&self, owner: &str, repo: &str, sha: &str) -> Result<Value, String> {
        self.get(&format!("/repos/{}/{}/commits/{}", owner, repo, sha)).await
    }

    pub async fn list_workflow_runs(&self, owner: &str, repo: &str, branch: Option<&str>) -> Result<Value, String> {
        let b = branch.map(|b| format!("&branch={}", b)).unwrap_or_default();
        self.get(&format!("/repos/{}/{}/actions/runs?per_page=10{}", owner, repo, b)).await
    }

    pub async fn get_workflow_run(&self, owner: &str, repo: &str, run_id: u64) -> Result<Value, String> {
        self.get(&format!("/repos/{}/{}/actions/runs/{}", owner, repo, run_id)).await
    }

    pub async fn create_or_update_file(&self, owner: &str, repo: &str, path: &str, content: &str, message: &str, sha: Option<&str>, branch: Option<&str>) -> Result<Value, String> {
        let encoded = base64_encode(content.as_bytes());
        let mut payload = serde_json::json!({"message": message, "content": encoded});
        if let Some(s) = sha { payload["sha"] = serde_json::Value::String(s.to_string()); }
        if let Some(b) = branch { payload["branch"] = serde_json::Value::String(b.to_string()); }
        let url = format!("https://api.github.com/repos/{}/{}/contents/{}", owner, repo, path);
        self.client.put(&url)
            .header("Authorization", format!("Bearer {}", self.token))
            .header("User-Agent", "mcp-github/1.0")
            .header("Accept", "application/vnd.github+json")
            .json(&payload)
            .send().await.map_err(|e| e.to_string())?
            .json::<Value>().await.map_err(|e| e.to_string())
    }

    pub async fn list_directory(&self, owner: &str, repo: &str, path: &str, branch: Option<&str>) -> Result<Value, String> {
        let ref_param = branch.map(|b| format!("?ref={}", b)).unwrap_or_default();
        self.get(&format!("/repos/{}/{}/contents/{}{}", owner, repo, path, ref_param)).await
    }
}

fn base64_encode(data: &[u8]) -> String {
    let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut out = String::new();
    for chunk in data.chunks(3) {
        let b0 = chunk[0] as u32;
        let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
        let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
        let triple = (b0 << 16) | (b1 << 8) | b2;
        out.push(table[((triple >> 18) & 0x3F) as usize] as char);
        out.push(table[((triple >> 12) & 0x3F) as usize] as char);
        if chunk.len() > 1 { out.push(table[((triple >> 6) & 0x3F) as usize] as char); } else { out.push('='); }
        if chunk.len() > 2 { out.push(table[(triple & 0x3F) as usize] as char); } else { out.push('='); }
    }
    out
}

fn urlencoded(s: &str) -> String {
    s.replace(' ', "+").replace('/', "%2F").replace('@', "%40")
}

fn base64_decode(s: &str) -> Vec<u8> {
    let s = s.replace(['\n', '\r', ' '], "");
    let mut out = Vec::new();
    let chars: Vec<u8> = s.bytes().collect();
    let table = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    for chunk in chars.chunks(4) {
        let mut buf = [0u8; 4];
        for (i, &b) in chunk.iter().enumerate() {
            buf[i] = table.iter().position(|&c| c == b).unwrap_or(0) as u8;
        }
        out.push((buf[0] << 2) | (buf[1] >> 4));
        if chunk.len() > 2 && chunk[2] != b'=' { out.push((buf[1] << 4) | (buf[2] >> 2)); }
        if chunk.len() > 3 && chunk[3] != b'=' { out.push((buf[2] << 6) | buf[3]); }
    }
    out
}