huskies: merge 1026

This commit is contained in:
dave
2026-05-14 12:53:14 +00:00
parent a80d0a497a
commit 72d79deec9
13 changed files with 1443 additions and 127 deletions
+50 -52
View File
@@ -6,11 +6,16 @@
use std::path::Path;
use super::super::{next_item_number, slugify_name, write_story_content};
use super::super::create_item_in_backlog;
/// Create an epic file and store it in the database.
/// Create an epic file, storing it in the database via `create_item_in_backlog`.
///
/// Returns the epic_id (e.g. `"880"`).
/// Routes through `create_item_in_backlog` so that the tombstone-skip allocator
/// defence and post-write CRDT verification (added by bug 1001's fix) apply to
/// epics too. Previously this function had its own ad-hoc allocate-and-write
/// path that bypassed those safety checks.
///
/// Returns the epic_id (numeric only, e.g. `"880"`).
pub fn create_epic_file(
root: &Path,
name: &str,
@@ -19,61 +24,54 @@ pub fn create_epic_file(
key_files: Option<&str>,
success_criteria: Option<&[String]>,
) -> Result<String, String> {
let epic_number = next_item_number(root)?;
let slug = slugify_name(name);
let name_owned = name.to_string();
let goal_owned = goal.to_string();
let motivation_owned = motivation.map(str::to_string);
let key_files_owned = key_files.map(str::to_string);
let success_criteria_owned: Vec<String> =
success_criteria.map(|sc| sc.to_vec()).unwrap_or_default();
if slug.is_empty() {
return Err("Name must contain at least one alphanumeric character.".to_string());
}
// Epics don't have acceptance criteria; pass an empty slice.
// create_item_in_backlog skips the AC check for type "epic".
create_item_in_backlog(root, "epic", name, &[], None, move |epic_number| {
let mut content = String::new();
content.push_str("---\n");
content.push_str("type: epic\n");
content.push_str(&format!("name: \"{}\"\n", name_owned.replace('"', "\\\"")));
content.push_str("---\n\n");
content.push_str(&format!("# Epic {epic_number}: {name_owned}\n\n"));
let epic_id = format!("{epic_number}");
content.push_str("## Goal\n\n");
content.push_str(&goal_owned);
content.push_str("\n\n");
let mut content = String::new();
content.push_str("---\n");
content.push_str("type: epic\n");
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
content.push_str("---\n\n");
content.push_str(&format!("# Epic {epic_number}: {name}\n\n"));
content.push_str("## Goal\n\n");
content.push_str(goal);
content.push_str("\n\n");
content.push_str("## Motivation\n\n");
if let Some(m) = motivation {
content.push_str(m);
content.push('\n');
} else {
content.push_str("- TBD\n");
}
content.push('\n');
content.push_str("## Key Files\n\n");
if let Some(kf) = key_files {
content.push_str(kf);
content.push('\n');
} else {
content.push_str("- TBD\n");
}
content.push('\n');
content.push_str("## Success Criteria\n\n");
match success_criteria {
Some(criteria) if !criteria.is_empty() => {
for c in criteria {
content.push_str(&format!("- {c}\n"));
}
}
_ => {
content.push_str("## Motivation\n\n");
if let Some(ref m) = motivation_owned {
content.push_str(m);
content.push('\n');
} else {
content.push_str("- TBD\n");
}
}
content.push('\n');
// Epics are stored in backlog (no pipeline advancement).
write_story_content(root, &epic_id, "1_backlog", &content, Some(name));
content.push_str("## Key Files\n\n");
if let Some(ref kf) = key_files_owned {
content.push_str(kf);
content.push('\n');
} else {
content.push_str("- TBD\n");
}
content.push('\n');
// Story 933: typed CRDT register for item_type.
crate::crdt_state::set_item_type(&epic_id, Some(crate::io::story_metadata::ItemType::Epic));
content.push_str("## Success Criteria\n\n");
if !success_criteria_owned.is_empty() {
for c in &success_criteria_owned {
content.push_str(&format!("- {c}\n"));
}
} else {
content.push_str("- TBD\n");
}
Ok(epic_id)
content
})
}
+7 -5
View File
@@ -250,15 +250,17 @@ pub(crate) fn create_item_in_backlog(
if slugify_name(name).is_empty() {
return Err("Title must contain at least one alphanumeric character.".to_string());
}
if acceptance_criteria.is_empty() {
return Err("At least one acceptance criterion is required.".to_string());
}
const VALID_TYPES: &[&str] = &["story", "bug", "spike", "refactor"];
const VALID_TYPES: &[&str] = &["story", "bug", "spike", "refactor", "epic"];
if !VALID_TYPES.contains(&item_type) {
return Err(format!(
"Invalid item type '{item_type}': must be one of story, bug, spike, refactor."
"Invalid item type '{item_type}': must be one of story, bug, spike, refactor, epic."
));
}
// Epics use success_criteria (optional); the acceptance_criteria check is
// only meaningful for pipeline work items.
if item_type != "epic" && acceptance_criteria.is_empty() {
return Err("At least one acceptance criterion is required.".to_string());
}
let item_number = next_item_number(root)?;
let item_id = format!("{item_number}");