huskies: merge 553_story_accept_spike_state_machine_transition_skips_merge_and_goes_directly_to_done
This commit is contained in:
Generated
+6
-83
@@ -1769,21 +1769,6 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
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]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -3427,23 +3412,6 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "new_debug_unreachable"
|
name = "new_debug_unreachable"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -3630,50 +3598,12 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
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]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
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]]
|
[[package]]
|
||||||
name = "os_str_bytes"
|
name = "os_str_bytes"
|
||||||
version = "6.6.1"
|
version = "6.6.1"
|
||||||
@@ -6092,16 +6022,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -6144,9 +6064,11 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"native-tls",
|
"rustls",
|
||||||
|
"rustls-native-certs",
|
||||||
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-native-tls",
|
"tokio-rustls",
|
||||||
"tungstenite 0.29.0",
|
"tungstenite 0.29.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6396,8 +6318,9 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"native-tls",
|
|
||||||
"rand 0.9.3",
|
"rand 0.9.3",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::slog;
|
|||||||
|
|
||||||
type ContentTransform = Option<Box<dyn Fn(&str) -> String>>;
|
type ContentTransform = Option<Box<dyn Fn(&str) -> 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}
|
// New format: {digits}_{type}_{slug}
|
||||||
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
|
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||||
if after_num.starts_with("_bug_") {
|
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> {
|
pub fn move_story_to_done(project_root: &Path, story_id: &str) -> Result<(), String> {
|
||||||
move_item(
|
move_item(
|
||||||
project_root,
|
project_root,
|
||||||
story_id,
|
story_id,
|
||||||
&["2_current", "4_merge"],
|
&["2_current", "3_qa", "4_merge"],
|
||||||
"5_done",
|
"5_done",
|
||||||
&["6_archived"],
|
&["6_archived"],
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! MCP QA tools — request, approve, and reject QA reviews for stories.
|
//! 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::http::context::AppContext;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::slog_warn;
|
use crate::slog_warn;
|
||||||
@@ -55,7 +55,48 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
|
|||||||
let _ = crate::io::story_metadata::clear_front_matter_field(&qa_path, "review_hold");
|
let _ = crate::io::story_metadata::clear_front_matter_field(&qa_path, "review_hold");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move story from work/3_qa/ to work/4_merge/
|
let item_type = crate::agents::lifecycle::item_type_from_id(story_id);
|
||||||
|
if item_type == "spike" {
|
||||||
|
// Spikes skip the merge stage entirely: merge the feature branch to master
|
||||||
|
// directly (fast-forward or simple merge), then move straight to done.
|
||||||
|
let branch = format!("feature/story-{story_id}");
|
||||||
|
let root = project_root.clone();
|
||||||
|
let br = branch.clone();
|
||||||
|
let sid = story_id.to_string();
|
||||||
|
let merge_ok = tokio::task::spawn_blocking(move || {
|
||||||
|
merge_spike_branch_to_master(&root, &br, &sid)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Merge task panicked: {e}"))??;
|
||||||
|
|
||||||
|
move_story_to_done(&project_root, story_id)?;
|
||||||
|
|
||||||
|
let pool = std::sync::Arc::clone(&ctx.agents);
|
||||||
|
pool.remove_agents_for_story(story_id);
|
||||||
|
|
||||||
|
let wt_path = crate::worktree::worktree_path(&project_root, story_id);
|
||||||
|
if wt_path.exists() {
|
||||||
|
let config = crate::config::ProjectConfig::load(&project_root).unwrap_or_default();
|
||||||
|
let _ = crate::worktree::remove_worktree_by_story_id(
|
||||||
|
&project_root,
|
||||||
|
story_id,
|
||||||
|
&config,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.auto_assign_available_work(&project_root).await;
|
||||||
|
|
||||||
|
serde_json::to_string_pretty(&json!({
|
||||||
|
"story_id": story_id,
|
||||||
|
"message": format!(
|
||||||
|
"Spike '{story_id}' approved. Branch merged to master ({}). Moved directly to work/5_done/.",
|
||||||
|
if merge_ok { "merged" } else { "no changes to merge" }
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.map_err(|e| format!("Serialization error: {e}"))
|
||||||
|
} else {
|
||||||
|
// Non-spike items go through the normal merge pipeline.
|
||||||
move_story_to_merge(&project_root, story_id)?;
|
move_story_to_merge(&project_root, story_id)?;
|
||||||
|
|
||||||
// Start the mergemaster agent
|
// Start the mergemaster agent
|
||||||
@@ -75,6 +116,74 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
|
|||||||
}))
|
}))
|
||||||
.map_err(|e| format!("Serialization error: {e}"))
|
.map_err(|e| format!("Serialization error: {e}"))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge a spike's feature branch into master using a fast-forward or simple merge.
|
||||||
|
///
|
||||||
|
/// Unlike the squash-merge pipeline used for stories, spikes skip quality gates
|
||||||
|
/// and preserve their commit history. Returns `true` if a merge was performed,
|
||||||
|
/// `false` if the branch had no unmerged commits.
|
||||||
|
fn merge_spike_branch_to_master(
|
||||||
|
project_root: &std::path::Path,
|
||||||
|
branch: &str,
|
||||||
|
story_id: &str,
|
||||||
|
) -> Result<bool, String> {
|
||||||
|
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<String, String> {
|
pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
let story_id = args
|
let story_id = args
|
||||||
|
|||||||
Reference in New Issue
Block a user