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 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-24 20:07:44 -05:00
parent bfd2a01ede
commit ae98b6f230
6 changed files with 984 additions and 32 deletions

View file

@ -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<PullRequest, ForgeError> {
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<PullRequest, ForgeError> {
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<bool, ForgeError> {
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<String>,
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<String>,
}
#[derive(Debug, Deserialize)]
struct ForgejoPr {
number: u64,
title: String,
state: String,
merged: bool,
head: ForgejoBranch,
base: ForgejoBranch,
html_url: Option<String>,
}
#[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,
}
}
}

View file

@ -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);
}
}

View file

@ -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<PullRequest, ForgeError> {
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<PullRequest, ForgeError> {
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<bool, ForgeError> {
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<String>,
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<bool>,
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,
}
}
}

View file

@ -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<String>,
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<PullRequest, ForgeError>;
/// Get a pull request by number
fn get_pr(&self, owner: &str, repo: &str, number: u64) -> Result<PullRequest, ForgeError>;
/// 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<bool, ForgeError>;
/// 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<String, ForgeError> {
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<Box<dyn Forge>, 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
);
}
}

View file

@ -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};

View file

@ -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<Value, ServerError> {
///
/// Uses native REST API for the detected forge (GitHub or Forgejo/Gitea).
pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let rfc = args.get("rfc").and_then(|v| v.as_str());
// If RFC is provided, format title as "RFC NNNN: Title Case Name"
@ -72,39 +76,90 @@ pub fn handle_create(_state: &ProjectState, args: &Value) -> Result<Value, Serve
}));
}
// Build the gh command
let mut cmd_parts = vec![
"gh pr create".to_string(),
format!("--base {}", base),
format!("--title '{}'", title),
];
if let Some(b) = body {
cmd_parts.push(format!("--body '{}'", b.replace('\'', "'\\''")));
// Get remote URL and detect forge type
let remote_url = match get_remote_url(&state.home.root) {
Ok(url) => url,
Err(e) => {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
"Couldn't detect git remote",
&e
)
}));
}
};
if draft {
cmd_parts.push("--draft".to_string());
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
)
}));
}
};
let create_command = cmd_parts.join(" ");
// 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",
"command": create_command,
"pr_url": pr.url,
"pr_number": pr.number,
"forge": forge_type.to_string(),
"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.")
&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)
)
}))
}
}
}
/// Handle blue_pr_verify
pub fn handle_verify(_state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
@ -547,3 +602,52 @@ fn to_title_case(s: &str) -> String {
.collect::<Vec<_>>()
.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<String, String> {
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<String, String> {
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())
}