291 lines
11 KiB
Rust
291 lines
11 KiB
Rust
//! Front-matter checks for story files: review holds, blocked state, and merge failures.
|
|
|
|
use std::path::Path;
|
|
|
|
/// Read story contents from the DB content store (CRDT-backed).
|
|
fn read_story_contents(_project_root: &Path, story_id: &str) -> Option<String> {
|
|
crate::db::read_content(story_id)
|
|
}
|
|
|
|
/// Read the optional `agent:` field from the front matter of a story file.
|
|
///
|
|
/// Returns `Some(agent_name)` if the front matter specifies an agent, or `None`
|
|
/// if the field is absent or the file cannot be read / parsed.
|
|
pub(super) fn read_story_front_matter_agent(
|
|
project_root: &Path,
|
|
_stage_dir: &str,
|
|
story_id: &str,
|
|
) -> Option<String> {
|
|
use crate::io::story_metadata::parse_front_matter;
|
|
let contents = read_story_contents(project_root, story_id)?;
|
|
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::io::story_metadata::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.review_hold)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Return `true` if the story file has `blocked: true` in its front matter.
|
|
pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
|
use crate::io::story_metadata::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.blocked)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// Return `true` if the story has any `depends_on` entries that are not yet in
|
|
/// `5_done` or `6_archived`.
|
|
///
|
|
/// Reads dependency state from the CRDT document first. Falls back to the
|
|
/// filesystem when the CRDT layer is not initialised.
|
|
pub(super) fn has_unmet_dependencies(project_root: &Path, stage_dir: &str, story_id: &str) -> bool {
|
|
// Prefer CRDT-based check.
|
|
let crdt_deps = crate::crdt_state::check_unmet_deps_crdt(story_id);
|
|
if !crdt_deps.is_empty() {
|
|
return true;
|
|
}
|
|
// If the CRDT had the item and returned empty deps, it means all are met.
|
|
if crate::pipeline_state::read_typed(story_id)
|
|
.ok()
|
|
.flatten()
|
|
.is_some()
|
|
{
|
|
return false;
|
|
}
|
|
// Fallback: filesystem check (CRDT not initialised or item not yet in CRDT).
|
|
!crate::io::story_metadata::check_unmet_deps(project_root, stage_dir, story_id).is_empty()
|
|
}
|
|
|
|
/// Return the list of dependency story numbers that are in `6_archived` (satisfied
|
|
/// via archive rather than via a clean `5_done` completion).
|
|
///
|
|
/// Used to emit a warning when backlog promotion fires because one or more deps were
|
|
/// archived. Returns an empty `Vec` when no deps are archived. Reads from CRDT
|
|
/// first; falls back to filesystem when CRDT is not initialised.
|
|
pub(super) fn check_archived_dependencies(
|
|
project_root: &Path,
|
|
stage_dir: &str,
|
|
story_id: &str,
|
|
) -> Vec<u32> {
|
|
// Prefer CRDT-based check when the item is known to CRDT.
|
|
if crate::pipeline_state::read_typed(story_id)
|
|
.ok()
|
|
.flatten()
|
|
.is_some()
|
|
{
|
|
return crate::crdt_state::check_archived_deps_crdt(story_id);
|
|
}
|
|
// Fallback: filesystem.
|
|
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.
|
|
pub(super) fn is_story_frozen(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
|
use crate::io::story_metadata::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.frozen)
|
|
.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::io::story_metadata::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::io::story_metadata::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::io::story_metadata::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::*;
|
|
|
|
#[test]
|
|
fn has_review_hold_returns_true_when_set() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
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",
|
|
);
|
|
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"));
|
|
}
|
|
|
|
#[test]
|
|
fn has_review_hold_returns_false_when_file_missing() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
assert!(!has_review_hold(tmp.path(), "3_qa", "99_spike_missing"));
|
|
}
|
|
|
|
#[test]
|
|
fn has_unmet_dependencies_returns_true_when_dep_not_done() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let current = tmp.path().join(".huskies/work/2_current");
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
std::fs::write(
|
|
current.join("10_story_blocked.md"),
|
|
"---\nname: Blocked\ndepends_on: [999]\n---\n",
|
|
)
|
|
.unwrap();
|
|
assert!(has_unmet_dependencies(
|
|
tmp.path(),
|
|
"2_current",
|
|
"10_story_blocked"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn has_unmet_dependencies_returns_false_when_dep_done() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let current = tmp.path().join(".huskies/work/2_current");
|
|
let done = tmp.path().join(".huskies/work/5_done");
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
std::fs::create_dir_all(&done).unwrap();
|
|
std::fs::write(done.join("999_story_dep.md"), "---\nname: Dep\n---\n").unwrap();
|
|
std::fs::write(
|
|
current.join("10_story_ok.md"),
|
|
"---\nname: Ok\ndepends_on: [999]\n---\n",
|
|
)
|
|
.unwrap();
|
|
assert!(!has_unmet_dependencies(
|
|
tmp.path(),
|
|
"2_current",
|
|
"10_story_ok"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn has_unmet_dependencies_returns_false_when_no_deps() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let current = tmp.path().join(".huskies/work/2_current");
|
|
std::fs::create_dir_all(¤t).unwrap();
|
|
std::fs::write(current.join("5_story_free.md"), "---\nname: Free\n---\n").unwrap();
|
|
assert!(!has_unmet_dependencies(
|
|
tmp.path(),
|
|
"2_current",
|
|
"5_story_free"
|
|
));
|
|
}
|
|
|
|
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
|
|
|
|
/// check_archived_dependencies returns dep IDs that are in 6_archived.
|
|
#[test]
|
|
fn check_archived_dependencies_returns_archived_ids() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
|
let archived = tmp.path().join(".huskies/work/6_archived");
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
std::fs::create_dir_all(&archived).unwrap();
|
|
std::fs::write(
|
|
archived.join("500_spike_crdt.md"),
|
|
"---\nname: CRDT Spike\n---\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(
|
|
backlog.join("503_story_dependent.md"),
|
|
"---\nname: Dependent\ndepends_on: [500]\n---\n",
|
|
)
|
|
.unwrap();
|
|
let archived_deps =
|
|
check_archived_dependencies(tmp.path(), "1_backlog", "503_story_dependent");
|
|
assert_eq!(archived_deps, vec![500]);
|
|
}
|
|
|
|
/// check_archived_dependencies returns empty when dep is in 5_done (not archived).
|
|
#[test]
|
|
fn check_archived_dependencies_empty_when_dep_in_done() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let backlog = tmp.path().join(".huskies/work/1_backlog");
|
|
let done = tmp.path().join(".huskies/work/5_done");
|
|
std::fs::create_dir_all(&backlog).unwrap();
|
|
std::fs::create_dir_all(&done).unwrap();
|
|
std::fs::write(done.join("490_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
|
std::fs::write(
|
|
backlog.join("503_story_waiting.md"),
|
|
"---\nname: Waiting\ndepends_on: [490]\n---\n",
|
|
)
|
|
.unwrap();
|
|
let archived_deps =
|
|
check_archived_dependencies(tmp.path(), "1_backlog", "503_story_waiting");
|
|
assert!(archived_deps.is_empty());
|
|
}
|
|
}
|