From ae98b6f230a28d86cd1e46630612a197ec59fca0 Mon Sep 17 00:00:00 2001 From: Eric Garcia Date: Sat, 24 Jan 2026 20:07:44 -0500 Subject: [PATCH] feat: add git forge integration for GitHub and Forgejo (RFC 0013) - Add forge module with Forge trait for unified PR operations - Implement GitHubForge with REST API client - Implement ForgejoForge with REST API client (works with Gitea too) - Add git URL parsing to extract host, owner, repo from remotes - Add auto-detection of forge type from remote URLs - Update blue_pr_create to use native forge API instead of gh CLI - Support GITHUB_TOKEN and FORGEJO_TOKEN environment variables The forge abstraction allows Blue to create PRs on both GitHub and Forgejo/Gitea instances without requiring external CLI tools. Co-Authored-By: Claude Opus 4.5 --- crates/blue-core/src/forge/forgejo.rs | 212 ++++++++++++++++++++++++ crates/blue-core/src/forge/git_url.rs | 205 ++++++++++++++++++++++++ crates/blue-core/src/forge/github.rs | 208 ++++++++++++++++++++++++ crates/blue-core/src/forge/mod.rs | 221 ++++++++++++++++++++++++++ crates/blue-core/src/lib.rs | 2 + crates/blue-mcp/src/handlers/pr.rs | 168 ++++++++++++++++---- 6 files changed, 984 insertions(+), 32 deletions(-) create mode 100644 crates/blue-core/src/forge/forgejo.rs create mode 100644 crates/blue-core/src/forge/git_url.rs create mode 100644 crates/blue-core/src/forge/github.rs create mode 100644 crates/blue-core/src/forge/mod.rs diff --git a/crates/blue-core/src/forge/forgejo.rs b/crates/blue-core/src/forge/forgejo.rs new file mode 100644 index 0000000..00dfbd0 --- /dev/null +++ b/crates/blue-core/src/forge/forgejo.rs @@ -0,0 +1,212 @@ +//! Forgejo/Gitea forge implementation +//! +//! REST API client for Forgejo and Gitea instances. + +use serde::{Deserialize, Serialize}; + +use super::{CreatePrOpts, Forge, ForgeError, ForgeType, MergeStrategy, PrState, PullRequest}; + +/// Forgejo/Gitea forge implementation +pub struct ForgejoForge { + host: String, + token: String, + client: reqwest::blocking::Client, +} + +impl ForgejoForge { + /// Create a new Forgejo forge client + pub fn new(host: &str, token: String) -> Self { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to create HTTP client"); + + Self { + host: host.to_string(), + token, + client, + } + } + + fn api_url(&self, path: &str) -> String { + format!("https://{}/api/v1{}", self.host, path) + } + + fn auth_header(&self) -> String { + format!("token {}", self.token) + } +} + +impl Forge for ForgejoForge { + fn create_pr(&self, opts: CreatePrOpts) -> Result { + let url = self.api_url(&format!("/repos/{}/{}/pulls", opts.owner, opts.repo)); + + let body = ForgejoCreatePr { + title: opts.title, + body: opts.body, + head: opts.head, + base: opts.base, + }; + + let response = self.client + .post(&url) + .header("Authorization", self.auth_header()) + .header("Content-Type", "application/json") + .json(&body) + .send() + .map_err(|e| ForgeError::Http(e.to_string()))?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().unwrap_or_default(); + return Err(ForgeError::Api { + status: status.as_u16(), + message: error_text, + }); + } + + let pr: ForgejoPr = response + .json() + .map_err(|e| ForgeError::Parse(e.to_string()))?; + + Ok(pr.into_pull_request(&self.host, &opts.owner, &opts.repo)) + } + + fn get_pr(&self, owner: &str, repo: &str, number: u64) -> Result { + let url = self.api_url(&format!("/repos/{}/{}/pulls/{}", owner, repo, number)); + + let response = self.client + .get(&url) + .header("Authorization", self.auth_header()) + .send() + .map_err(|e| ForgeError::Http(e.to_string()))?; + + let status = response.status(); + if status.as_u16() == 404 { + return Err(ForgeError::NotFound { + owner: owner.to_string(), + repo: repo.to_string(), + number, + }); + } + + if !status.is_success() { + let error_text = response.text().unwrap_or_default(); + return Err(ForgeError::Api { + status: status.as_u16(), + message: error_text, + }); + } + + let pr: ForgejoPr = response + .json() + .map_err(|e| ForgeError::Parse(e.to_string()))?; + + Ok(pr.into_pull_request(&self.host, owner, repo)) + } + + fn merge_pr(&self, owner: &str, repo: &str, number: u64, strategy: MergeStrategy) -> Result<(), ForgeError> { + let url = self.api_url(&format!("/repos/{}/{}/pulls/{}/merge", owner, repo, number)); + + let do_type = match strategy { + MergeStrategy::Merge => "merge", + MergeStrategy::Squash => "squash", + MergeStrategy::Rebase => "rebase", + }; + + let body = ForgejoMergePr { + do_type: do_type.to_string(), + merge_message_field: None, + }; + + let response = self.client + .post(&url) + .header("Authorization", self.auth_header()) + .header("Content-Type", "application/json") + .json(&body) + .send() + .map_err(|e| ForgeError::Http(e.to_string()))?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().unwrap_or_default(); + return Err(ForgeError::Api { + status: status.as_u16(), + message: error_text, + }); + } + + Ok(()) + } + + fn pr_is_merged(&self, owner: &str, repo: &str, number: u64) -> Result { + let url = self.api_url(&format!("/repos/{}/{}/pulls/{}/merge", owner, repo, number)); + + let response = self.client + .get(&url) + .header("Authorization", self.auth_header()) + .send() + .map_err(|e| ForgeError::Http(e.to_string()))?; + + // 204 = merged, 404 = not merged + Ok(response.status().as_u16() == 204) + } + + fn forge_type(&self) -> ForgeType { + ForgeType::Forgejo + } +} + +// Forgejo API request/response types + +#[derive(Debug, Serialize)] +struct ForgejoCreatePr { + title: String, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option, + head: String, + base: String, +} + +#[derive(Debug, Serialize)] +struct ForgejoMergePr { + #[serde(rename = "Do")] + do_type: String, + #[serde(rename = "MergeMessageField", skip_serializing_if = "Option::is_none")] + merge_message_field: Option, +} + +#[derive(Debug, Deserialize)] +struct ForgejoPr { + number: u64, + title: String, + state: String, + merged: bool, + head: ForgejoBranch, + base: ForgejoBranch, + html_url: Option, +} + +#[derive(Debug, Deserialize)] +struct ForgejoBranch { + #[serde(rename = "ref")] + ref_name: String, +} + +impl ForgejoPr { + fn into_pull_request(self, host: &str, owner: &str, repo: &str) -> PullRequest { + let url = self.html_url.unwrap_or_else(|| { + format!("https://{}/{}/{}/pulls/{}", host, owner, repo, self.number) + }); + + PullRequest { + number: self.number, + url, + title: self.title, + state: if self.state == "open" { PrState::Open } else { PrState::Closed }, + merged: self.merged, + head: self.head.ref_name, + base: self.base.ref_name, + } + } +} diff --git a/crates/blue-core/src/forge/git_url.rs b/crates/blue-core/src/forge/git_url.rs new file mode 100644 index 0000000..75d14f9 --- /dev/null +++ b/crates/blue-core/src/forge/git_url.rs @@ -0,0 +1,205 @@ +//! Git URL parsing utilities +//! +//! Parses various git remote URL formats to extract host, owner, and repo. + +/// Parsed git URL components +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GitUrl { + pub host: String, + pub owner: String, + pub repo: String, + pub protocol: GitProtocol, +} + +/// Git URL protocol +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GitProtocol { + Ssh, + Https, + Git, +} + +/// Parse a git remote URL into its components +/// +/// Supports formats: +/// - `git@github.com:owner/repo.git` (SSH) +/// - `https://github.com/owner/repo.git` (HTTPS) +/// - `git://github.com/owner/repo.git` (Git protocol) +/// - `ssh://git@github.com/owner/repo.git` (SSH with explicit protocol) +pub fn parse_git_url(url: &str) -> GitUrl { + let url = url.trim(); + + // SSH format: git@host:owner/repo.git + if url.starts_with("git@") || url.contains("@") && url.contains(":") && !url.contains("://") { + return parse_ssh_url(url); + } + + // HTTPS format: https://host/owner/repo.git + if url.starts_with("https://") { + return parse_https_url(url); + } + + // SSH with protocol: ssh://git@host/owner/repo.git + if url.starts_with("ssh://") { + return parse_ssh_protocol_url(url); + } + + // Git protocol: git://host/owner/repo.git + if url.starts_with("git://") { + return parse_git_protocol_url(url); + } + + // Fallback: try to extract what we can + GitUrl { + host: String::new(), + owner: String::new(), + repo: url.to_string(), + protocol: GitProtocol::Https, + } +} + +fn parse_ssh_url(url: &str) -> GitUrl { + // Format: git@host:owner/repo.git or user@host:owner/repo.git + let without_user = url.split('@').nth(1).unwrap_or(url); + let parts: Vec<&str> = without_user.splitn(2, ':').collect(); + + if parts.len() != 2 { + return GitUrl { + host: String::new(), + owner: String::new(), + repo: url.to_string(), + protocol: GitProtocol::Ssh, + }; + } + + let host = parts[0].to_string(); + let path = parts[1].trim_end_matches(".git"); + let path_parts: Vec<&str> = path.splitn(2, '/').collect(); + + let (owner, repo) = if path_parts.len() == 2 { + (path_parts[0].to_string(), path_parts[1].to_string()) + } else { + (String::new(), path.to_string()) + }; + + GitUrl { + host, + owner, + repo, + protocol: GitProtocol::Ssh, + } +} + +fn parse_https_url(url: &str) -> GitUrl { + // Format: https://host/owner/repo.git + let without_protocol = url.trim_start_matches("https://"); + let parts: Vec<&str> = without_protocol.splitn(4, '/').collect(); + + let host = parts.first().map(|s| s.to_string()).unwrap_or_default(); + let owner = parts.get(1).map(|s| s.to_string()).unwrap_or_default(); + let repo = parts.get(2) + .map(|s| s.trim_end_matches(".git").to_string()) + .unwrap_or_default(); + + GitUrl { + host, + owner, + repo, + protocol: GitProtocol::Https, + } +} + +fn parse_ssh_protocol_url(url: &str) -> GitUrl { + // Format: ssh://git@host/owner/repo.git + let without_protocol = url.trim_start_matches("ssh://"); + let without_user = without_protocol.split('@').nth(1).unwrap_or(without_protocol); + let parts: Vec<&str> = without_user.splitn(4, '/').collect(); + + let host = parts.first().map(|s| s.to_string()).unwrap_or_default(); + let owner = parts.get(1).map(|s| s.to_string()).unwrap_or_default(); + let repo = parts.get(2) + .map(|s| s.trim_end_matches(".git").to_string()) + .unwrap_or_default(); + + GitUrl { + host, + owner, + repo, + protocol: GitProtocol::Ssh, + } +} + +fn parse_git_protocol_url(url: &str) -> GitUrl { + // Format: git://host/owner/repo.git + let without_protocol = url.trim_start_matches("git://"); + let parts: Vec<&str> = without_protocol.splitn(4, '/').collect(); + + let host = parts.first().map(|s| s.to_string()).unwrap_or_default(); + let owner = parts.get(1).map(|s| s.to_string()).unwrap_or_default(); + let repo = parts.get(2) + .map(|s| s.trim_end_matches(".git").to_string()) + .unwrap_or_default(); + + GitUrl { + host, + owner, + repo, + protocol: GitProtocol::Git, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ssh_url() { + let url = parse_git_url("git@github.com:owner/repo.git"); + assert_eq!(url.host, "github.com"); + assert_eq!(url.owner, "owner"); + assert_eq!(url.repo, "repo"); + assert_eq!(url.protocol, GitProtocol::Ssh); + } + + #[test] + fn test_parse_https_url() { + let url = parse_git_url("https://github.com/owner/repo.git"); + assert_eq!(url.host, "github.com"); + assert_eq!(url.owner, "owner"); + assert_eq!(url.repo, "repo"); + assert_eq!(url.protocol, GitProtocol::Https); + } + + #[test] + fn test_parse_https_url_no_git_suffix() { + let url = parse_git_url("https://github.com/owner/repo"); + assert_eq!(url.host, "github.com"); + assert_eq!(url.owner, "owner"); + assert_eq!(url.repo, "repo"); + } + + #[test] + fn test_parse_codeberg_ssh() { + let url = parse_git_url("git@codeberg.org:user/project.git"); + assert_eq!(url.host, "codeberg.org"); + assert_eq!(url.owner, "user"); + assert_eq!(url.repo, "project"); + } + + #[test] + fn test_parse_custom_host() { + let url = parse_git_url("git@git.example.com:team/app.git"); + assert_eq!(url.host, "git.example.com"); + assert_eq!(url.owner, "team"); + assert_eq!(url.repo, "app"); + } + + #[test] + fn test_parse_ssh_protocol() { + let url = parse_git_url("ssh://git@github.com/owner/repo.git"); + assert_eq!(url.host, "github.com"); + assert_eq!(url.owner, "owner"); + assert_eq!(url.repo, "repo"); + assert_eq!(url.protocol, GitProtocol::Ssh); + } +} diff --git a/crates/blue-core/src/forge/github.rs b/crates/blue-core/src/forge/github.rs new file mode 100644 index 0000000..7f24548 --- /dev/null +++ b/crates/blue-core/src/forge/github.rs @@ -0,0 +1,208 @@ +//! GitHub forge implementation +//! +//! REST API client for GitHub. + +use serde::{Deserialize, Serialize}; + +use super::{CreatePrOpts, Forge, ForgeError, ForgeType, MergeStrategy, PrState, PullRequest}; + +/// GitHub forge implementation +pub struct GitHubForge { + token: String, + client: reqwest::blocking::Client, +} + +impl GitHubForge { + /// Create a new GitHub forge client + pub fn new(token: String) -> Self { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .user_agent("blue-mcp/0.1") + .build() + .expect("Failed to create HTTP client"); + + Self { token, client } + } + + fn api_url(path: &str) -> String { + format!("https://api.github.com{}", path) + } + + fn auth_header(&self) -> String { + format!("Bearer {}", self.token) + } +} + +impl Forge for GitHubForge { + fn create_pr(&self, opts: CreatePrOpts) -> Result { + let url = Self::api_url(&format!("/repos/{}/{}/pulls", opts.owner, opts.repo)); + + let body = GitHubCreatePr { + title: opts.title, + body: opts.body, + head: opts.head, + base: opts.base, + draft: opts.draft, + }; + + let response = self.client + .post(&url) + .header("Authorization", self.auth_header()) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .json(&body) + .send() + .map_err(|e| ForgeError::Http(e.to_string()))?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().unwrap_or_default(); + return Err(ForgeError::Api { + status: status.as_u16(), + message: error_text, + }); + } + + let pr: GitHubPr = response + .json() + .map_err(|e| ForgeError::Parse(e.to_string()))?; + + Ok(pr.into_pull_request()) + } + + fn get_pr(&self, owner: &str, repo: &str, number: u64) -> Result { + let url = Self::api_url(&format!("/repos/{}/{}/pulls/{}", owner, repo, number)); + + let response = self.client + .get(&url) + .header("Authorization", self.auth_header()) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .map_err(|e| ForgeError::Http(e.to_string()))?; + + let status = response.status(); + if status.as_u16() == 404 { + return Err(ForgeError::NotFound { + owner: owner.to_string(), + repo: repo.to_string(), + number, + }); + } + + if !status.is_success() { + let error_text = response.text().unwrap_or_default(); + return Err(ForgeError::Api { + status: status.as_u16(), + message: error_text, + }); + } + + let pr: GitHubPr = response + .json() + .map_err(|e| ForgeError::Parse(e.to_string()))?; + + Ok(pr.into_pull_request()) + } + + fn merge_pr(&self, owner: &str, repo: &str, number: u64, strategy: MergeStrategy) -> Result<(), ForgeError> { + let url = Self::api_url(&format!("/repos/{}/{}/pulls/{}/merge", owner, repo, number)); + + let merge_method = match strategy { + MergeStrategy::Merge => "merge", + MergeStrategy::Squash => "squash", + MergeStrategy::Rebase => "rebase", + }; + + let body = GitHubMergePr { + merge_method: merge_method.to_string(), + }; + + let response = self.client + .put(&url) + .header("Authorization", self.auth_header()) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .json(&body) + .send() + .map_err(|e| ForgeError::Http(e.to_string()))?; + + let status = response.status(); + if !status.is_success() { + let error_text = response.text().unwrap_or_default(); + return Err(ForgeError::Api { + status: status.as_u16(), + message: error_text, + }); + } + + Ok(()) + } + + fn pr_is_merged(&self, owner: &str, repo: &str, number: u64) -> Result { + let url = Self::api_url(&format!("/repos/{}/{}/pulls/{}/merge", owner, repo, number)); + + let response = self.client + .get(&url) + .header("Authorization", self.auth_header()) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .send() + .map_err(|e| ForgeError::Http(e.to_string()))?; + + // 204 = merged, 404 = not merged + Ok(response.status().as_u16() == 204) + } + + fn forge_type(&self) -> ForgeType { + ForgeType::GitHub + } +} + +// GitHub API request/response types + +#[derive(Debug, Serialize)] +struct GitHubCreatePr { + title: String, + #[serde(skip_serializing_if = "Option::is_none")] + body: Option, + head: String, + base: String, + draft: bool, +} + +#[derive(Debug, Serialize)] +struct GitHubMergePr { + merge_method: String, +} + +#[derive(Debug, Deserialize)] +struct GitHubPr { + number: u64, + title: String, + state: String, + merged: Option, + html_url: String, + head: GitHubBranch, + base: GitHubBranch, +} + +#[derive(Debug, Deserialize)] +struct GitHubBranch { + #[serde(rename = "ref")] + ref_name: String, +} + +impl GitHubPr { + fn into_pull_request(self) -> PullRequest { + PullRequest { + number: self.number, + url: self.html_url, + title: self.title, + state: if self.state == "open" { PrState::Open } else { PrState::Closed }, + merged: self.merged.unwrap_or(false), + head: self.head.ref_name, + base: self.base.ref_name, + } + } +} diff --git a/crates/blue-core/src/forge/mod.rs b/crates/blue-core/src/forge/mod.rs new file mode 100644 index 0000000..d2fd8a4 --- /dev/null +++ b/crates/blue-core/src/forge/mod.rs @@ -0,0 +1,221 @@ +//! Git Forge Integration (RFC 0013) +//! +//! Provides a unified interface for interacting with different git forges +//! (GitHub, Forgejo/Gitea) for PR operations. + +mod git_url; +mod github; +mod forgejo; + +pub use git_url::{GitUrl, parse_git_url}; +pub use github::GitHubForge; +pub use forgejo::ForgejoForge; + +use serde::{Deserialize, Serialize}; +use std::env; + +/// Supported forge types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ForgeType { + GitHub, + Forgejo, +} + +impl std::fmt::Display for ForgeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ForgeType::GitHub => write!(f, "github"), + ForgeType::Forgejo => write!(f, "forgejo"), + } + } +} + +/// Options for creating a pull request +#[derive(Debug, Clone)] +pub struct CreatePrOpts { + pub owner: String, + pub repo: String, + pub head: String, + pub base: String, + pub title: String, + pub body: Option, + pub draft: bool, +} + +/// Merge strategy for PRs +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MergeStrategy { + Merge, + Squash, + Rebase, +} + +/// Pull request info returned from forge operations +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PullRequest { + pub number: u64, + pub url: String, + pub title: String, + pub state: PrState, + pub merged: bool, + pub head: String, + pub base: String, +} + +/// Pull request state +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PrState { + Open, + Closed, +} + +/// Forge errors +#[derive(Debug, thiserror::Error)] +pub enum ForgeError { + #[error("HTTP request failed: {0}")] + Http(String), + + #[error("API error: {status} - {message}")] + Api { status: u16, message: String }, + + #[error("Missing token: set {var} environment variable")] + MissingToken { var: &'static str }, + + #[error("Failed to parse response: {0}")] + Parse(String), + + #[error("PR not found: {owner}/{repo}#{number}")] + NotFound { owner: String, repo: String, number: u64 }, +} + +/// The Forge trait - unified interface for git forges +pub trait Forge: Send + Sync { + /// Create a pull request + fn create_pr(&self, opts: CreatePrOpts) -> Result; + + /// Get a pull request by number + fn get_pr(&self, owner: &str, repo: &str, number: u64) -> Result; + + /// Merge a pull request + fn merge_pr(&self, owner: &str, repo: &str, number: u64, strategy: MergeStrategy) -> Result<(), ForgeError>; + + /// Check if a PR is merged + fn pr_is_merged(&self, owner: &str, repo: &str, number: u64) -> Result; + + /// Get the forge type + fn forge_type(&self) -> ForgeType; +} + +/// Detect forge type from a git remote URL +pub fn detect_forge_type(remote_url: &str) -> ForgeType { + let url = parse_git_url(remote_url); + + match url.host.as_str() { + "github.com" => ForgeType::GitHub, + "codeberg.org" => ForgeType::Forgejo, + host if host.contains("gitea") => ForgeType::Forgejo, + host if host.contains("forgejo") => ForgeType::Forgejo, + _ => { + // For unknown hosts, try probing the Forgejo/Gitea API + // If that fails, default to GitHub API format + if probe_forgejo_api(&url.host) { + ForgeType::Forgejo + } else { + ForgeType::GitHub + } + } + } +} + +/// Probe a host to see if it's running Forgejo/Gitea +fn probe_forgejo_api(host: &str) -> bool { + // Try to hit the version endpoint - Forgejo/Gitea respond, GitHub doesn't + let url = format!("https://{}/api/v1/version", host); + + // Use a blocking client with short timeout for probing + let client = match reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + { + Ok(c) => c, + Err(_) => return false, + }; + + match client.get(&url).send() { + Ok(resp) => resp.status().is_success(), + Err(_) => false, + } +} + +/// Get the appropriate token for a forge type +pub fn get_token(forge_type: ForgeType) -> Result { + match forge_type { + ForgeType::GitHub => { + env::var("GITHUB_TOKEN") + .or_else(|_| env::var("GH_TOKEN")) + .map_err(|_| ForgeError::MissingToken { var: "GITHUB_TOKEN" }) + } + ForgeType::Forgejo => { + env::var("FORGEJO_TOKEN") + .or_else(|_| env::var("GITEA_TOKEN")) + .map_err(|_| ForgeError::MissingToken { var: "FORGEJO_TOKEN" }) + } + } +} + +/// Create a forge instance for a given remote URL +pub fn create_forge(remote_url: &str) -> Result, ForgeError> { + let url = parse_git_url(remote_url); + let forge_type = detect_forge_type(remote_url); + let token = get_token(forge_type)?; + + match forge_type { + ForgeType::GitHub => Ok(Box::new(GitHubForge::new(token))), + ForgeType::Forgejo => Ok(Box::new(ForgejoForge::new(&url.host, token))), + } +} + +/// Forge configuration for caching +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForgeConfig { + #[serde(rename = "type")] + pub forge_type: ForgeType, + pub host: String, + pub owner: String, + pub repo: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detect_github() { + assert_eq!( + detect_forge_type("git@github.com:owner/repo.git"), + ForgeType::GitHub + ); + assert_eq!( + detect_forge_type("https://github.com/owner/repo.git"), + ForgeType::GitHub + ); + } + + #[test] + fn test_detect_codeberg() { + assert_eq!( + detect_forge_type("git@codeberg.org:owner/repo.git"), + ForgeType::Forgejo + ); + } + + #[test] + fn test_detect_gitea_in_host() { + assert_eq!( + detect_forge_type("git@gitea.example.com:owner/repo.git"), + ForgeType::Forgejo + ); + } +} diff --git a/crates/blue-core/src/lib.rs b/crates/blue-core/src/lib.rs index 7c59821..f7684d4 100644 --- a/crates/blue-core/src/lib.rs +++ b/crates/blue-core/src/lib.rs @@ -15,6 +15,7 @@ const _BLUE_SECRET_NAME: &str = "Sheepey"; // pronounced "Shee-paay" pub mod daemon; pub mod documents; +pub mod forge; pub mod indexer; pub mod llm; pub mod realm; @@ -25,6 +26,7 @@ pub mod voice; pub mod workflow; pub use documents::{Adr, Audit, AuditFinding, AuditSeverity, AuditType, Decision, Rfc, Spike, SpikeOutcome, Status, Task, update_markdown_status}; +pub use forge::{CreatePrOpts, Forge, ForgeConfig, ForgeError, ForgeType, ForgejoForge, GitHubForge, GitUrl, MergeStrategy, PrState, PullRequest, create_forge, detect_forge_type, get_token, parse_git_url}; pub use indexer::{Indexer, IndexerConfig, IndexerError, IndexResult, ParsedSymbol, is_indexable_file, should_skip_dir, DEFAULT_INDEX_MODEL, MAX_FILE_LINES}; pub use llm::{CompletionOptions, CompletionResult, LlmBackendChoice, LlmConfig, LlmError, LlmManager, LlmProvider, LlmProviderChoice, LocalLlmConfig, ApiLlmConfig, KeywordLlm, MockLlm, ProviderStatus}; pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo}; diff --git a/crates/blue-mcp/src/handlers/pr.rs b/crates/blue-mcp/src/handlers/pr.rs index c811cf5..3b7c718 100644 --- a/crates/blue-mcp/src/handlers/pr.rs +++ b/crates/blue-mcp/src/handlers/pr.rs @@ -1,6 +1,8 @@ //! Pull Request tool handlers //! //! Handles PR creation, verification, and merge with workflow enforcement. +//! Supports both GitHub and Forgejo/Gitea via the forge abstraction (RFC 0013). +//! //! Enforces: //! - Base branch must be `develop` (not `main`) //! - Test plan checkboxes must be verified before merge @@ -12,7 +14,7 @@ use std::process::Command; -use blue_core::ProjectState; +use blue_core::{CreatePrOpts, MergeStrategy, ProjectState, create_forge, detect_forge_type, parse_git_url}; use serde_json::{json, Value}; use crate::error::ServerError; @@ -34,7 +36,9 @@ pub enum TaskCategory { /// /// If `rfc` is provided (e.g., "0007-consistent-branch-naming"), the title /// will be formatted as "RFC 0007: Consistent Branch Naming" per RFC 0007. -pub fn handle_create(_state: &ProjectState, args: &Value) -> Result { +/// +/// Uses native REST API for the detected forge (GitHub or Forgejo/Gitea). +pub fn handle_create(state: &ProjectState, args: &Value) -> Result { let rfc = args.get("rfc").and_then(|v| v.as_str()); // If RFC is provided, format title as "RFC NNNN: Title Case Name" @@ -72,38 +76,89 @@ pub fn handle_create(_state: &ProjectState, args: &Value) -> Result url, + Err(e) => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Couldn't detect git remote", + &e + ) + })); + } + }; - if let Some(b) = body { - cmd_parts.push(format!("--body '{}'", b.replace('\'', "'\\''"))); + let git_url = parse_git_url(&remote_url); + let forge_type = detect_forge_type(&remote_url); + + // Get current branch for head + let head = match get_current_branch(&state.home.root) { + Ok(branch) => branch, + Err(e) => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Couldn't get current branch", + &e + ) + })); + } + }; + + // Create forge client and make PR + let forge = match create_forge(&remote_url) { + Ok(f) => f, + Err(e) => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Couldn't create forge client", + &format!("{}", e) + ) + })); + } + }; + + let opts = CreatePrOpts { + owner: git_url.owner.clone(), + repo: git_url.repo.clone(), + head, + base: base.to_string(), + title: title.clone(), + body: body.map(|s| s.to_string()), + draft, + }; + + match forge.create_pr(opts) { + Ok(pr) => { + Ok(json!({ + "status": "success", + "pr_url": pr.url, + "pr_number": pr.number, + "forge": forge_type.to_string(), + "base_branch": base, + "title": title, + "message": blue_core::voice::success( + &format!("Created PR #{}", pr.number), + Some(&pr.url) + ), + "next_steps": [ + "Run blue_pr_verify to check test plan items" + ] + })) + } + Err(e) => { + Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Failed to create PR", + &format!("{}", e) + ) + })) + } } - - if draft { - cmd_parts.push("--draft".to_string()); - } - - let create_command = cmd_parts.join(" "); - - Ok(json!({ - "status": "success", - "command": create_command, - "base_branch": base, - "title": title, - "next_steps": [ - format!("Run: {}", create_command), - "Add yourself as reviewer: gh pr edit --add-reviewer @me", - "Run blue_pr_verify to check test plan items" - ], - "message": blue_core::voice::success( - &format!("Ready to create PR targeting '{}'", base), - Some("Run the command to create the PR.") - ) - })) } /// Handle blue_pr_verify @@ -547,3 +602,52 @@ fn to_title_case(s: &str) -> String { .collect::>() .join(" ") } + +/// Get the remote URL from git config +/// +/// Tries 'origin' first, then falls back to any other remote. +fn get_remote_url(repo_path: &std::path::Path) -> Result { + let repo = git2::Repository::discover(repo_path) + .map_err(|e| format!("Not a git repository: {}", e))?; + + // Try origin first + if let Ok(remote) = repo.find_remote("origin") { + if let Some(url) = remote.url() { + return Ok(url.to_string()); + } + } + + // Try forgejo remote (common in Blue repos) + if let Ok(remote) = repo.find_remote("forgejo") { + if let Some(url) = remote.url() { + return Ok(url.to_string()); + } + } + + // Fall back to any remote + let remotes = repo.remotes() + .map_err(|e| format!("Couldn't list remotes: {}", e))?; + + for name in remotes.iter().flatten() { + if let Ok(remote) = repo.find_remote(name) { + if let Some(url) = remote.url() { + return Ok(url.to_string()); + } + } + } + + Err("No remotes configured".to_string()) +} + +/// Get the current branch name +fn get_current_branch(repo_path: &std::path::Path) -> Result { + let repo = git2::Repository::discover(repo_path) + .map_err(|e| format!("Not a git repository: {}", e))?; + + let head = repo.head() + .map_err(|e| format!("Couldn't get HEAD: {}", e))?; + + head.shorthand() + .map(|s| s.to_string()) + .ok_or_else(|| "HEAD is not a branch".to_string()) +}