feat(mcp): Complete Phase 3 - PR and Release handlers

Add PR workflow and release management handlers. Total tools: 22.

New tools:
- blue_pr_create: Create PR with enforced develop base branch
- blue_pr_verify: Verify test plan checkboxes (CLI/browser/manual)
- blue_pr_check_item: Mark test plan item as verified
- blue_pr_check_approvals: Check for user approval
- blue_pr_merge: Squash-merge with precondition enforcement
- blue_release_create: Semantic versioning with RFC analysis

All handlers use gh CLI for GitHub operations.
Blue's voice in all error messages.
16 tests passing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-24 03:21:36 -05:00
parent eeb7d0c14f
commit 09e7c89c1b
6 changed files with 868 additions and 4 deletions

View file

@ -21,6 +21,12 @@ pub enum ServerError {
#[error("State load failed: {0}")]
StateLoadFailed(String),
#[error("Command failed: {0}")]
CommandFailed(String),
#[error("Not found: {0}")]
NotFound(String),
}
impl ServerError {
@ -33,6 +39,8 @@ impl ServerError {
ServerError::ToolNotFound(_) => -32601,
ServerError::BlueNotDetected => -32000,
ServerError::StateLoadFailed(_) => -32001,
ServerError::CommandFailed(_) => -32002,
ServerError::NotFound(_) => -32003,
}
}
}

View file

@ -2,7 +2,9 @@
//!
//! Each module handles a specific document type or workflow.
pub mod spike;
pub mod adr;
pub mod decision;
pub mod pr;
pub mod release;
pub mod spike;
pub mod worktree;

View file

@ -0,0 +1,512 @@
//! Pull Request tool handlers
//!
//! Handles PR creation, verification, and merge with workflow enforcement.
//! Enforces:
//! - Base branch must be `develop` (not `main`)
//! - Test plan checkboxes must be verified before merge
//! - User must approve PR before merge
use std::process::Command;
use blue_core::ProjectState;
use serde_json::{json, Value};
use crate::error::ServerError;
/// Task category for test plan items
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskCategory {
/// Can be automated via CLI (run tests, build, lint)
CliAutomatable,
/// Can be automated via browser (visual verification)
BrowserAutomatable,
/// Requires human verification
TrulyManual,
}
/// Handle blue_pr_create
pub fn handle_create(_state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let base = args
.get("base")
.and_then(|v| v.as_str())
.unwrap_or("develop");
let body = args.get("body").and_then(|v| v.as_str());
let draft = args.get("draft").and_then(|v| v.as_bool()).unwrap_or(false);
// Enforce base branch
if base == "main" || base == "master" {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
"Can't target main directly",
"Use 'develop' as base branch, then release to main"
)
}));
}
// 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('\'', "'\\''")));
}
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
pub fn handle_verify(_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);
// Fetch PR via gh CLI
let pr_data = fetch_pr_data(pr_number)?;
// Parse test plan from PR body
let items = parse_test_plan(&pr_data.body);
let checked_count = items.iter().filter(|(_, checked, _)| *checked).count();
let unchecked: Vec<_> = items
.iter()
.filter(|(_, checked, _)| !*checked)
.collect();
let cli_tasks: Vec<_> = unchecked
.iter()
.filter(|(_, _, cat)| matches!(cat, TaskCategory::CliAutomatable))
.map(|(desc, _, _)| desc.clone())
.collect();
let browser_tasks: Vec<_> = unchecked
.iter()
.filter(|(_, _, cat)| matches!(cat, TaskCategory::BrowserAutomatable))
.map(|(desc, _, _)| desc.clone())
.collect();
let manual_tasks: Vec<_> = unchecked
.iter()
.filter(|(_, _, cat)| matches!(cat, TaskCategory::TrulyManual))
.map(|(desc, _, _)| desc.clone())
.collect();
let all_verified = unchecked.is_empty();
Ok(json!({
"status": "success",
"pr_number": pr_data.number,
"pr_state": pr_data.state,
"test_plan": {
"total": items.len(),
"checked": checked_count,
"unchecked": unchecked.len(),
"all_verified": all_verified
},
"unchecked_by_category": {
"cli_automatable": cli_tasks,
"browser_automatable": browser_tasks,
"truly_manual": manual_tasks
},
"message": if all_verified {
blue_core::voice::success(
&format!("PR #{}: All {} items verified", pr_data.number, items.len()),
Some("Ready to check approvals with blue_pr_check_approvals.")
)
} else {
format!(
"PR #{}: {}/{} verified. CLI: {}, Browser: {}, Manual: {}",
pr_data.number, checked_count, items.len(),
cli_tasks.len(), browser_tasks.len(), manual_tasks.len()
)
}
}))
}
/// Handle blue_pr_check_item
pub fn handle_check_item(_state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let item = args
.get("item")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let pr_number = args.get("pr_number").and_then(|v| v.as_u64()).map(|n| n as u32);
let verified_by = args.get("verified_by").and_then(|v| v.as_str());
// Fetch current PR body
let pr_data = fetch_pr_data(pr_number)?;
// Find and update the item
let (updated_body, matched_item) = update_checkbox_in_body(&pr_data.body, item)?;
// Update PR via gh CLI
update_pr_body(pr_data.number, &updated_body)?;
// Re-parse to get updated status
let items = parse_test_plan(&updated_body);
let unchecked_count = items.iter().filter(|(_, checked, _)| !*checked).count();
Ok(json!({
"status": "success",
"item_checked": matched_item,
"verified_by": verified_by,
"remaining_unchecked": unchecked_count,
"all_verified": unchecked_count == 0,
"message": blue_core::voice::success(
&format!("Checked: '{}'", matched_item),
Some(&format!("{} items remaining.", unchecked_count))
)
}))
}
/// Handle blue_pr_check_approvals
pub fn handle_check_approvals(_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);
let (approved, approved_by) = fetch_pr_approvals(pr_number)?;
let pr_data = fetch_pr_data(pr_number)?;
// Check test plan completion
let items = parse_test_plan(&pr_data.body);
let all_items_checked = items.iter().all(|(_, checked, _)| *checked);
let ready_to_merge = approved && all_items_checked;
let mut blocking_reasons = Vec::new();
if !approved {
blocking_reasons.push("Waiting for user approval on GitHub".to_string());
}
if !all_items_checked {
let unchecked = items.iter().filter(|(_, checked, _)| !*checked).count();
blocking_reasons.push(format!("{} test plan items unchecked", unchecked));
}
Ok(json!({
"status": "success",
"pr_number": pr_data.number,
"approved": approved,
"approved_by": approved_by,
"test_plan_complete": all_items_checked,
"ready_to_merge": ready_to_merge,
"blocking_reasons": blocking_reasons,
"message": if ready_to_merge {
blue_core::voice::success(
"PR approved and verified",
Some("Ready to merge with blue_pr_merge.")
)
} else {
blue_core::voice::error(
"Not ready to merge",
&blocking_reasons.join(". ")
)
}
}))
}
/// 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);
let squash = args.get("squash").and_then(|v| v.as_bool()).unwrap_or(true);
// Fetch PR and check preconditions
let pr_data = fetch_pr_data(pr_number)?;
let (approved, _) = fetch_pr_approvals(pr_number)?;
let items = parse_test_plan(&pr_data.body);
let all_items_checked = items.iter().all(|(_, checked, _)| *checked);
// Enforce preconditions
if !approved {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
"Can't merge without approval",
"Get user approval on GitHub first"
)
}));
}
if !all_items_checked {
let unchecked = items.iter().filter(|(_, checked, _)| !*checked).count();
return Ok(json!({
"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!(
"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.")
)
}))
}
// =============================================================================
// Helper functions
// =============================================================================
struct PrData {
number: u32,
body: String,
state: String,
}
fn fetch_pr_data(pr_number: Option<u32>) -> Result<PrData, ServerError> {
let mut args = vec!["pr", "view", "--json", "number,body,state"];
let pr_num_str;
if let Some(n) = pr_number {
pr_num_str = n.to_string();
args.insert(2, &pr_num_str);
}
let output = Command::new("gh")
.args(&args)
.output()
.map_err(|e| ServerError::CommandFailed(format!("Failed to run gh: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ServerError::CommandFailed(format!(
"gh pr view failed: {}",
stderr
)));
}
let data: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| ServerError::CommandFailed(format!("Failed to parse PR data: {}", e)))?;
Ok(PrData {
number: data["number"].as_u64().unwrap_or(0) as u32,
body: data["body"].as_str().unwrap_or("").to_string(),
state: data["state"].as_str().unwrap_or("").to_string(),
})
}
fn fetch_pr_approvals(pr_number: Option<u32>) -> Result<(bool, Vec<String>), ServerError> {
let mut args = vec!["pr", "view", "--json", "reviews"];
let pr_num_str;
if let Some(n) = pr_number {
pr_num_str = n.to_string();
args.insert(2, &pr_num_str);
}
let output = Command::new("gh")
.args(&args)
.output()
.map_err(|e| ServerError::CommandFailed(format!("Failed to run gh: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ServerError::CommandFailed(format!(
"gh pr view failed: {}",
stderr
)));
}
let data: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| ServerError::CommandFailed(format!("Failed to parse reviews: {}", e)))?;
let reviews = data["reviews"].as_array();
let approved_by: Vec<String> = reviews
.map(|arr| {
arr.iter()
.filter(|r| r["state"].as_str() == Some("APPROVED"))
.filter_map(|r| r["author"]["login"].as_str().map(String::from))
.collect()
})
.unwrap_or_default();
Ok((!approved_by.is_empty(), approved_by))
}
fn update_pr_body(pr_number: u32, new_body: &str) -> Result<(), ServerError> {
let output = Command::new("gh")
.args(["pr", "edit", &pr_number.to_string(), "--body", new_body])
.output()
.map_err(|e| ServerError::CommandFailed(format!("Failed to run gh: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(ServerError::CommandFailed(format!(
"gh pr edit failed: {}",
stderr
)));
}
Ok(())
}
/// Parse test plan checkboxes from PR body
fn parse_test_plan(body: &str) -> Vec<(String, bool, TaskCategory)> {
let mut items = Vec::new();
let mut in_test_plan = false;
for line in body.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## Test") || trimmed.starts_with("## test") {
in_test_plan = true;
continue;
}
if in_test_plan && trimmed.starts_with("## ") {
break;
}
if in_test_plan {
if let Some((desc, checked)) = parse_checkbox_line(trimmed) {
let category = categorize_task(&desc);
items.push((desc, checked, category));
}
}
}
items
}
fn parse_checkbox_line(line: &str) -> Option<(String, bool)> {
if line.starts_with("- [x]") || line.starts_with("- [X]") {
Some((line[5..].trim().to_string(), true))
} else if line.starts_with("- [ ]") {
Some((line[5..].trim().to_string(), false))
} else {
None
}
}
fn categorize_task(description: &str) -> TaskCategory {
let lower = description.to_lowercase();
// CLI-automatable patterns
let cli_patterns = [
"run tests", "run test", "unit test", "npm test", "cargo test",
"build", "compile", "lint", "format", "install", "type check",
"pytest", "make",
];
if cli_patterns.iter().any(|p| lower.contains(p)) {
return TaskCategory::CliAutomatable;
}
// Truly manual patterns (check before browser patterns)
let manual_patterns = [
"physical device", "screen reader", "voiceover", "nvda",
"subjective", "intuitive", "usability", "production",
"accessibility audit", "manual",
];
if manual_patterns.iter().any(|p| lower.contains(p)) {
return TaskCategory::TrulyManual;
}
// Browser-automatable patterns
let browser_patterns = [
"verify", "check", "confirm", "displays", "shows", "click",
"navigate", "form", "modal", "dropdown", "responsive", "login",
"error message", "validation", "visual",
];
if browser_patterns.iter().any(|p| lower.contains(p)) {
return TaskCategory::BrowserAutomatable;
}
// Default to manual for unknown
TaskCategory::TrulyManual
}
fn update_checkbox_in_body(body: &str, item_selector: &str) -> Result<(String, String), ServerError> {
let mut lines: Vec<String> = body.lines().map(|s| s.to_string()).collect();
let mut matched_item = None;
let mut matched_line_idx = None;
let mut in_test_plan = false;
let mut item_index = 0usize;
// Try to parse as index first
let target_index: Option<usize> = item_selector.parse().ok();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("## Test") || trimmed.starts_with("## test") {
in_test_plan = true;
continue;
}
if in_test_plan && trimmed.starts_with("## ") {
break;
}
if in_test_plan && trimmed.starts_with("- [ ]") {
item_index += 1;
let description = trimmed[5..].trim();
let matches = target_index.map(|idx| idx == item_index).unwrap_or(false)
|| description.to_lowercase().contains(&item_selector.to_lowercase());
if matches {
matched_item = Some(description.to_string());
matched_line_idx = Some(i);
break;
}
}
}
if let Some(idx) = matched_line_idx {
lines[idx] = lines[idx].replace("- [ ]", "- [x]");
}
match matched_item {
Some(item) => Ok((lines.join("\n"), item)),
None => Err(ServerError::NotFound(format!(
"No matching unchecked item for: {}",
item_selector
))),
}
}

View file

@ -0,0 +1,160 @@
//! Release tool handlers
//!
//! Handles release creation with semantic versioning analysis.
use blue_core::{DocType, ProjectState};
use serde_json::{json, Value};
use crate::error::ServerError;
/// Semantic version bump type
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum VersionBump {
Patch,
Minor,
Major,
}
impl VersionBump {
pub fn as_str(&self) -> &'static str {
match self {
Self::Patch => "patch",
Self::Minor => "minor",
Self::Major => "major",
}
}
}
/// Handle blue_release_create
pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let version_override = args.get("version").and_then(|v| v.as_str());
// Check for in-progress work
let in_progress: Vec<_> = state
.store
.list_documents(DocType::Rfc)
.unwrap_or_default()
.into_iter()
.filter(|d| d.status == "in-progress")
.collect();
if !in_progress.is_empty() {
let titles: Vec<_> = in_progress.iter().map(|d| d.title.as_str()).collect();
return Ok(json!({
"status": "blocked",
"message": blue_core::voice::error(
&format!("Can't release with in-progress work: {}", titles.join(", ")),
"Complete or defer these RFCs first"
),
"blocking_rfcs": titles
}));
}
// Get implemented RFCs
let implemented: Vec<_> = state
.store
.list_documents(DocType::Rfc)
.unwrap_or_default()
.into_iter()
.filter(|d| d.status == "implemented")
.collect();
// Analyze version bump
let suggested_bump = analyze_version_bump(&implemented);
// Get current version (would read from Cargo.toml in real impl)
let current_version = "0.1.0";
// Calculate next version
let suggested_version = next_version(current_version, suggested_bump);
let version = version_override
.map(|s| s.to_string())
.unwrap_or_else(|| suggested_version.clone());
// Generate changelog entries
let changelog_entries: Vec<String> = implemented
.iter()
.map(|rfc| format!("- {} (RFC {:04})", rfc.title, rfc.number.unwrap_or(0)))
.collect();
Ok(json!({
"status": "success",
"current_version": current_version,
"suggested_bump": suggested_bump.as_str(),
"suggested_version": suggested_version,
"version": version,
"rfcs_included": implemented.iter().map(|r| &r.title).collect::<Vec<_>>(),
"changelog_entries": changelog_entries,
"commands": {
"create_pr": format!("gh pr create --base main --head develop --title 'Release {}'", version),
"tag": format!("git tag v{}", version),
"push_tag": format!("git push origin v{}", version)
},
"message": blue_core::voice::success(
&format!("Ready to release {} ({} bump)", version, suggested_bump.as_str()),
Some(&format!("{} RFCs included. Follow the commands to complete.", implemented.len()))
)
}))
}
/// Analyze implemented RFCs to determine version bump
fn analyze_version_bump(rfcs: &[blue_core::Document]) -> VersionBump {
let mut max_bump = VersionBump::Patch;
for rfc in rfcs {
let title_lower = rfc.title.to_lowercase();
// Major version indicators
if title_lower.contains("breaking")
|| title_lower.contains("remove")
|| title_lower.contains("deprecate")
{
return VersionBump::Major;
}
// Minor version indicators
if title_lower.starts_with("add-")
|| title_lower.starts_with("implement-")
|| title_lower.contains("feature")
{
max_bump = max_bump.max(VersionBump::Minor);
}
}
max_bump
}
/// Calculate next version based on current version and bump type
fn next_version(current: &str, bump: VersionBump) -> String {
let parts: Vec<u32> = current.split('.').filter_map(|s| s.parse().ok()).collect();
if parts.len() < 3 {
return "0.1.0".to_string();
}
let (major, minor, patch) = (parts[0], parts[1], parts[2]);
match bump {
VersionBump::Major => format!("{}.0.0", major + 1),
VersionBump::Minor => format!("{}.{}.0", major, minor + 1),
VersionBump::Patch => format!("{}.{}.{}", major, minor, patch + 1),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_next_version() {
assert_eq!(next_version("1.2.3", VersionBump::Patch), "1.2.4");
assert_eq!(next_version("1.2.3", VersionBump::Minor), "1.3.0");
assert_eq!(next_version("1.2.3", VersionBump::Major), "2.0.0");
}
#[test]
fn test_version_bump_comparison() {
assert!(VersionBump::Major > VersionBump::Minor);
assert!(VersionBump::Minor > VersionBump::Patch);
}
}

View file

@ -469,6 +469,134 @@ impl BlueServer {
},
"required": ["title"]
}
},
{
"name": "blue_pr_create",
"description": "Create a PR with enforced base branch (develop, not main).",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
},
"title": {
"type": "string",
"description": "PR title"
},
"base": {
"type": "string",
"description": "Base branch (default: develop)"
},
"body": {
"type": "string",
"description": "PR body (markdown)"
},
"draft": {
"type": "boolean",
"description": "Create as draft PR"
}
},
"required": ["title"]
}
},
{
"name": "blue_pr_verify",
"description": "Verify test plan checkboxes in a PR.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
},
"pr_number": {
"type": "number",
"description": "PR number (auto-detect from branch if not provided)"
}
}
}
},
{
"name": "blue_pr_check_item",
"description": "Mark a test plan item as verified in the PR.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
},
"pr_number": {
"type": "number",
"description": "PR number"
},
"item": {
"type": "string",
"description": "Item index (1-based) or substring to match"
},
"verified_by": {
"type": "string",
"description": "How the item was verified"
}
},
"required": ["item"]
}
},
{
"name": "blue_pr_check_approvals",
"description": "Check if PR has been approved by reviewers.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
},
"pr_number": {
"type": "number",
"description": "PR number"
}
}
}
},
{
"name": "blue_pr_merge",
"description": "Squash-merge a PR after verification and approval.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
},
"pr_number": {
"type": "number",
"description": "PR number"
},
"squash": {
"type": "boolean",
"description": "Use squash merge (default: true)"
}
}
}
},
{
"name": "blue_release_create",
"description": "Create a release with semantic versioning.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
},
"version": {
"type": "string",
"description": "Override suggested version (e.g., '2.1.0')"
}
}
}
}
]
}))
@ -506,6 +634,13 @@ impl BlueServer {
"blue_worktree_create" => self.handle_worktree_create(&call.arguments),
"blue_worktree_list" => self.handle_worktree_list(&call.arguments),
"blue_worktree_remove" => self.handle_worktree_remove(&call.arguments),
// Phase 3: PR and Release handlers
"blue_pr_create" => self.handle_pr_create(&call.arguments),
"blue_pr_verify" => self.handle_pr_verify(&call.arguments),
"blue_pr_check_item" => self.handle_pr_check_item(&call.arguments),
"blue_pr_check_approvals" => self.handle_pr_check_approvals(&call.arguments),
"blue_pr_merge" => self.handle_pr_merge(&call.arguments),
"blue_release_create" => self.handle_release_create(&call.arguments),
_ => Err(ServerError::ToolNotFound(call.name)),
}?;
@ -935,6 +1070,44 @@ impl BlueServer {
let state = self.ensure_state()?;
crate::handlers::worktree::handle_remove(state, args)
}
// Phase 3: PR and Release handlers
fn handle_pr_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::pr::handle_create(state, args)
}
fn handle_pr_verify(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::pr::handle_verify(state, args)
}
fn handle_pr_check_item(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::pr::handle_check_item(state, args)
}
fn handle_pr_check_approvals(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::pr::handle_check_approvals(state, args)
}
fn handle_pr_merge(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::pr::handle_merge(state, args)
}
fn handle_release_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::release::handle_create(state, args)
}
}
impl Default for BlueServer {

View file

@ -208,14 +208,23 @@ blue/
- [x] Total: 16 MCP tools, 842 new lines of code
- [x] Blue's voice in all error messages
### Phase 3-4: Pending
### Phase 3: PR and Release - COMPLETE
- [x] handlers/pr.rs - PR create, verify, check_item, check_approvals, merge
- [x] handlers/release.rs - Semantic versioning release creation
- [x] 6 new MCP tools: blue_pr_create, blue_pr_verify, blue_pr_check_item,
blue_pr_check_approvals, blue_pr_merge, blue_release_create
- [x] Total: 22 MCP tools
- [x] Blue's voice in all error messages
- [x] 16 tests passing
### Phase 4: Pending
Remaining tools to port:
- PR workflow (blue_pr_create, blue_pr_verify, blue_pr_merge)
- Release management (blue_release_create)
- Staging environment tools
- Session management
- Code search/indexing
- Reminders
## Test Plan