huskies: merge 867
This commit is contained in:
@@ -349,6 +349,7 @@ fn stage_to_name(s: &Stage) -> &'static str {
|
|||||||
Stage::Merge { .. } => "merge",
|
Stage::Merge { .. } => "merge",
|
||||||
Stage::Done { .. } => "done",
|
Stage::Done { .. } => "done",
|
||||||
Stage::Archived { .. } => "archived",
|
Stage::Archived { .. } => "archived",
|
||||||
|
Stage::Frozen { .. } => "frozen",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,16 +93,14 @@ pub(super) fn check_archived_dependencies(
|
|||||||
crate::io::story_metadata::check_archived_deps(project_root, stage_dir, story_id)
|
crate::io::story_metadata::check_archived_deps(project_root, stage_dir, story_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `true` if the story file has `frozen: true` in its front matter.
|
/// Return `true` if the story is in the `Frozen` pipeline stage.
|
||||||
pub(super) fn is_story_frozen(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
///
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
/// Checks the typed CRDT stage via `read_typed`.
|
||||||
let contents = match read_story_contents(project_root, story_id) {
|
pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||||
Some(c) => c,
|
crate::pipeline_state::read_typed(story_id)
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
parse_front_matter(&contents)
|
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.frozen)
|
.flatten()
|
||||||
|
.map(|item| item.stage.is_frozen())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
//! Handler for the `freeze` and `unfreeze` commands.
|
//! Handler for the `freeze` and `unfreeze` commands.
|
||||||
//!
|
//!
|
||||||
//! `freeze <number>` sets `frozen: true` on the story, halting pipeline
|
//! `freeze <number>` transitions the story to `Stage::Frozen`, halting pipeline
|
||||||
//! advancement and auto-assign until `unfreeze <number>` clears the flag.
|
//! advancement and auto-assign until `unfreeze <number>` restores the prior stage.
|
||||||
|
|
||||||
use super::CommandContext;
|
use super::CommandContext;
|
||||||
use crate::io::story_metadata::{
|
use crate::io::story_metadata::parse_front_matter;
|
||||||
clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field,
|
|
||||||
};
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Handle the `freeze` command.
|
/// Handle the `freeze` command.
|
||||||
@@ -52,23 +50,22 @@ fn freeze_by_story_id(story_id: &str) -> String {
|
|||||||
|
|
||||||
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
||||||
|
|
||||||
if meta.frozen == Some(true) {
|
// Check if already frozen via the typed stage.
|
||||||
|
if crate::pipeline_state::read_typed(story_id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|i| i.stage.is_frozen())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
return format!("**{story_name}** ({story_id}) is already frozen.");
|
return format!("**{story_name}** ({story_id}) is already frozen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = set_front_matter_field(&contents, "frozen", "true");
|
match crate::pipeline_state::transition_to_frozen(story_id) {
|
||||||
|
Ok(_) => format!(
|
||||||
crate::db::write_content(story_id, &updated);
|
|
||||||
let stage = crate::pipeline_state::read_typed(story_id)
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|i| i.stage.dir_name().to_string())
|
|
||||||
.unwrap_or_else(|| "2_current".to_string());
|
|
||||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
|
||||||
|
|
||||||
format!(
|
|
||||||
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
|
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
|
||||||
)
|
),
|
||||||
|
Err(e) => format!("Failed to freeze **{story_name}** ({story_id}): {e}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle the `unfreeze` command.
|
/// Handle the `unfreeze` command.
|
||||||
@@ -112,21 +109,23 @@ fn unfreeze_by_story_id(story_id: &str) -> String {
|
|||||||
|
|
||||||
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
||||||
|
|
||||||
if meta.frozen != Some(true) {
|
// Check frozen via typed stage.
|
||||||
|
let is_frozen = crate::pipeline_state::read_typed(story_id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|i| i.stage.is_frozen())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !is_frozen {
|
||||||
return format!("**{story_name}** ({story_id}) is not frozen. Nothing to unfreeze.");
|
return format!("**{story_name}** ({story_id}) is not frozen. Nothing to unfreeze.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated = clear_front_matter_field_in_content(&contents, "frozen");
|
match crate::pipeline_state::transition_to_unfrozen(story_id) {
|
||||||
|
Ok(_) => {
|
||||||
crate::db::write_content(story_id, &updated);
|
|
||||||
let stage = crate::pipeline_state::read_typed(story_id)
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|i| i.stage.dir_name().to_string())
|
|
||||||
.unwrap_or_else(|| "2_current".to_string());
|
|
||||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
|
||||||
|
|
||||||
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
|
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
|
||||||
|
}
|
||||||
|
Err(e) => format!("Failed to unfreeze **{story_name}** ({story_id}): {e}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -212,8 +211,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn freeze_command_sets_frozen_flag() {
|
fn freeze_command_sets_stage_to_frozen() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
write_story_file(
|
write_story_file(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
@@ -226,40 +226,54 @@ mod tests {
|
|||||||
output.contains("Frozen") && output.contains("Freeze Me"),
|
output.contains("Frozen") && output.contains("Freeze Me"),
|
||||||
"should confirm freeze with story name: {output}"
|
"should confirm freeze with story name: {output}"
|
||||||
);
|
);
|
||||||
let contents = crate::db::read_content("9940_story_freezeme")
|
let item = crate::pipeline_state::read_typed("9940_story_freezeme")
|
||||||
.expect("story content should be readable after freeze");
|
.expect("read_typed should succeed")
|
||||||
|
.expect("item should be present");
|
||||||
assert!(
|
assert!(
|
||||||
contents.contains("frozen: true"),
|
item.stage.is_frozen(),
|
||||||
"frozen flag should be set: {contents}"
|
"stage should be Frozen after freeze: {:?}",
|
||||||
|
item.stage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unfreeze_command_clears_frozen_flag() {
|
fn unfreeze_command_restores_prior_stage() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
write_story_file(
|
write_story_file(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
"2_current",
|
"2_current",
|
||||||
"9941_story_frozen.md",
|
"9941_story_frozen.md",
|
||||||
"---\nname: Frozen Story\nfrozen: true\n---\n# Story\n",
|
"---\nname: Frozen Story\n---\n# Story\n",
|
||||||
);
|
);
|
||||||
|
// Freeze first.
|
||||||
|
let freeze_out = freeze_cmd_with_root(tmp.path(), "9941").unwrap();
|
||||||
|
assert!(
|
||||||
|
freeze_out.contains("Frozen"),
|
||||||
|
"should confirm freeze: {freeze_out}"
|
||||||
|
);
|
||||||
|
// Now unfreeze.
|
||||||
let output = unfreeze_cmd_with_root(tmp.path(), "9941").unwrap();
|
let output = unfreeze_cmd_with_root(tmp.path(), "9941").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Unfrozen") && output.contains("Frozen Story"),
|
output.contains("Unfrozen") && output.contains("Frozen Story"),
|
||||||
"should confirm unfreeze with story name: {output}"
|
"should confirm unfreeze with story name: {output}"
|
||||||
);
|
);
|
||||||
let contents = crate::db::read_content("9941_story_frozen")
|
let item = crate::pipeline_state::read_typed("9941_story_frozen")
|
||||||
.expect("story content should be readable after unfreeze");
|
.expect("read_typed should succeed")
|
||||||
|
.expect("item should be present");
|
||||||
assert!(
|
assert!(
|
||||||
!contents.contains("frozen:"),
|
matches!(item.stage, crate::pipeline_state::Stage::Coding),
|
||||||
"frozen flag should be removed: {contents}"
|
"stage should be restored to Coding: {:?}",
|
||||||
|
item.stage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unfreeze_command_not_frozen_returns_error() {
|
fn unfreeze_command_not_frozen_returns_error() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
crate::db::ensure_content_store();
|
||||||
write_story_file(
|
write_story_file(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
"2_current",
|
"2_current",
|
||||||
@@ -276,12 +290,17 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn freeze_command_already_frozen_returns_message() {
|
fn freeze_command_already_frozen_returns_message() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
crate::db::ensure_content_store();
|
||||||
write_story_file(
|
write_story_file(
|
||||||
tmp.path(),
|
tmp.path(),
|
||||||
"2_current",
|
"2_current",
|
||||||
"9943_story_alreadyfrozen.md",
|
"9943_story_alreadyfrozen.md",
|
||||||
"---\nname: Already Frozen\nfrozen: true\n---\n# Story\n",
|
"---\nname: Already Frozen\n---\n# Story\n",
|
||||||
);
|
);
|
||||||
|
// Freeze it first.
|
||||||
|
freeze_cmd_with_root(tmp.path(), "9943").unwrap();
|
||||||
|
// Try to freeze again.
|
||||||
let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap();
|
let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("already frozen"),
|
output.contains("already frozen"),
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ fn stage_display_name(stage: &str) -> &str {
|
|||||||
Some(Stage::Merge { .. }) => "merge",
|
Some(Stage::Merge { .. }) => "merge",
|
||||||
Some(Stage::Done { .. }) => "done",
|
Some(Stage::Done { .. }) => "done",
|
||||||
Some(Stage::Archived { .. }) => "archived",
|
Some(Stage::Archived { .. }) => "archived",
|
||||||
|
Some(Stage::Frozen { .. }) => "frozen",
|
||||||
None => stage,
|
None => stage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
|||||||
Stage::Merge { .. } => "merge",
|
Stage::Merge { .. } => "merge",
|
||||||
Stage::Done { .. } => "done",
|
Stage::Done { .. } => "done",
|
||||||
Stage::Archived { .. } => "archived",
|
Stage::Archived { .. } => "archived",
|
||||||
|
Stage::Frozen { .. } => "frozen",
|
||||||
};
|
};
|
||||||
member_items.push(json!({
|
member_items.push(json!({
|
||||||
"story_id": sid,
|
"story_id": sid,
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
|||||||
Stage::Merge { .. } => state.merge.push(story),
|
Stage::Merge { .. } => state.merge.push(story),
|
||||||
Stage::Done { .. } => state.done.push(story),
|
Stage::Done { .. } => state.done.push(story),
|
||||||
Stage::Archived { .. } => {} // skip archived
|
Stage::Archived { .. } => {} // skip archived
|
||||||
|
Stage::Frozen { .. } => state.backlog.push(story), // show frozen with backlog
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pub(super) struct FrontMatter {
|
|||||||
pub depends_on: Option<Vec<u32>>,
|
pub depends_on: Option<Vec<u32>>,
|
||||||
/// When `true`, the story is frozen.
|
/// When `true`, the story is frozen.
|
||||||
pub frozen: Option<bool>,
|
pub frozen: Option<bool>,
|
||||||
|
/// Stage directory to restore on unfreeze (e.g. `"2_current"`).
|
||||||
|
pub resume_to_stage: Option<String>,
|
||||||
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
|
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
|
||||||
/// Used by the bug-645 salvage path to distinguish a genuine test-passing
|
/// Used by the bug-645 salvage path to distinguish a genuine test-passing
|
||||||
/// session from one that merely compiled.
|
/// session from one that merely compiled.
|
||||||
@@ -76,6 +78,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
|||||||
blocked: front.blocked,
|
blocked: front.blocked,
|
||||||
depends_on: front.depends_on,
|
depends_on: front.depends_on,
|
||||||
frozen: front.frozen,
|
frozen: front.frozen,
|
||||||
|
resume_to_stage: front.resume_to_stage,
|
||||||
run_tests_passed: front.run_tests_passed,
|
run_tests_passed: front.run_tests_passed,
|
||||||
item_type: front.item_type,
|
item_type: front.item_type,
|
||||||
mergemaster_attempted: front.mergemaster_attempted,
|
mergemaster_attempted: front.mergemaster_attempted,
|
||||||
@@ -131,17 +134,15 @@ pub fn resolve_qa_mode_from_content(story_id: &str, contents: &str, default: QaM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `true` if the story has `frozen: true` in the content store.
|
/// Return `true` if the story is in the `Frozen` pipeline stage.
|
||||||
///
|
///
|
||||||
/// Used by the pipeline advance code to suppress stage transitions for frozen stories.
|
/// Checks the typed CRDT stage via `read_typed`. Used by the pipeline advance
|
||||||
|
/// code to suppress stage transitions for frozen stories.
|
||||||
pub fn is_story_frozen_in_store(story_id: &str) -> bool {
|
pub fn is_story_frozen_in_store(story_id: &str) -> bool {
|
||||||
let contents = match crate::db::read_content(story_id) {
|
crate::pipeline_state::read_typed(story_id)
|
||||||
Some(c) => c,
|
|
||||||
None => return false,
|
|
||||||
};
|
|
||||||
parse_front_matter(&contents)
|
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.frozen)
|
.flatten()
|
||||||
|
.map(|item| item.stage.is_frozen())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ pub struct StoryMetadata {
|
|||||||
/// When `true`, the story is frozen: auto-assign skips it, the pipeline
|
/// When `true`, the story is frozen: auto-assign skips it, the pipeline
|
||||||
/// does not advance it, and no mergemaster is spawned.
|
/// does not advance it, and no mergemaster is spawned.
|
||||||
pub frozen: Option<bool>,
|
pub frozen: Option<bool>,
|
||||||
|
/// Pipeline stage to restore when unfreezing (e.g. `"2_current"`).
|
||||||
|
/// Written by `transition_to_frozen`; cleared by `transition_to_unfrozen`.
|
||||||
|
pub resume_to_stage: Option<String>,
|
||||||
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
|
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
|
||||||
/// Used by the bug-645 salvage path to require real test evidence, not just
|
/// Used by the bug-645 salvage path to require real test evidence, not just
|
||||||
/// compilation success.
|
/// compilation success.
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ pub fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, Strin
|
|||||||
Stage::Merge { .. } => ("merge", format!("huskies: queue {item_id} for merge")),
|
Stage::Merge { .. } => ("merge", format!("huskies: queue {item_id} for merge")),
|
||||||
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
|
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
|
||||||
Stage::Archived { .. } => ("accept", format!("huskies: accept {item_id}")),
|
Stage::Archived { .. } => ("accept", format!("huskies: accept {item_id}")),
|
||||||
|
Stage::Frozen { .. } => ("freeze", format!("huskies: freeze {item_id}")),
|
||||||
};
|
};
|
||||||
Some((action, msg))
|
Some((action, msg))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,3 +97,28 @@ pub fn apply_transition_str(
|
|||||||
) -> Result<TransitionFired, String> {
|
) -> Result<TransitionFired, String> {
|
||||||
apply_transition(story_id, event, content_transform).map_err(|e| e.to_string())
|
apply_transition(story_id, event, content_transform).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Freeze a story at its current stage.
|
||||||
|
///
|
||||||
|
/// Transitions the story to `Stage::Frozen { resume_to: current_stage }` and
|
||||||
|
/// writes `resume_to_stage` into the front matter so the projection layer can
|
||||||
|
/// reconstruct the full typed stage on subsequent reads.
|
||||||
|
pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
||||||
|
let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?;
|
||||||
|
let resume_dir = item.stage.dir_name().to_string();
|
||||||
|
let transform = move |content: &str| -> String {
|
||||||
|
crate::io::story_metadata::set_front_matter_field(content, "resume_to_stage", &resume_dir)
|
||||||
|
};
|
||||||
|
apply_transition(story_id, PipelineEvent::Freeze, Some(&transform))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unfreeze a story, restoring it to the stage it was in before freezing.
|
||||||
|
///
|
||||||
|
/// Transitions `Stage::Frozen { resume_to }` back to `resume_to` and removes
|
||||||
|
/// the `resume_to_stage` field from the front matter.
|
||||||
|
pub fn transition_to_unfrozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
||||||
|
let transform = |content: &str| -> String {
|
||||||
|
crate::io::story_metadata::clear_front_matter_field_in_content(content, "resume_to_stage")
|
||||||
|
};
|
||||||
|
apply_transition(story_id, PipelineEvent::Unfreeze, Some(&transform))
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ pub use projection::{ProjectionError, project_stage};
|
|||||||
pub use projection::{read_all_typed, read_typed};
|
pub use projection::{read_all_typed, read_typed};
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use apply::{ApplyError, apply_transition, apply_transition_str};
|
pub use apply::{
|
||||||
|
ApplyError, apply_transition, apply_transition_str, transition_to_frozen,
|
||||||
|
transition_to_unfrozen,
|
||||||
|
};
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use subscribers::{
|
pub use subscribers::{
|
||||||
|
|||||||
@@ -119,6 +119,21 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
|||||||
reason,
|
reason,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
"7_frozen" => {
|
||||||
|
// The stage to resume to is stored in front matter as `resume_to_stage`.
|
||||||
|
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
|
||||||
|
let resume_to = crate::db::read_content(&view.story_id)
|
||||||
|
.and_then(|content| {
|
||||||
|
crate::io::story_metadata::parse_front_matter(&content)
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.resume_to_stage)
|
||||||
|
.and_then(|dir| Stage::from_dir(&dir))
|
||||||
|
})
|
||||||
|
.unwrap_or(Stage::Coding);
|
||||||
|
Ok(Stage::Frozen {
|
||||||
|
resume_to: Box::new(resume_to),
|
||||||
|
})
|
||||||
|
}
|
||||||
other => Err(ProjectionError::UnknownStage(other.to_string())),
|
other => Err(ProjectionError::UnknownStage(other.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +152,7 @@ impl PipelineItem {
|
|||||||
..
|
..
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
// Frozen stories map to "7_frozen"; they are not "blocked" in the CRDT sense.
|
||||||
(dir, blocked)
|
(dir, blocked)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -538,4 +538,124 @@ fn cannot_reject_from_archived() {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Freeze / Unfreeze ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn freeze_from_active_stages() {
|
||||||
|
for s in [Stage::Upcoming, Stage::Backlog, Stage::Coding, Stage::Qa] {
|
||||||
|
let result = transition(s.clone(), PipelineEvent::Freeze).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(result, Stage::Frozen { .. }),
|
||||||
|
"expected Frozen from {s:?}"
|
||||||
|
);
|
||||||
|
if let Stage::Frozen { resume_to } = result {
|
||||||
|
assert_eq!(*resume_to, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn freeze_from_merge() {
|
||||||
|
let m = Stage::Merge {
|
||||||
|
feature_branch: fb("f"),
|
||||||
|
commits_ahead: nz(1),
|
||||||
|
};
|
||||||
|
let result = transition(m.clone(), PipelineEvent::Freeze).unwrap();
|
||||||
|
assert!(matches!(result, Stage::Frozen { .. }));
|
||||||
|
if let Stage::Frozen { resume_to } = result {
|
||||||
|
assert_eq!(*resume_to, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unfreeze_restores_prior_stage() {
|
||||||
|
let prior = Stage::Coding;
|
||||||
|
let frozen = Stage::Frozen {
|
||||||
|
resume_to: Box::new(prior.clone()),
|
||||||
|
};
|
||||||
|
let result = transition(frozen, PipelineEvent::Unfreeze).unwrap();
|
||||||
|
assert_eq!(result, prior);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cannot_freeze_done() {
|
||||||
|
let s = Stage::Done {
|
||||||
|
merged_at: chrono::Utc::now(),
|
||||||
|
merge_commit: sha("abc"),
|
||||||
|
};
|
||||||
|
let result = transition(s, PipelineEvent::Freeze);
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(TransitionError::InvalidTransition { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cannot_freeze_archived() {
|
||||||
|
let s = Stage::Archived {
|
||||||
|
archived_at: chrono::Utc::now(),
|
||||||
|
reason: ArchiveReason::Completed,
|
||||||
|
};
|
||||||
|
let result = transition(s, PipelineEvent::Freeze);
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(TransitionError::InvalidTransition { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cannot_unfreeze_coding() {
|
||||||
|
let result = transition(Stage::Coding, PipelineEvent::Unfreeze);
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(TransitionError::InvalidTransition { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Regression test: freeze → unfreeze round-trip via `apply_transition`.
|
||||||
|
/// Verifies that the CRDT shows the correct prior stage restored.
|
||||||
|
#[test]
|
||||||
|
fn regression_freeze_unfreeze_restores_crdt_stage() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
crate::db::ensure_content_store();
|
||||||
|
|
||||||
|
let story_id = "9950_story_freeze_regression";
|
||||||
|
let content = "---\nname: Freeze Regression\n---\n# Story\n";
|
||||||
|
crate::db::write_item_with_content(story_id, "2_current", content);
|
||||||
|
|
||||||
|
// Confirm starting stage.
|
||||||
|
let item = read_typed(story_id).unwrap().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(item.stage, Stage::Coding),
|
||||||
|
"should start at Coding"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Freeze.
|
||||||
|
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
|
||||||
|
|
||||||
|
let item = read_typed(story_id).unwrap().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(item.stage, Stage::Frozen { .. }),
|
||||||
|
"should be Frozen after freeze: {:?}",
|
||||||
|
item.stage
|
||||||
|
);
|
||||||
|
if let Stage::Frozen { ref resume_to } = item.stage {
|
||||||
|
assert!(
|
||||||
|
matches!(**resume_to, Stage::Coding),
|
||||||
|
"resume_to should be Coding: {:?}",
|
||||||
|
resume_to
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unfreeze.
|
||||||
|
super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");
|
||||||
|
|
||||||
|
let item = read_typed(story_id).unwrap().unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(item.stage, Stage::Coding),
|
||||||
|
"should be restored to Coding after unfreeze: {:?}",
|
||||||
|
item.stage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── ProjectionError Display ─────────────────────────────────────────
|
// ── ProjectionError Display ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ pub enum PipelineEvent {
|
|||||||
Close,
|
Close,
|
||||||
/// Manual demotion back to backlog from an active stage.
|
/// Manual demotion back to backlog from an active stage.
|
||||||
Demote,
|
Demote,
|
||||||
|
/// Freeze the story at its current stage (suspends pipeline and auto-assign).
|
||||||
|
Freeze,
|
||||||
|
/// Unfreeze the story, restoring it to the stage it was at when frozen.
|
||||||
|
Unfreeze,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-node execution events ───────────────────────────────────────────────
|
// ── Per-node execution events ───────────────────────────────────────────────
|
||||||
@@ -94,6 +98,8 @@ pub fn event_label(e: &PipelineEvent) -> &'static str {
|
|||||||
PipelineEvent::Triage => "Triage",
|
PipelineEvent::Triage => "Triage",
|
||||||
PipelineEvent::Close => "Close",
|
PipelineEvent::Close => "Close",
|
||||||
PipelineEvent::Demote => "Demote",
|
PipelineEvent::Demote => "Demote",
|
||||||
|
PipelineEvent::Freeze => "Freeze",
|
||||||
|
PipelineEvent::Unfreeze => "Unfreeze",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +237,17 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
|
|||||||
Unblock,
|
Unblock,
|
||||||
) => Ok(Backlog),
|
) => Ok(Backlog),
|
||||||
|
|
||||||
|
// ── Freeze: any active stage → Frozen(resume_to=current) ────────
|
||||||
|
(stage @ (Upcoming | Backlog | Coding | Qa), Freeze) => Ok(Frozen {
|
||||||
|
resume_to: Box::new(stage),
|
||||||
|
}),
|
||||||
|
(stage @ Merge { .. }, Freeze) => Ok(Frozen {
|
||||||
|
resume_to: Box::new(stage),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ── Unfreeze: Frozen → resume_to ─────────────────────────────────
|
||||||
|
(Frozen { resume_to }, Unfreeze) => Ok(*resume_to),
|
||||||
|
|
||||||
// ── Everything else is invalid ──────────────────────────────────
|
// ── Everything else is invalid ──────────────────────────────────
|
||||||
_ => Err(invalid()),
|
_ => Err(invalid()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ pub enum Stage {
|
|||||||
archived_at: DateTime<Utc>,
|
archived_at: DateTime<Utc>,
|
||||||
reason: ArchiveReason,
|
reason: ArchiveReason,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Pipeline advancement and auto-assign are suspended. Resumes to
|
||||||
|
/// `resume_to` when unfrozen.
|
||||||
|
Frozen { resume_to: Box<Stage> },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a story was archived. Subsumes the old `blocked`, `merge_failure`,
|
/// Why a story was archived. Subsumes the old `blocked`, `merge_failure`,
|
||||||
@@ -130,6 +134,11 @@ impl Stage {
|
|||||||
matches!(self, Stage::Coding | Stage::Qa | Stage::Merge { .. })
|
matches!(self, Stage::Coding | Stage::Qa | Stage::Merge { .. })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if this stage is `Frozen`.
|
||||||
|
pub fn is_frozen(&self) -> bool {
|
||||||
|
matches!(self, Stage::Frozen { .. })
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns true if this is the Upcoming variant.
|
/// Returns true if this is the Upcoming variant.
|
||||||
pub fn is_upcoming(&self) -> bool {
|
pub fn is_upcoming(&self) -> bool {
|
||||||
matches!(self, Stage::Upcoming)
|
matches!(self, Stage::Upcoming)
|
||||||
@@ -177,6 +186,11 @@ impl Stage {
|
|||||||
archived_at: DateTime::<Utc>::UNIX_EPOCH,
|
archived_at: DateTime::<Utc>::UNIX_EPOCH,
|
||||||
reason: ArchiveReason::Completed,
|
reason: ArchiveReason::Completed,
|
||||||
}),
|
}),
|
||||||
|
// Frozen: stub with Coding as resume_to — rich resume_to is loaded
|
||||||
|
// from front matter by the projection layer.
|
||||||
|
"7_frozen" => Some(Stage::Frozen {
|
||||||
|
resume_to: Box::new(Stage::Coding),
|
||||||
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +270,7 @@ pub fn stage_label(s: &Stage) -> &'static str {
|
|||||||
Stage::Merge { .. } => "Merge",
|
Stage::Merge { .. } => "Merge",
|
||||||
Stage::Done { .. } => "Done",
|
Stage::Done { .. } => "Done",
|
||||||
Stage::Archived { .. } => "Archived",
|
Stage::Archived { .. } => "Archived",
|
||||||
|
Stage::Frozen { .. } => "Frozen",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,5 +284,6 @@ pub fn stage_dir_name(s: &Stage) -> &'static str {
|
|||||||
Stage::Merge { .. } => "4_merge",
|
Stage::Merge { .. } => "4_merge",
|
||||||
Stage::Done { .. } => "5_done",
|
Stage::Done { .. } => "5_done",
|
||||||
Stage::Archived { .. } => "6_archived",
|
Stage::Archived { .. } => "6_archived",
|
||||||
|
Stage::Frozen { .. } => "7_frozen",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ pub fn get_work_item_content(
|
|||||||
crate::pipeline_state::Stage::Merge { .. } => "merge",
|
crate::pipeline_state::Stage::Merge { .. } => "merge",
|
||||||
crate::pipeline_state::Stage::Done { .. } => "done",
|
crate::pipeline_state::Stage::Done { .. } => "done",
|
||||||
crate::pipeline_state::Stage::Archived { .. } => "archived",
|
crate::pipeline_state::Stage::Archived { .. } => "archived",
|
||||||
|
crate::pipeline_state::Stage::Frozen { .. } => "frozen",
|
||||||
})
|
})
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub fn stage_display_name(stage: &str) -> &'static str {
|
|||||||
Some(Stage::Merge { .. }) => "Merge",
|
Some(Stage::Merge { .. }) => "Merge",
|
||||||
Some(Stage::Done { .. }) => "Done",
|
Some(Stage::Done { .. }) => "Done",
|
||||||
Some(Stage::Archived { .. }) => "Archived",
|
Some(Stage::Archived { .. }) => "Archived",
|
||||||
|
Some(Stage::Frozen { .. }) => "Frozen",
|
||||||
None => "Unknown",
|
None => "Unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user