huskies: merge 946
This commit is contained in:
@@ -453,7 +453,7 @@ mod tests {
|
||||
"5_story_test",
|
||||
"1_backlog",
|
||||
content,
|
||||
crate::db::ItemMeta::default(),
|
||||
crate::db::ItemMeta::named("Test"),
|
||||
);
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
@@ -485,7 +485,7 @@ mod tests {
|
||||
"6_story_back",
|
||||
"2_current",
|
||||
content,
|
||||
crate::db::ItemMeta::default(),
|
||||
crate::db::ItemMeta::named("Back"),
|
||||
);
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
@@ -517,7 +517,7 @@ mod tests {
|
||||
"9907_story_idem",
|
||||
"2_current",
|
||||
content,
|
||||
crate::db::ItemMeta::default(),
|
||||
crate::db::ItemMeta::named("Idem"),
|
||||
);
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
|
||||
@@ -177,14 +177,12 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
|
||||
// --- Metadata (story 929: CRDT-first, yaml_residue marks gaps) ---
|
||||
let mut front_matter = serde_json::Map::new();
|
||||
if let Some(view) = crate::crdt_state::read_item(story_id) {
|
||||
if let Some(name) = view.name() {
|
||||
front_matter.insert("name".to_string(), json!(name));
|
||||
}
|
||||
front_matter.insert("name".to_string(), json!(view.name()));
|
||||
if let Some(agent) = view.agent() {
|
||||
front_matter.insert("agent".to_string(), json!(agent));
|
||||
}
|
||||
if let Some(qa) = view.qa_mode() {
|
||||
front_matter.insert("qa".to_string(), json!(qa));
|
||||
front_matter.insert("qa".to_string(), json!(qa.as_str()));
|
||||
}
|
||||
let rc = view.retry_count();
|
||||
if rc > 0 {
|
||||
@@ -194,15 +192,9 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
|
||||
if !deps.is_empty() {
|
||||
front_matter.insert("depends_on".to_string(), json!(deps));
|
||||
}
|
||||
if let Some(cb) = view.claimed_by()
|
||||
&& !cb.is_empty()
|
||||
{
|
||||
front_matter.insert("claimed_by".to_string(), json!(cb));
|
||||
}
|
||||
if let Some(ca) = view.claimed_at()
|
||||
&& ca > 0.0
|
||||
{
|
||||
front_matter.insert("claimed_at".to_string(), json!(ca));
|
||||
if let Some(claim) = view.claim() {
|
||||
front_matter.insert("claimed_by".to_string(), json!(claim.node));
|
||||
front_matter.insert("claimed_at".to_string(), json!(claim.at));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -464,7 +464,7 @@ mod tests {
|
||||
"9901_bug_crash",
|
||||
"1_backlog",
|
||||
content,
|
||||
crate::db::ItemMeta::default(),
|
||||
crate::db::ItemMeta::named("Crash"),
|
||||
);
|
||||
// Stage the file so it's tracked
|
||||
std::process::Command::new("git")
|
||||
|
||||
@@ -37,8 +37,7 @@ pub(crate) fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<Str
|
||||
let contents = crate::http::workflow::read_story_content(&root, story_id)
|
||||
.map_err(|_| format!("Story file not found: {story_id}.md"))?;
|
||||
|
||||
let story_name =
|
||||
crate::crdt_state::read_item(story_id).and_then(|v| v.name().map(str::to_string));
|
||||
let story_name = crate::crdt_state::read_item(story_id).map(|v| v.name().to_string());
|
||||
let todos = parse_unchecked_todos(&contents);
|
||||
|
||||
serde_json::to_string_pretty(&json!({
|
||||
|
||||
@@ -61,7 +61,8 @@ pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result<String, String> {
|
||||
continue;
|
||||
};
|
||||
|
||||
if view.item_type() == Some("epic") {
|
||||
use crate::io::story_metadata::ItemType;
|
||||
if view.item_type() == Some(ItemType::Epic) {
|
||||
epics.push((sid.clone(), item.name.clone()));
|
||||
}
|
||||
|
||||
@@ -110,13 +111,17 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
||||
let epic_view = crate::crdt_state::read_item(epic_id)
|
||||
.ok_or_else(|| format!("Epic '{epic_id}' not found in CRDT"))?;
|
||||
|
||||
if epic_view.item_type() != Some("epic") {
|
||||
use crate::io::story_metadata::ItemType;
|
||||
if epic_view.item_type() != Some(ItemType::Epic) {
|
||||
return Err(format!(
|
||||
"'{epic_id}' is not an epic (item_type: {:?})",
|
||||
epic_view.item_type()
|
||||
));
|
||||
}
|
||||
|
||||
// Parse the epic_id argument to a numeric EpicId for comparison.
|
||||
let epic_numeric = crate::crdt_state::EpicId::from_crdt_str(epic_id);
|
||||
|
||||
// Find member items.
|
||||
let all_items = crate::pipeline_state::read_all_typed();
|
||||
let mut member_items: Vec<Value> = Vec::new();
|
||||
@@ -126,7 +131,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
||||
let Some(member_view) = crate::crdt_state::read_item(sid) else {
|
||||
continue;
|
||||
};
|
||||
if member_view.epic() == Some(epic_id) {
|
||||
if member_view.epic() == epic_numeric {
|
||||
// Story 945: Frozen / ReviewHold / MergeFailureFinal are first-class
|
||||
// Stage variants — no more orthogonal boolean flags.
|
||||
let stage_name = match &item.stage {
|
||||
@@ -238,14 +243,18 @@ mod tests {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
|
||||
use crate::crdt_state::EpicId;
|
||||
use crate::io::story_metadata::ItemType;
|
||||
|
||||
// Write a fake epic with the typed CRDT registers (story 933).
|
||||
// Epics use numeric-only story_ids (see create_epic_file).
|
||||
crate::db::write_item_with_content(
|
||||
"9990_epic_rollup",
|
||||
"9990",
|
||||
"1_backlog",
|
||||
"# Rollup Epic\n\n## Goal\n\nTest\n",
|
||||
crate::db::ItemMeta::named("Rollup Epic"),
|
||||
);
|
||||
crate::crdt_state::set_item_type("9990_epic_rollup", Some("epic"));
|
||||
crate::crdt_state::set_item_type("9990", Some(ItemType::Epic));
|
||||
|
||||
// Write two member items: one done, one current.
|
||||
crate::db::write_item_with_content(
|
||||
@@ -254,8 +263,8 @@ mod tests {
|
||||
"# Done Member\n",
|
||||
crate::db::ItemMeta::named("Done Member"),
|
||||
);
|
||||
crate::crdt_state::set_item_type("9991_story_member_done", Some("story"));
|
||||
crate::crdt_state::set_epic("9991_story_member_done", Some("9990_epic_rollup"));
|
||||
crate::crdt_state::set_item_type("9991_story_member_done", Some(ItemType::Story));
|
||||
crate::crdt_state::set_epic("9991_story_member_done", Some(EpicId(9990)));
|
||||
|
||||
crate::db::write_item_with_content(
|
||||
"9992_story_member_current",
|
||||
@@ -263,8 +272,8 @@ mod tests {
|
||||
"# Current Member\n",
|
||||
crate::db::ItemMeta::named("Current Member"),
|
||||
);
|
||||
crate::crdt_state::set_item_type("9992_story_member_current", Some("story"));
|
||||
crate::crdt_state::set_epic("9992_story_member_current", Some("9990_epic_rollup"));
|
||||
crate::crdt_state::set_item_type("9992_story_member_current", Some(ItemType::Story));
|
||||
crate::crdt_state::set_epic("9992_story_member_current", Some(EpicId(9990)));
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = crate::http::test_helpers::test_ctx(tmp.path());
|
||||
@@ -272,7 +281,7 @@ mod tests {
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
let epic = parsed
|
||||
.iter()
|
||||
.find(|e| e["epic_id"] == "9990_epic_rollup")
|
||||
.find(|e| e["epic_id"] == "9990")
|
||||
.expect("expected rollup epic in list");
|
||||
assert_eq!(epic["members_total"], 2, "two members expected");
|
||||
assert_eq!(epic["members_done"], 1, "one done member expected");
|
||||
|
||||
@@ -230,7 +230,7 @@ mod tests {
|
||||
"51_story_no_branch",
|
||||
"2_current",
|
||||
content,
|
||||
crate::db::ItemMeta::default(),
|
||||
crate::db::ItemMeta::named("No Branch"),
|
||||
);
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
@@ -246,6 +246,9 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
// Story 946 AC 5: nameless items are invisible at the CRDT layer.
|
||||
// `extract_item_view` returns `None` for items with no name register set,
|
||||
// so they are filtered from all read paths including `validate_story_dirs`.
|
||||
crate::db::write_item_with_content(
|
||||
"9908_test",
|
||||
"2_current",
|
||||
@@ -256,10 +259,9 @@ mod tests {
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_validate_stories(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
let item = parsed
|
||||
.iter()
|
||||
.find(|v| v["story_id"] == "9908_test")
|
||||
.expect("expected 9908_test in validation results");
|
||||
assert_eq!(item["valid"], false);
|
||||
assert!(
|
||||
parsed.iter().all(|v| v["story_id"] != "9908_test"),
|
||||
"nameless items must be invisible to tool_validate_stories"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
|
||||
crate::crdt_state::set_agent(story_id, Some(agent));
|
||||
}
|
||||
if let Some(epic) = args.get("epic").and_then(|v| v.as_str()) {
|
||||
crate::crdt_state::set_epic(story_id, Some(epic).filter(|s| !s.is_empty()));
|
||||
crate::crdt_state::set_epic(story_id, crate::crdt_state::EpicId::from_crdt_str(epic));
|
||||
}
|
||||
|
||||
if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) {
|
||||
@@ -52,12 +52,16 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
|
||||
crate::crdt_state::set_qa_mode(story_id, mode);
|
||||
}
|
||||
"epic" => {
|
||||
let s = value.as_str().filter(|s| !s.is_empty());
|
||||
crate::crdt_state::set_epic(story_id, s);
|
||||
let parsed = value
|
||||
.as_str()
|
||||
.and_then(crate::crdt_state::EpicId::from_crdt_str);
|
||||
crate::crdt_state::set_epic(story_id, parsed);
|
||||
}
|
||||
"type" => {
|
||||
let s = value.as_str().filter(|s| !s.is_empty());
|
||||
crate::crdt_state::set_item_type(story_id, s);
|
||||
let parsed = value
|
||||
.as_str()
|
||||
.and_then(crate::io::story_metadata::ItemType::from_str);
|
||||
crate::crdt_state::set_item_type(story_id, parsed);
|
||||
}
|
||||
"depends_on" => {
|
||||
if let Some(arr) = value.as_array() {
|
||||
|
||||
@@ -77,9 +77,8 @@ pub(super) fn is_bug_item(stem: &str) -> bool {
|
||||
}
|
||||
if after_num.is_empty() {
|
||||
return crate::crdt_state::read_item(stem)
|
||||
.and_then(|v| v.item_type().map(str::to_string))
|
||||
.map(|t| t == "bug")
|
||||
.unwrap_or(false);
|
||||
.and_then(|v| v.item_type())
|
||||
.is_some_and(|t| t == crate::io::story_metadata::ItemType::Bug);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ pub fn create_epic_file(
|
||||
write_story_content(root, &epic_id, "1_backlog", &content, Some(name));
|
||||
|
||||
// Story 933: typed CRDT register for item_type.
|
||||
crate::crdt_state::set_item_type(&epic_id, Some("epic"));
|
||||
crate::crdt_state::set_item_type(&epic_id, Some(crate::io::story_metadata::ItemType::Epic));
|
||||
|
||||
Ok(epic_id)
|
||||
}
|
||||
|
||||
@@ -71,9 +71,8 @@ pub(super) fn is_refactor_item(stem: &str) -> bool {
|
||||
}
|
||||
if after_num.is_empty() {
|
||||
return crate::crdt_state::read_item(stem)
|
||||
.and_then(|v| v.item_type().map(str::to_string))
|
||||
.map(|t| t == "refactor")
|
||||
.unwrap_or(false);
|
||||
.and_then(|v| v.item_type())
|
||||
.is_some_and(|t| t == crate::io::story_metadata::ItemType::Refactor);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
|
||||
|
||||
let spike_id = result.unwrap();
|
||||
let view = crate::crdt_state::read_item(&spike_id).expect("CRDT entry should exist");
|
||||
assert_eq!(view.name(), Some(name));
|
||||
assert_eq!(view.name(), name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -103,8 +103,10 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let qa = view.as_ref().and_then(|v| v.qa_mode().map(str::to_string));
|
||||
let epic_id = view.as_ref().and_then(|v| v.epic().map(str::to_string));
|
||||
let qa = view
|
||||
.as_ref()
|
||||
.and_then(|v| v.qa_mode().map(|q| q.as_str().to_string()));
|
||||
let epic_id = view.as_ref().and_then(|v| v.epic().map(|e| e.to_string()));
|
||||
let merge_failure = crate::crdt_state::read_merge_job(sid).and_then(|j| j.error);
|
||||
|
||||
let story = UpcomingStory {
|
||||
@@ -219,7 +221,7 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
|
||||
.map(|item| {
|
||||
let sid = &item.story_id.0;
|
||||
let epic_id =
|
||||
crate::crdt_state::read_item(sid).and_then(|v| v.epic().map(str::to_string));
|
||||
crate::crdt_state::read_item(sid).and_then(|v| v.epic().map(|e| e.to_string()));
|
||||
UpcomingStory {
|
||||
story_id: item.story_id.0.clone(),
|
||||
name: if item.name.is_empty() {
|
||||
@@ -265,34 +267,25 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
|
||||
///
|
||||
/// Story 929: validation reads the typed CRDT `name` register; the legacy YAML
|
||||
/// front-matter parse is gone.
|
||||
///
|
||||
/// Story 946: nameless items are filtered at the CRDT layer (`extract_item_view`
|
||||
/// returns `None` for items with no name register set) and therefore never reach
|
||||
/// this function. Every item in `read_all_typed()` is guaranteed to have a
|
||||
/// non-empty name, so the only validation left here is stage filtering.
|
||||
pub fn validate_story_dirs(_root: &Path) -> Result<Vec<StoryValidationResult>, String> {
|
||||
use crate::pipeline_state::Stage;
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
let typed_items = crate::pipeline_state::read_all_typed();
|
||||
for item in typed_items {
|
||||
for item in crate::pipeline_state::read_all_typed() {
|
||||
if !matches!(item.stage, Stage::Backlog | Stage::Coding) {
|
||||
continue;
|
||||
}
|
||||
let story_id = item.story_id.0.clone();
|
||||
let name = crate::crdt_state::read_item(&story_id)
|
||||
.and_then(|v| v.name().map(str::to_string))
|
||||
.filter(|s| !s.is_empty());
|
||||
|
||||
if name.is_some() {
|
||||
results.push(StoryValidationResult {
|
||||
story_id,
|
||||
valid: true,
|
||||
error: None,
|
||||
});
|
||||
} else {
|
||||
results.push(StoryValidationResult {
|
||||
story_id,
|
||||
valid: false,
|
||||
error: Some("Missing 'name' field".to_string()),
|
||||
});
|
||||
}
|
||||
results.push(StoryValidationResult {
|
||||
story_id: item.story_id.0.clone(),
|
||||
valid: true,
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||
@@ -591,12 +584,13 @@ mod tests {
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let results = validate_story_dirs(tmp.path()).unwrap();
|
||||
let r = results
|
||||
.iter()
|
||||
.find(|r| r.story_id == "9875_story_no_fm")
|
||||
.unwrap();
|
||||
assert!(!r.valid);
|
||||
assert_eq!(r.error.as_deref(), Some("Missing 'name' field"));
|
||||
// Story 946: nameless items are invisible at the CRDT layer (AC 5).
|
||||
// `extract_item_view` returns `None` for items with no name register,
|
||||
// so they never surface to `validate_story_dirs`.
|
||||
assert!(
|
||||
results.iter().all(|r| r.story_id != "9875_story_no_fm"),
|
||||
"nameless items must be invisible to validate_story_dirs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -611,13 +605,11 @@ mod tests {
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let results = validate_story_dirs(tmp.path()).unwrap();
|
||||
let r = results
|
||||
.iter()
|
||||
.find(|r| r.story_id == "9876_story_no_name")
|
||||
.unwrap();
|
||||
assert!(!r.valid);
|
||||
let err = r.error.as_deref().unwrap();
|
||||
assert!(err.contains("Missing 'name' field"));
|
||||
// Story 946: nameless items are invisible at the CRDT layer (AC 5).
|
||||
assert!(
|
||||
results.iter().all(|r| r.story_id != "9876_story_no_name"),
|
||||
"nameless items must be invisible to validate_story_dirs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -185,7 +185,7 @@ mod tests {
|
||||
let story_id = result.unwrap();
|
||||
let view =
|
||||
crate::crdt_state::read_item(&story_id).expect("CRDT entry should exist after create");
|
||||
assert_eq!(view.name(), Some(name));
|
||||
assert_eq!(view.name(), name);
|
||||
}
|
||||
|
||||
// ── check_criterion_in_file tests ─────────────────────────────────────────
|
||||
@@ -242,7 +242,7 @@ mod tests {
|
||||
let view = crate::crdt_state::read_item(&story_id).expect("CRDT entry must exist");
|
||||
assert_eq!(
|
||||
view.item_type(),
|
||||
Some("story"),
|
||||
Some(crate::io::story_metadata::ItemType::Story),
|
||||
"CRDT register must be set to story"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,7 +435,10 @@ mod tests {
|
||||
setup_story_in_fs(tmp.path(), "100_spike_my_spike", spike_content);
|
||||
|
||||
// Convert spike to story by updating the typed item_type CRDT register.
|
||||
crate::crdt_state::set_item_type("100_spike_my_spike", Some("story"));
|
||||
crate::crdt_state::set_item_type(
|
||||
"100_spike_my_spike",
|
||||
Some(crate::io::story_metadata::ItemType::Story),
|
||||
);
|
||||
|
||||
// Add three acceptance criteria.
|
||||
add_criterion_to_file(tmp.path(), "100_spike_my_spike", "First criterion")
|
||||
|
||||
@@ -266,7 +266,10 @@ pub(crate) fn create_item_in_backlog(
|
||||
|
||||
write_story_content(root, &item_id, "1_backlog", &content, Some(name));
|
||||
crate::crdt_state::set_depends_on(&item_id, depends_on.unwrap_or(&[]));
|
||||
crate::crdt_state::set_item_type(&item_id, Some(item_type));
|
||||
crate::crdt_state::set_item_type(
|
||||
&item_id,
|
||||
crate::io::story_metadata::ItemType::from_str(item_type),
|
||||
);
|
||||
|
||||
Ok(item_id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user