7d7ab85994
Replaces the YAML-only `type: epic` / `epic: <id>` front-matter fields with typed CRDT registers on PipelineItemCrdt. The epic-mechanism MCP tools (`tool_list_epics`, `tool_show_epic`), the epic-context injection in agent spawn, and the type-classifier helpers (`item_type_from_id`, `is_bug_item`, `is_refactor_item`) now all read from the CRDT. Schema: - PipelineItemCrdt: `item_type: LwwRegisterCrdt<String>` and `epic: LwwRegisterCrdt<String>` registers. - WorkItem: typed `item_type()` and `epic()` accessors returning `Option<&str>`. - crdt_state::set_item_type(story_id, Option<&str>) and crdt_state::set_epic(story_id, Option<&str>) typed setters. Write paths populate the new registers: - create_story_file / create_bug_file / create_spike_file / create_refactor_file / create_epic_file — each calls set_item_type after write_story_content. - tool_update_story intercepts `epic` and `type` fields and routes them to the typed setters (same pattern as qa / depends_on). Read paths migrated off yaml_legacy: - http/mcp/story_tools/epic.rs: tool_list_epics + tool_show_epic. - agents/lifecycle.rs::item_type_from_id (numeric-only IDs). - agents/pool/start/spawn.rs epic-context injection. - http/workflow/bug_ops/bug.rs::is_bug_item, refactor.rs::is_refactor_item. - http/workflow/pipeline.rs::load_pipeline_state — review_hold/qa/epic_id all come from the CRDT now; only merge_failure is still YAML (sweep in 929 stage 10). All `yaml_residue(...)` wraps for item_type / epic are removed; the remaining residue marker doc no longer references 933. cargo fmt --check, clippy --all-targets -- -D warnings, and the 2857-test suite all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.5 KiB
Rust
139 lines
4.5 KiB
Rust
//! Bug-item creation and listing operations.
|
|
|
|
use crate::db::yaml_legacy::parse_front_matter;
|
|
use std::path::Path;
|
|
|
|
use super::super::{next_item_number, slugify_name, write_story_content};
|
|
|
|
/// Create a bug file and store it in the database.
|
|
///
|
|
/// Also writes to the filesystem for backwards compatibility during migration.
|
|
/// Returns the bug_id (e.g. `"4"`).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn create_bug_file(
|
|
root: &Path,
|
|
name: &str,
|
|
description: &str,
|
|
steps_to_reproduce: &str,
|
|
actual_result: &str,
|
|
expected_result: &str,
|
|
acceptance_criteria: Option<&[String]>,
|
|
depends_on: Option<&[u32]>,
|
|
) -> Result<String, String> {
|
|
let bug_number = next_item_number(root)?;
|
|
let slug = slugify_name(name);
|
|
|
|
if slug.is_empty() {
|
|
return Err("Name must contain at least one alphanumeric character.".to_string());
|
|
}
|
|
|
|
let bug_id = format!("{bug_number}");
|
|
|
|
let mut content = String::new();
|
|
content.push_str("---\n");
|
|
content.push_str("type: bug\n");
|
|
content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\"")));
|
|
if let Some(deps) = depends_on.filter(|d| !d.is_empty()) {
|
|
let nums: Vec<String> = deps.iter().map(|n| n.to_string()).collect();
|
|
content.push_str(&format!("depends_on: [{}]\n", nums.join(", ")));
|
|
}
|
|
content.push_str("---\n\n");
|
|
content.push_str(&format!("# Bug {bug_number}: {name}\n\n"));
|
|
content.push_str("## Description\n\n");
|
|
content.push_str(description);
|
|
content.push_str("\n\n");
|
|
content.push_str("## How to Reproduce\n\n");
|
|
content.push_str(steps_to_reproduce);
|
|
content.push_str("\n\n");
|
|
content.push_str("## Actual Result\n\n");
|
|
content.push_str(actual_result);
|
|
content.push_str("\n\n");
|
|
content.push_str("## Expected Result\n\n");
|
|
content.push_str(expected_result);
|
|
content.push_str("\n\n");
|
|
content.push_str("## Acceptance Criteria\n\n");
|
|
if let Some(criteria) = acceptance_criteria {
|
|
for criterion in criteria {
|
|
content.push_str(&format!("- [ ] {criterion}\n"));
|
|
}
|
|
} else {
|
|
content.push_str("- [ ] Bug is fixed and verified\n");
|
|
}
|
|
|
|
// Write to database content store and CRDT.
|
|
write_story_content(root, &bug_id, "1_backlog", &content);
|
|
|
|
// Sync depends_on to the typed CRDT register.
|
|
crate::crdt_state::set_depends_on(&bug_id, depends_on.unwrap_or(&[]));
|
|
|
|
// Story 933: typed CRDT register for item_type.
|
|
crate::crdt_state::set_item_type(&bug_id, Some("bug"));
|
|
|
|
Ok(bug_id)
|
|
}
|
|
|
|
/// Returns true if the item stem is a bug item.
|
|
///
|
|
/// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then
|
|
/// consults the typed CRDT `item_type` register for numeric-only IDs (story 933).
|
|
pub(super) fn is_bug_item(stem: &str) -> bool {
|
|
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
|
|
if after_num.starts_with("_bug_") {
|
|
return true;
|
|
}
|
|
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);
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Extract bug name from content (heading or front matter).
|
|
#[allow(clippy::string_slice)] // colon_pos from find(": "); +2 skips ASCII ": " → valid boundary
|
|
pub(super) fn extract_bug_name_from_content(content: &str) -> Option<String> {
|
|
// Try front matter first.
|
|
if let Ok(meta) = parse_front_matter(content)
|
|
&& let Some(name) = meta.name
|
|
{
|
|
return Some(name);
|
|
}
|
|
// Fallback: heading.
|
|
for line in content.lines() {
|
|
if let Some(rest) = line.strip_prefix("# Bug ")
|
|
&& let Some(colon_pos) = rest.find(": ")
|
|
{
|
|
return Some(rest[colon_pos + 2..].to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// List all open bugs from CRDT + content store.
|
|
///
|
|
/// Returns a sorted list of `(bug_id, name)` pairs.
|
|
pub fn list_bug_files(_root: &Path) -> Result<Vec<(String, String)>, String> {
|
|
let mut bugs = Vec::new();
|
|
|
|
for item in crate::pipeline_state::read_all_typed() {
|
|
if !matches!(item.stage, crate::pipeline_state::Stage::Backlog)
|
|
|| !is_bug_item(&item.story_id.0)
|
|
{
|
|
continue;
|
|
}
|
|
let sid = item.story_id.0;
|
|
let name = if item.name.is_empty() {
|
|
None
|
|
} else {
|
|
Some(item.name)
|
|
}
|
|
.or_else(|| crate::db::read_content(&sid).and_then(|c| extract_bug_name_from_content(&c)))
|
|
.unwrap_or_else(|| sid.clone());
|
|
bugs.push((sid, name));
|
|
}
|
|
|
|
bugs.sort_by(|a, b| a.0.cmp(&b.0));
|
|
Ok(bugs)
|
|
}
|