Merge branch 'git-forge-integration' into develop
This commit is contained in:
commit
04f34cc27c
6 changed files with 1176 additions and 72 deletions
212
crates/blue-core/src/forge/forgejo.rs
Normal file
212
crates/blue-core/src/forge/forgejo.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
205
crates/blue-core/src/forge/git_url.rs
Normal file
205
crates/blue-core/src/forge/git_url.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
crates/blue-core/src/forge/github.rs
Normal file
208
crates/blue-core/src/forge/github.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
300
crates/blue-core/src/forge/mod.rs
Normal file
300
crates/blue-core/src/forge/mod.rs
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blue config file structure
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct BlueConfig {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub forge: Option<ForgeConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BlueConfig {
|
||||||
|
/// Load config from .blue/config.yaml
|
||||||
|
pub fn load(blue_dir: &std::path::Path) -> Option<Self> {
|
||||||
|
let config_path = blue_dir.join("config.yaml");
|
||||||
|
if !config_path.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(&config_path).ok()?;
|
||||||
|
serde_yaml::from_str(&content).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save config to .blue/config.yaml
|
||||||
|
pub fn save(&self, blue_dir: &std::path::Path) -> Result<(), std::io::Error> {
|
||||||
|
let config_path = blue_dir.join("config.yaml");
|
||||||
|
let content = serde_yaml::to_string(self)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||||
|
std::fs::write(&config_path, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect forge type with caching support
|
||||||
|
///
|
||||||
|
/// If blue_dir is provided, will check for cached config first and save
|
||||||
|
/// detected type for future use.
|
||||||
|
pub fn detect_forge_type_cached(remote_url: &str, blue_dir: Option<&std::path::Path>) -> ForgeType {
|
||||||
|
let url = parse_git_url(remote_url);
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
if let Some(dir) = blue_dir {
|
||||||
|
if let Some(config) = BlueConfig::load(dir) {
|
||||||
|
if let Some(forge) = config.forge {
|
||||||
|
// Validate cached config matches current remote
|
||||||
|
if forge.host == url.host && forge.owner == url.owner && forge.repo == url.repo {
|
||||||
|
return forge.forge_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect and cache
|
||||||
|
let forge_type = detect_forge_type(remote_url);
|
||||||
|
|
||||||
|
// Save to cache if blue_dir provided
|
||||||
|
if let Some(dir) = blue_dir {
|
||||||
|
let forge_config = ForgeConfig {
|
||||||
|
forge_type,
|
||||||
|
host: url.host,
|
||||||
|
owner: url.owner,
|
||||||
|
repo: url.repo,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut config = BlueConfig::load(dir).unwrap_or_default();
|
||||||
|
config.forge = Some(forge_config);
|
||||||
|
let _ = config.save(dir); // Ignore errors - caching is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_type
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a forge instance with caching support
|
||||||
|
pub fn create_forge_cached(remote_url: &str, blue_dir: Option<&std::path::Path>) -> Result<Box<dyn Forge>, ForgeError> {
|
||||||
|
let url = parse_git_url(remote_url);
|
||||||
|
let forge_type = detect_forge_type_cached(remote_url, blue_dir);
|
||||||
|
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))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ const _BLUE_SECRET_NAME: &str = "Sheepey"; // pronounced "Shee-paay"
|
||||||
pub mod alignment;
|
pub mod alignment;
|
||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
pub mod documents;
|
pub mod documents;
|
||||||
|
pub mod forge;
|
||||||
pub mod indexer;
|
pub mod indexer;
|
||||||
pub mod llm;
|
pub mod llm;
|
||||||
pub mod realm;
|
pub mod realm;
|
||||||
|
|
@ -27,6 +28,7 @@ pub mod workflow;
|
||||||
|
|
||||||
pub use alignment::{AlignmentDialogue, AlignmentScore, DialogueStatus, Expert, ExpertResponse, ExpertTier, PanelTemplate, Perspective, PerspectiveStatus, Round, Tension, TensionStatus, build_expert_prompt, parse_expert_response};
|
pub use alignment::{AlignmentDialogue, AlignmentScore, DialogueStatus, Expert, ExpertResponse, ExpertTier, PanelTemplate, Perspective, PerspectiveStatus, Round, Tension, TensionStatus, build_expert_prompt, parse_expert_response};
|
||||||
pub use documents::{Adr, Audit, AuditFinding, AuditSeverity, AuditType, Decision, Rfc, Spike, SpikeOutcome, Status, Task, update_markdown_status};
|
pub use documents::{Adr, Audit, AuditFinding, AuditSeverity, AuditType, Decision, Rfc, Spike, SpikeOutcome, Status, Task, update_markdown_status};
|
||||||
|
pub use forge::{BlueConfig, CreatePrOpts, Forge, ForgeConfig, ForgeError, ForgeType, ForgejoForge, GitHubForge, GitUrl, MergeStrategy, PrState, PullRequest, create_forge, create_forge_cached, detect_forge_type, detect_forge_type_cached, 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 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 llm::{CompletionOptions, CompletionResult, LlmBackendChoice, LlmConfig, LlmError, LlmManager, LlmProvider, LlmProviderChoice, LocalLlmConfig, ApiLlmConfig, KeywordLlm, MockLlm, ProviderStatus};
|
||||||
pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo};
|
pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
//! Pull Request tool handlers
|
//! Pull Request tool handlers
|
||||||
//!
|
//!
|
||||||
//! Handles PR creation, verification, and merge with workflow enforcement.
|
//! Handles PR creation, verification, and merge with workflow enforcement.
|
||||||
|
//! Supports both GitHub and Forgejo/Gitea via the forge abstraction (RFC 0013).
|
||||||
|
//!
|
||||||
//! Enforces:
|
//! Enforces:
|
||||||
//! - Base branch must be `develop` (not `main`)
|
//! - Base branch must be `develop` (not `main`)
|
||||||
//! - Test plan checkboxes must be verified before merge
|
//! - Test plan checkboxes must be verified before merge
|
||||||
|
|
@ -12,7 +14,7 @@
|
||||||
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use blue_core::ProjectState;
|
use blue_core::{CreatePrOpts, MergeStrategy, ProjectState, create_forge_cached, detect_forge_type_cached, parse_git_url};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::error::ServerError;
|
use crate::error::ServerError;
|
||||||
|
|
@ -34,7 +36,9 @@ pub enum TaskCategory {
|
||||||
///
|
///
|
||||||
/// If `rfc` is provided (e.g., "0007-consistent-branch-naming"), the title
|
/// If `rfc` is provided (e.g., "0007-consistent-branch-naming"), the title
|
||||||
/// will be formatted as "RFC 0007: Consistent Branch Naming" per RFC 0007.
|
/// 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());
|
let rfc = args.get("rfc").and_then(|v| v.as_str());
|
||||||
|
|
||||||
// If RFC is provided, format title as "RFC NNNN: Title Case Name"
|
// If RFC is provided, format title as "RFC NNNN: Title Case Name"
|
||||||
|
|
@ -72,38 +76,90 @@ pub fn handle_create(_state: &ProjectState, args: &Value) -> Result<Value, Serve
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the gh command
|
// Get remote URL and detect forge type
|
||||||
let mut cmd_parts = vec![
|
let remote_url = match get_remote_url(&state.home.root) {
|
||||||
"gh pr create".to_string(),
|
Ok(url) => url,
|
||||||
format!("--base {}", base),
|
Err(e) => {
|
||||||
format!("--title '{}'", title),
|
return Ok(json!({
|
||||||
];
|
"status": "error",
|
||||||
|
"message": blue_core::voice::error(
|
||||||
|
"Couldn't detect git remote",
|
||||||
|
&e
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(b) = body {
|
let git_url = parse_git_url(&remote_url);
|
||||||
cmd_parts.push(format!("--body '{}'", b.replace('\'', "'\\''")));
|
let blue_dir = Some(state.home.blue_dir.as_path());
|
||||||
|
let forge_type = detect_forge_type_cached(&remote_url, blue_dir);
|
||||||
|
|
||||||
|
// 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 (with caching)
|
||||||
|
let forge = match create_forge_cached(&remote_url, blue_dir) {
|
||||||
|
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
|
/// Handle blue_pr_verify
|
||||||
|
|
@ -253,58 +309,130 @@ pub fn handle_check_approvals(_state: &ProjectState, args: &Value) -> Result<Val
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle blue_pr_merge
|
/// Handle blue_pr_merge
|
||||||
pub fn handle_merge(_state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
///
|
||||||
let pr_number = args.get("pr_number").and_then(|v| v.as_u64()).map(|n| n as u32);
|
/// Merges a PR using the detected forge's native API.
|
||||||
|
pub fn handle_merge(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
|
||||||
|
let pr_number = args.get("pr_number").and_then(|v| v.as_u64());
|
||||||
let squash = args.get("squash").and_then(|v| v.as_bool()).unwrap_or(true);
|
let squash = args.get("squash").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||||
|
|
||||||
// Fetch PR and check preconditions
|
// Get remote URL and create forge client
|
||||||
let pr_data = fetch_pr_data(pr_number)?;
|
let remote_url = match get_remote_url(&state.home.root) {
|
||||||
let (approved, _) = fetch_pr_approvals(pr_number)?;
|
Ok(url) => url,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": blue_core::voice::error(
|
||||||
|
"Couldn't detect git remote",
|
||||||
|
&e
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let git_url = parse_git_url(&remote_url);
|
||||||
|
let blue_dir = Some(state.home.blue_dir.as_path());
|
||||||
|
let forge_type = detect_forge_type_cached(&remote_url, blue_dir);
|
||||||
|
|
||||||
|
let forge = match create_forge_cached(&remote_url, blue_dir) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
return Ok(json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": blue_core::voice::error(
|
||||||
|
"Couldn't create forge client",
|
||||||
|
&format!("{}", e)
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get PR number - either from args or try to detect from current branch
|
||||||
|
let number = match pr_number {
|
||||||
|
Some(n) => n,
|
||||||
|
None => {
|
||||||
|
// Try to get PR for current branch via gh CLI as fallback
|
||||||
|
let pr_data = fetch_pr_data(None)?;
|
||||||
|
pr_data.number as u64
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check preconditions via gh CLI (works for GitHub, may not work for Forgejo)
|
||||||
|
// TODO: Add review fetching to Forge trait for full cross-forge support
|
||||||
|
let preconditions_result = check_merge_preconditions(pr_number.map(|n| n as u32));
|
||||||
|
|
||||||
|
if let Err(precondition_error) = preconditions_result {
|
||||||
|
// If we can't check preconditions (e.g., gh not configured), warn but allow
|
||||||
|
// the user to proceed - the forge will reject if not allowed
|
||||||
|
if !args.get("force").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||||
|
return Ok(json!({
|
||||||
|
"status": "warning",
|
||||||
|
"message": blue_core::voice::error(
|
||||||
|
"Couldn't verify preconditions",
|
||||||
|
&format!("{}. Use force=true to merge anyway.", precondition_error)
|
||||||
|
),
|
||||||
|
"hint": "Precondition checks require gh CLI. The forge may still reject the merge."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the merge
|
||||||
|
let strategy = if squash {
|
||||||
|
MergeStrategy::Squash
|
||||||
|
} else {
|
||||||
|
MergeStrategy::Merge
|
||||||
|
};
|
||||||
|
|
||||||
|
match forge.merge_pr(&git_url.owner, &git_url.repo, number, strategy) {
|
||||||
|
Ok(()) => {
|
||||||
|
Ok(json!({
|
||||||
|
"status": "success",
|
||||||
|
"pr_number": number,
|
||||||
|
"forge": forge_type.to_string(),
|
||||||
|
"strategy": if squash { "squash" } else { "merge" },
|
||||||
|
"message": blue_core::voice::success(
|
||||||
|
&format!("Merged PR #{}", number),
|
||||||
|
Some("Run blue_worktree_cleanup to clean up local worktree.")
|
||||||
|
),
|
||||||
|
"next_steps": [
|
||||||
|
"Run blue_worktree_cleanup to remove worktree and local branch"
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
Ok(json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": blue_core::voice::error(
|
||||||
|
"Merge failed",
|
||||||
|
&format!("{}", e)
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check merge preconditions (approval, test plan)
|
||||||
|
/// Returns Ok(()) if ready to merge, Err with reason otherwise
|
||||||
|
fn check_merge_preconditions(pr_number: Option<u32>) -> Result<(), String> {
|
||||||
|
// Try to fetch PR data via gh CLI
|
||||||
|
let pr_data = fetch_pr_data(pr_number)
|
||||||
|
.map_err(|e| format!("Couldn't fetch PR data: {:?}", e))?;
|
||||||
|
|
||||||
|
let (approved, _) = fetch_pr_approvals(pr_number)
|
||||||
|
.map_err(|e| format!("Couldn't fetch approvals: {:?}", e))?;
|
||||||
|
|
||||||
let items = parse_test_plan(&pr_data.body);
|
let items = parse_test_plan(&pr_data.body);
|
||||||
let all_items_checked = items.iter().all(|(_, checked, _)| *checked);
|
let all_items_checked = items.iter().all(|(_, checked, _)| *checked);
|
||||||
|
|
||||||
// Enforce preconditions
|
|
||||||
if !approved {
|
if !approved {
|
||||||
return Ok(json!({
|
return Err("PR not approved. Get reviewer approval first.".to_string());
|
||||||
"status": "error",
|
|
||||||
"message": blue_core::voice::error(
|
|
||||||
"Can't merge without approval",
|
|
||||||
"Get user approval on GitHub first"
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !all_items_checked {
|
if !all_items_checked {
|
||||||
let unchecked = items.iter().filter(|(_, checked, _)| !*checked).count();
|
let unchecked = items.iter().filter(|(_, checked, _)| !*checked).count();
|
||||||
return Ok(json!({
|
return Err(format!("{} test plan items still unchecked", unchecked));
|
||||||
"status": "error",
|
|
||||||
"message": blue_core::voice::error(
|
|
||||||
&format!("{} test plan items still unchecked", unchecked),
|
|
||||||
"Run blue_pr_verify to complete verification"
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let merge_cmd = format!(
|
Ok(())
|
||||||
"gh pr merge {} {}--delete-branch",
|
|
||||||
pr_data.number,
|
|
||||||
if squash { "--squash " } else { "" }
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(json!({
|
|
||||||
"status": "success",
|
|
||||||
"command": merge_cmd,
|
|
||||||
"pr_number": pr_data.number,
|
|
||||||
"squash": squash,
|
|
||||||
"next_steps": [
|
|
||||||
format!("Run: {}", merge_cmd),
|
|
||||||
"Run blue_worktree_remove to clean up"
|
|
||||||
],
|
|
||||||
"message": blue_core::voice::success(
|
|
||||||
&format!("PR #{} ready to merge", pr_data.number),
|
|
||||||
Some("Run the command to merge.")
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -547,3 +675,52 @@ fn to_title_case(s: &str) -> String {
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ")
|
.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())
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue