diff --git a/crates/blue-mcp/src/error.rs b/crates/blue-mcp/src/error.rs index d15c2b7..1276843 100644 --- a/crates/blue-mcp/src/error.rs +++ b/crates/blue-mcp/src/error.rs @@ -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, } } } diff --git a/crates/blue-mcp/src/handlers/mod.rs b/crates/blue-mcp/src/handlers/mod.rs index 0fe5b05..f793cbe 100644 --- a/crates/blue-mcp/src/handlers/mod.rs +++ b/crates/blue-mcp/src/handlers/mod.rs @@ -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; diff --git a/crates/blue-mcp/src/handlers/pr.rs b/crates/blue-mcp/src/handlers/pr.rs new file mode 100644 index 0000000..e240d41 --- /dev/null +++ b/crates/blue-mcp/src/handlers/pr.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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) -> Result { + 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) -> Result<(bool, Vec), 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 = 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 = 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 = 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 + ))), + } +} diff --git a/crates/blue-mcp/src/handlers/release.rs b/crates/blue-mcp/src/handlers/release.rs new file mode 100644 index 0000000..fce1513 --- /dev/null +++ b/crates/blue-mcp/src/handlers/release.rs @@ -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 { + 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 = 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::>(), + "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 = 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); + } +} diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index 22935c4..975495c 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -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) -> Result { + 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) -> Result { + 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) -> Result { + 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) -> Result { + 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) -> Result { + 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) -> Result { + 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 { diff --git a/docs/rfcs/0002-port-coherence-functionality.md b/docs/rfcs/0002-port-coherence-functionality.md index 20e1a4f..594bfbd 100644 --- a/docs/rfcs/0002-port-coherence-functionality.md +++ b/docs/rfcs/0002-port-coherence-functionality.md @@ -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