diff --git a/Cargo.lock b/Cargo.lock index d7c54527..9e77e99d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1769,21 +1769,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3427,23 +3412,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3630,50 +3598,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.113" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "os_str_bytes" version = "6.6.1" @@ -6092,16 +6022,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6144,9 +6064,11 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", - "native-tls", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", - "tokio-native-tls", + "tokio-rustls", "tungstenite 0.29.0", ] @@ -6396,8 +6318,9 @@ dependencies = [ "http", "httparse", "log", - "native-tls", "rand 0.9.3", + "rustls", + "rustls-pki-types", "sha1", "thiserror 2.0.18", ] diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index 5600d951..a954f2b6 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -7,7 +7,7 @@ use crate::slog; type ContentTransform = Option String>>; -pub(super) fn item_type_from_id(item_id: &str) -> &'static str { +pub(crate) fn item_type_from_id(item_id: &str) -> &'static str { // New format: {digits}_{type}_{slug} let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit()); if after_num.starts_with("_bug_") { @@ -155,14 +155,15 @@ pub fn feature_branch_has_unmerged_changes(project_root: &Path, story_id: &str) } } -/// Move a story from `work/2_current/` or `work/4_merge/` to `work/5_done/`. +/// Move a story from `work/2_current/`, `work/3_qa/`, or `work/4_merge/` to `work/5_done/`. /// -/// Idempotent if already in `5_done/` or `6_archived/`. Errors if not found in `2_current/` or `4_merge/`. +/// Idempotent if already in `5_done/` or `6_archived/`. Errors if not found in any earlier stage. +/// Spikes may transition directly from `3_qa/` to `5_done/`, skipping the merge stage. pub fn move_story_to_done(project_root: &Path, story_id: &str) -> Result<(), String> { move_item( project_root, story_id, - &["2_current", "4_merge"], + &["2_current", "3_qa", "4_merge"], "5_done", &["6_archived"], false, diff --git a/server/src/http/mcp/qa_tools.rs b/server/src/http/mcp/qa_tools.rs index d6d782f5..40aab25f 100644 --- a/server/src/http/mcp/qa_tools.rs +++ b/server/src/http/mcp/qa_tools.rs @@ -1,5 +1,5 @@ //! MCP QA tools — request, approve, and reject QA reviews for stories. -use crate::agents::{move_story_to_merge, move_story_to_qa, reject_story_from_qa}; +use crate::agents::{move_story_to_done, move_story_to_merge, move_story_to_qa, reject_story_from_qa}; use crate::http::context::AppContext; use crate::slog; use crate::slog_warn; @@ -55,25 +55,134 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result Result { + use std::process::Command; + + // Check the branch exists and has unmerged changes. + if !crate::agents::lifecycle::feature_branch_has_unmerged_changes(project_root, story_id) { + slog!("[qa] Spike '{story_id}': feature branch has no unmerged changes, skipping merge."); + return Ok(false); + } + + // Ensure we are on master. + let checkout = Command::new("git") + .args(["checkout", "master"]) + .current_dir(project_root) + .output() + .map_err(|e| format!("git checkout master failed: {e}"))?; + if !checkout.status.success() { + return Err(format!( + "Failed to checkout master: {}", + String::from_utf8_lossy(&checkout.stderr) + )); + } + + // Try fast-forward first, then fall back to a regular merge. + let ff = Command::new("git") + .args(["merge", "--ff-only", branch]) + .current_dir(project_root) + .output() + .map_err(|e| format!("git merge --ff-only failed: {e}"))?; + + if ff.status.success() { + slog!("[qa] Spike '{story_id}': fast-forward merged '{branch}' into master."); + return Ok(true); + } + + // Fast-forward failed (diverged history) — fall back to a regular merge. + let merge = Command::new("git") + .args([ + "merge", + "--no-ff", + branch, + "-m", + &format!("Merge spike branch '{branch}' into master"), + ]) + .current_dir(project_root) + .output() + .map_err(|e| format!("git merge failed: {e}"))?; + + if merge.status.success() { + slog!("[qa] Spike '{story_id}': merged '{branch}' into master (no-ff)."); + Ok(true) + } else { + Err(format!( + "Failed to merge spike branch '{branch}' into master: {}", + String::from_utf8_lossy(&merge.stderr) + )) + } } pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result {