huskies: merge 890

This commit is contained in:
dave
2026-05-12 14:43:27 +00:00
parent bb845d17cf
commit 2c5326f339
5 changed files with 227 additions and 182 deletions
+161 -101
View File
@@ -29,37 +29,79 @@ pub(super) fn read_story_front_matter_agent(
parse_front_matter(&contents).ok()?.agent
}
/// Return `true` if the story file in the given stage has `review_hold: true` in its front matter.
pub(super) fn has_review_hold(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
};
parse_front_matter(&contents)
/// Return `true` if the story is in the `Frozen` pipeline stage.
///
/// In the typed CRDT model, `Frozen` is the authoritative representation of
/// stories that are held for human review (replacing the legacy
/// `review_hold: true` YAML front-matter field). The typed stage register is
/// the only source consulted — stale YAML is ignored.
pub(super) fn has_review_hold(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
crate::pipeline_state::read_typed(story_id)
.ok()
.and_then(|m| m.review_hold)
.flatten()
.map(|item| item.stage.is_frozen())
.unwrap_or(false)
}
/// Return `true` if the story is blocked — either via the typed `Stage::Blocked`
/// variant or the legacy `blocked: true` front-matter field.
pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
// Check the typed stage first (authoritative after story 866).
if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id)
&& item.stage.is_blocked()
{
return true;
}
// Legacy fallback: check front-matter field for backward compatibility.
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
};
parse_front_matter(&contents)
/// Return `true` if the story is blocked via the typed `Stage::Blocked` or
/// `Stage::MergeFailure` variant (or the legacy `Archived(Blocked)` state).
///
/// The typed pipeline stage register is the only source consulted — the legacy
/// `blocked: true` YAML front-matter field is no longer checked.
pub(super) fn is_story_blocked(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
crate::pipeline_state::read_typed(story_id)
.ok()
.and_then(|m| m.blocked)
.flatten()
.map(|item| item.stage.is_blocked())
.unwrap_or(false)
}
/// Return `true` if the story's merge failure contains a git content-conflict
/// marker (`"Merge conflict"` or `"CONFLICT (content):"`).
///
/// Used by the auto-assigner to decide whether to spawn mergemaster automatically.
/// The typed stage register is consulted first; the CRDT content store is then
/// scanned for conflict markers (the projection layer does not carry the reason
/// string). No YAML front-matter parsing is performed.
pub(super) fn has_content_conflict_failure(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
let is_merge_failure = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|item| {
matches!(
item.stage,
crate::pipeline_state::Stage::MergeFailure { .. }
)
})
.unwrap_or(false);
if !is_merge_failure {
return false;
}
// The projection does not carry the reason string; read the raw content
// from the CRDT content store and scan for conflict markers.
crate::db::read_content(story_id)
.map(|content| {
content.contains("Merge conflict") || content.contains("CONFLICT (content):")
})
.unwrap_or(false)
}
/// Return `true` if the CRDT `mergemaster_attempted` register is set for this story.
///
/// Used to prevent the auto-assigner from repeatedly spawning mergemaster for
/// the same story after a failed mergemaster session. The CRDT register is the
/// only source consulted — the legacy YAML field is no longer checked.
pub(super) fn has_mergemaster_attempted(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
crate::crdt_state::read_item(story_id)
.and_then(|view| view.mergemaster_attempted)
.unwrap_or(false)
}
@@ -120,97 +162,115 @@ pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id:
.unwrap_or(false)
}
/// Return `true` if the story file has a `merge_failure` field in its front matter.
pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
};
parse_front_matter(&contents)
.ok()
.and_then(|m| m.merge_failure)
.is_some()
}
/// Return `true` if the story's `merge_failure` contains a git content-conflict
/// marker (`"Merge conflict"` or `"CONFLICT (content):"`).
///
/// Used by the auto-assigner to decide whether to spawn mergemaster automatically.
pub(super) fn has_content_conflict_failure(
project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
};
parse_front_matter(&contents)
.ok()
.and_then(|m| m.merge_failure)
.map(|reason| reason.contains("Merge conflict") || reason.contains("CONFLICT (content):"))
.unwrap_or(false)
}
/// Return `true` if the story has `mergemaster_attempted: true` in its front matter.
///
/// Used to prevent the auto-assigner from repeatedly spawning mergemaster for
/// the same story after a failed mergemaster session.
pub(super) fn has_mergemaster_attempted(
project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
use crate::db::yaml_legacy::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
};
parse_front_matter(&contents)
.ok()
.and_then(|m| m.mergemaster_attempted)
.unwrap_or(false)
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── has_review_hold ───────────────────────────────────────────────────────
#[test]
fn has_review_hold_returns_true_when_set() {
let tmp = tempfile::tempdir().unwrap();
fn has_review_hold_returns_true_when_frozen() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"10_spike_research",
"3_qa",
"---\nname: Research spike\nreview_hold: true\n---\n# Spike\n",
crate::db::ItemMeta::from_yaml(
"---\nname: Research spike\nreview_hold: true\n---\n# Spike\n",
),
);
assert!(has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
}
#[test]
fn has_review_hold_returns_false_when_not_set() {
let tmp = tempfile::tempdir().unwrap();
let qa_dir = tmp.path().join(".huskies/work/3_qa");
std::fs::create_dir_all(&qa_dir).unwrap();
let spike_path = qa_dir.join("10_spike_research.md");
std::fs::write(&spike_path, "---\nname: Research spike\n---\n# Spike\n").unwrap();
assert!(!has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
crate::db::write_item_with_content(
"890_spike_frozen",
"7_frozen",
"---\nname: Frozen Spike\n---\n# Spike\n",
crate::db::ItemMeta::named("Frozen Spike"),
);
assert!(has_review_hold(tmp.path(), "3_qa", "890_spike_frozen"));
}
#[test]
fn has_review_hold_returns_false_when_file_missing() {
fn has_review_hold_returns_false_for_qa_stage() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
crate::db::write_item_with_content(
"890_spike_active_qa",
"3_qa",
"---\nname: Active QA Spike\n---\n# Spike\n",
crate::db::ItemMeta::named("Active QA Spike"),
);
assert!(!has_review_hold(tmp.path(), "3_qa", "890_spike_active_qa"));
}
#[test]
fn has_review_hold_returns_false_when_story_unknown() {
let tmp = tempfile::tempdir().unwrap();
assert!(!has_review_hold(tmp.path(), "3_qa", "99_spike_missing"));
}
// ── is_story_blocked — regression: typed stage is sole authority ──────────
#[test]
fn is_story_blocked_set_via_typed_stage_returns_true() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
crate::db::write_item_with_content(
"890_story_blocked_set",
"2_blocked",
"---\nname: Blocked Story\n---\n",
crate::db::ItemMeta::named("Blocked Story"),
);
assert!(is_story_blocked(
tmp.path(),
"2_blocked",
"890_story_blocked_set"
));
}
#[test]
fn is_story_blocked_cleared_via_typed_stage_returns_false() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// First set to blocked.
crate::db::write_item_with_content(
"890_story_blocked_clear",
"2_blocked",
"---\nname: Clearable Story\n---\n",
crate::db::ItemMeta::named("Clearable Story"),
);
// Then clear by transitioning to an active stage.
crate::db::write_item_with_content(
"890_story_blocked_clear",
"2_current",
"---\nname: Clearable Story\n---\n",
crate::db::ItemMeta::named("Clearable Story"),
);
assert!(!is_story_blocked(
tmp.path(),
"2_current",
"890_story_blocked_clear"
));
}
#[test]
fn is_story_blocked_stale_yaml_is_ignored() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// YAML front matter says `blocked: true`, but the typed CRDT stage is backlog.
// After removing the YAML fallback, the function must return false.
crate::db::write_item_with_content(
"890_story_stale_yaml",
"1_backlog",
"---\nname: Stale\nblocked: true\n---\n",
crate::db::ItemMeta::named("Stale"),
);
assert!(
!is_story_blocked(tmp.path(), "1_backlog", "890_story_stale_yaml"),
"stale YAML `blocked: true` must not be reported as blocked when typed stage is Backlog"
);
}
// ── has_unmet_dependencies ────────────────────────────────────────────────
#[test]
fn has_unmet_dependencies_returns_true_when_dep_not_done() {
let tmp = tempfile::tempdir().unwrap();