huskies: merge 865
This commit is contained in:
@@ -10,7 +10,7 @@ use std::num::NonZeroU32;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use crate::io::story_metadata::clear_front_matter_field_in_content;
|
use crate::db::yaml_legacy::clear_front_matter_field_in_content;
|
||||||
use crate::pipeline_state::{
|
use crate::pipeline_state::{
|
||||||
ApplyError, ArchiveReason, BranchName, GitSha, PipelineEvent, Stage, apply_transition,
|
ApplyError, ArchiveReason, BranchName, GitSha, PipelineEvent, Stage, apply_transition,
|
||||||
stage_label,
|
stage_label,
|
||||||
@@ -34,7 +34,7 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
|
|||||||
// Numeric-only ID: check content store front matter for explicit type.
|
// Numeric-only ID: check content store front matter for explicit type.
|
||||||
if after_num.is_empty()
|
if after_num.is_empty()
|
||||||
&& let Some(content) = crate::db::read_content(item_id)
|
&& let Some(content) = crate::db::read_content(item_id)
|
||||||
&& let Ok(meta) = crate::io::story_metadata::parse_front_matter(&content)
|
&& let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&content)
|
||||||
&& let Some(t) = meta.item_type.as_deref()
|
&& let Some(t) = meta.item_type.as_deref()
|
||||||
{
|
{
|
||||||
return match t {
|
return match t {
|
||||||
@@ -214,7 +214,7 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> {
|
|||||||
let mut result = clear_front_matter_field_in_content(content, "review_hold");
|
let mut result = clear_front_matter_field_in_content(content, "review_hold");
|
||||||
if !notes_owned.is_empty() {
|
if !notes_owned.is_empty() {
|
||||||
result =
|
result =
|
||||||
crate::io::story_metadata::write_rejection_notes_to_content(&result, ¬es_owned);
|
crate::db::yaml_legacy::write_rejection_notes_to_content(&result, ¬es_owned);
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
});
|
});
|
||||||
@@ -257,7 +257,7 @@ pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String>
|
|||||||
pub fn transition_to_merge_failure(story_id: &str, reason: &str) -> Result<(), String> {
|
pub fn transition_to_merge_failure(story_id: &str, reason: &str) -> Result<(), String> {
|
||||||
let reason_owned = reason.to_string();
|
let reason_owned = reason.to_string();
|
||||||
let transform: Box<dyn Fn(&str) -> String> = Box::new(move |content: &str| {
|
let transform: Box<dyn Fn(&str) -> String> = Box::new(move |content: &str| {
|
||||||
crate::io::story_metadata::write_merge_failure_in_content(content, &reason_owned)
|
crate::db::yaml_legacy::write_merge_failure_in_content(content, &reason_owned)
|
||||||
});
|
});
|
||||||
apply_transition(
|
apply_transition(
|
||||||
story_id,
|
story_id,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ impl AgentPool {
|
|||||||
/// logged so the user can see the promotion was triggered by an archived dep, not
|
/// logged so the user can see the promotion was triggered by an archived dep, not
|
||||||
/// a clean completion.
|
/// a clean completion.
|
||||||
pub(super) fn promote_ready_backlog_stories(&self, project_root: &Path) {
|
pub(super) fn promote_ready_backlog_stories(&self, project_root: &Path) {
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
|
|
||||||
let items = scan_stage_items(project_root, "1_backlog");
|
let items = scan_stage_items(project_root, "1_backlog");
|
||||||
for story_id in &items {
|
for story_id in &items {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ impl AgentPool {
|
|||||||
// crash/restart doesn't re-trigger an infinite loop.
|
// crash/restart doesn't re-trigger an infinite loop.
|
||||||
if let Some(contents) = crate::db::read_content(story_id) {
|
if let Some(contents) = crate::db::read_content(story_id) {
|
||||||
let updated =
|
let updated =
|
||||||
crate::io::story_metadata::write_mergemaster_attempted_in_content(
|
crate::db::yaml_legacy::write_mergemaster_attempted_in_content(
|
||||||
&contents,
|
&contents,
|
||||||
);
|
);
|
||||||
crate::db::write_content(story_id, &updated);
|
crate::db::write_content(story_id, &updated);
|
||||||
|
|||||||
@@ -159,10 +159,7 @@ impl AgentPool {
|
|||||||
let default_qa = crate::config::ProjectConfig::load(project_root)
|
let default_qa = crate::config::ProjectConfig::load(project_root)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.default_qa_mode();
|
.default_qa_mode();
|
||||||
let story_path = project_root
|
crate::io::story_metadata::resolve_qa_mode(story_id, default_qa)
|
||||||
.join(".huskies/work/2_current")
|
|
||||||
.join(format!("{story_id}.md"));
|
|
||||||
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,9 +218,7 @@ impl AgentPool {
|
|||||||
let story_path = project_root
|
let story_path = project_root
|
||||||
.join(".huskies/work/3_qa")
|
.join(".huskies/work/3_qa")
|
||||||
.join(format!("{story_id}.md"));
|
.join(format!("{story_id}.md"));
|
||||||
if let Err(e) =
|
if let Err(e) = crate::db::yaml_legacy::write_review_hold(&story_path) {
|
||||||
crate::io::story_metadata::write_review_hold(&story_path)
|
|
||||||
{
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
||||||
);
|
);
|
||||||
@@ -278,14 +273,11 @@ impl AgentPool {
|
|||||||
if item_type == "spike" {
|
if item_type == "spike" {
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
let story_path = project_root
|
|
||||||
.join(".huskies/work/3_qa")
|
|
||||||
.join(format!("{story_id}.md"));
|
|
||||||
let default_qa = crate::config::ProjectConfig::load(project_root)
|
let default_qa = crate::config::ProjectConfig::load(project_root)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.default_qa_mode();
|
.default_qa_mode();
|
||||||
matches!(
|
matches!(
|
||||||
crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa),
|
crate::io::story_metadata::resolve_qa_mode(story_id, default_qa),
|
||||||
crate::io::story_metadata::QaMode::Human
|
crate::io::story_metadata::QaMode::Human
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -295,7 +287,7 @@ impl AgentPool {
|
|||||||
let story_path = project_root
|
let story_path = project_root
|
||||||
.join(".huskies/work/3_qa")
|
.join(".huskies/work/3_qa")
|
||||||
.join(format!("{story_id}.md"));
|
.join(format!("{story_id}.md"));
|
||||||
if let Err(e) = crate::io::story_metadata::write_review_hold(&story_path) {
|
if let Err(e) = crate::db::yaml_legacy::write_review_hold(&story_path) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ pub(super) fn read_story_front_matter_agent(
|
|||||||
{
|
{
|
||||||
return Some(agent.clone());
|
return Some(agent.clone());
|
||||||
}
|
}
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
let contents = read_story_contents(project_root, story_id)?;
|
let contents = read_story_contents(project_root, story_id)?;
|
||||||
parse_front_matter(&contents).ok()?.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.
|
/// 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 {
|
pub(super) fn has_review_hold(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
let contents = match read_story_contents(project_root, story_id) {
|
let contents = match read_story_contents(project_root, story_id) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return false,
|
None => return false,
|
||||||
@@ -52,7 +52,7 @@ pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Legacy fallback: check front-matter field for backward compatibility.
|
// Legacy fallback: check front-matter field for backward compatibility.
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
let contents = match read_story_contents(project_root, story_id) {
|
let contents = match read_story_contents(project_root, story_id) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return false,
|
None => return false,
|
||||||
@@ -122,7 +122,7 @@ pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id:
|
|||||||
|
|
||||||
/// Return `true` if the story file has a `merge_failure` field in its front matter.
|
/// 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 {
|
pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
let contents = match read_story_contents(project_root, story_id) {
|
let contents = match read_story_contents(project_root, story_id) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return false,
|
None => return false,
|
||||||
@@ -142,7 +142,7 @@ pub(super) fn has_content_conflict_failure(
|
|||||||
_stage_dir: &str,
|
_stage_dir: &str,
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
let contents = match read_story_contents(project_root, story_id) {
|
let contents = match read_story_contents(project_root, story_id) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return false,
|
None => return false,
|
||||||
@@ -163,7 +163,7 @@ pub(super) fn has_mergemaster_attempted(
|
|||||||
_stage_dir: &str,
|
_stage_dir: &str,
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
let contents = match read_story_contents(project_root, story_id) {
|
let contents = match read_story_contents(project_root, story_id) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => return false,
|
None => return false,
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ pub(super) fn resolve_qa_mode_from_store(
|
|||||||
/// Write review_hold to the content store.
|
/// Write review_hold to the content store.
|
||||||
pub(super) fn write_review_hold_to_store(story_id: &str) {
|
pub(super) fn write_review_hold_to_store(story_id: &str) {
|
||||||
if let Some(contents) = crate::db::read_content(story_id) {
|
if let Some(contents) = crate::db::read_content(story_id) {
|
||||||
let updated = crate::io::story_metadata::write_review_hold_in_content(&contents);
|
let updated = crate::db::yaml_legacy::write_review_hold_in_content(&contents);
|
||||||
crate::db::write_content(story_id, &updated);
|
crate::db::write_content(story_id, &updated);
|
||||||
// Also persist to SQLite via shadow write.
|
// Also persist to SQLite via shadow write.
|
||||||
let stage = crate::pipeline_state::read_typed(story_id)
|
let stage = crate::pipeline_state::read_typed(story_id)
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ pub(super) async fn run_agent_spawn(
|
|||||||
// content and prepend it to the system prompt so the agent treats it as
|
// content and prepend it to the system prompt so the agent treats it as
|
||||||
// authoritative context.
|
// authoritative context.
|
||||||
if let Some(story_content) = crate::db::read_content(&sid)
|
if let Some(story_content) = crate::db::read_content(&sid)
|
||||||
&& let Ok(meta) = crate::io::story_metadata::parse_front_matter(&story_content)
|
&& let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&story_content)
|
||||||
&& let Some(ref epic_id) = meta.epic
|
&& let Some(ref epic_id) = meta.epic
|
||||||
&& let Some(epic_content) = crate::db::read_content(epic_id)
|
&& let Some(epic_content) = crate::db::read_content(epic_id)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ pub(super) fn read_front_matter_agent(story_id: &str, agent_name: Option<&str>)
|
|||||||
return Some(agent.clone());
|
return Some(agent.clone());
|
||||||
}
|
}
|
||||||
crate::db::read_content(story_id).and_then(|contents| {
|
crate::db::read_content(story_id).and_then(|contents| {
|
||||||
crate::io::story_metadata::parse_front_matter(&contents)
|
crate::db::yaml_legacy::parse_front_matter(&contents)
|
||||||
.ok()?
|
.ok()?
|
||||||
.agent
|
.agent
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! Passing no dependency numbers clears the field entirely.
|
//! Passing no dependency numbers clears the field entirely.
|
||||||
|
|
||||||
use super::CommandContext;
|
use super::CommandContext;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
|
|
||||||
/// Handle the `depends` command.
|
/// Handle the `depends` command.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! advancement and auto-assign until `unfreeze <number>` restores the prior stage.
|
//! advancement and auto-assign until `unfreeze <number>` restores the prior stage.
|
||||||
|
|
||||||
use super::CommandContext;
|
use super::CommandContext;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Handle the `freeze` command.
|
/// Handle the `freeze` command.
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let found_name = content
|
let found_name = content
|
||||||
.and_then(|c| crate::io::story_metadata::parse_front_matter(&c).ok())
|
.and_then(|c| crate::db::yaml_legacy::parse_front_matter(&c).ok())
|
||||||
.and_then(|m| m.name);
|
.and_then(|m| m.name);
|
||||||
|
|
||||||
let display_name = found_name.as_deref().unwrap_or(&story_id);
|
let display_name = found_name.as_deref().unwrap_or(&story_id);
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<Stri
|
|||||||
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||||
let (_, _, _, content) = crate::chat::lookup::find_story_by_number(root, num_str)?;
|
let (_, _, _, content) = crate::chat::lookup::find_story_by_number(root, num_str)?;
|
||||||
let content = content?;
|
let content = content?;
|
||||||
crate::io::story_metadata::parse_front_matter(&content)
|
crate::db::yaml_legacy::parse_front_matter(&content)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.name)
|
.and_then(|m| m.name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ pub(crate) fn build_status_from_items(
|
|||||||
.filter(|i| matches!(i.stage, Stage::Merge { .. }))
|
.filter(|i| matches!(i.stage, Stage::Merge { .. }))
|
||||||
.filter_map(|i| {
|
.filter_map(|i| {
|
||||||
let content = crate::db::read_content(&i.story_id.0)?;
|
let content = crate::db::read_content(&i.story_id.0)?;
|
||||||
let meta = crate::io::story_metadata::parse_front_matter(&content).ok()?;
|
let meta = crate::db::yaml_legacy::parse_front_matter(&content).ok()?;
|
||||||
let mf = meta.merge_failure?;
|
let mf = meta.merge_failure?;
|
||||||
Some((i.story_id.0.clone(), mf))
|
Some((i.story_id.0.clone(), mf))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ fn build_triage_dump(
|
|||||||
None => return format!("Story {num_str}: content not found in content store."),
|
None => return format!("Story {num_str}: content not found in content store."),
|
||||||
};
|
};
|
||||||
|
|
||||||
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
|
let meta = crate::db::yaml_legacy::parse_front_matter(&contents).ok();
|
||||||
let name = meta
|
let name = meta
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| m.name.as_deref())
|
.and_then(|m| m.name.as_deref())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! and returns a confirmation.
|
//! and returns a confirmation.
|
||||||
|
|
||||||
use super::CommandContext;
|
use super::CommandContext;
|
||||||
use crate::io::story_metadata::{clear_front_matter_field_in_content, parse_front_matter};
|
use crate::db::yaml_legacy::{clear_front_matter_field_in_content, parse_front_matter};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Handle the `unblock` command.
|
/// Handle the `unblock` command.
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
|||||||
if file_num == num_str
|
if file_num == num_str
|
||||||
&& let Some(c) = crate::db::read_content(&id)
|
&& let Some(c) = crate::db::read_content(&id)
|
||||||
{
|
{
|
||||||
return crate::io::story_metadata::parse_front_matter(&c)
|
return crate::db::yaml_legacy::parse_front_matter(&c)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.name);
|
.and_then(|m| m.name);
|
||||||
}
|
}
|
||||||
@@ -182,7 +182,7 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
if file_num == num_str {
|
if file_num == num_str {
|
||||||
return std::fs::read_to_string(&path).ok().and_then(|c| {
|
return std::fs::read_to_string(&path).ok().and_then(|c| {
|
||||||
crate::io::story_metadata::parse_front_matter(&c)
|
crate::db::yaml_legacy::parse_front_matter(&c)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.name)
|
.and_then(|m| m.name)
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
use crate::agents::{AgentPool, AgentStatus};
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
use crate::chat::util::strip_bot_mention;
|
use crate::chat::util::strip_bot_mention;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// A parsed assign command from a Matrix message body.
|
/// A parsed assign command from a Matrix message body.
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ pub async fn handle_delete(
|
|||||||
|
|
||||||
let story_name = content
|
let story_name = content
|
||||||
.and_then(|contents| {
|
.and_then(|contents| {
|
||||||
crate::io::story_metadata::parse_front_matter(&contents)
|
crate::db::yaml_legacy::parse_front_matter(&contents)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.name)
|
.and_then(|m| m.name)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ pub async fn handle_start(
|
|||||||
|
|
||||||
let story_name = content
|
let story_name = content
|
||||||
.and_then(|contents| {
|
.and_then(|contents| {
|
||||||
crate::io::story_metadata::parse_front_matter(&contents)
|
crate::db::yaml_legacy::parse_front_matter(&contents)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.name)
|
.and_then(|m| m.name)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ pub mod content_store;
|
|||||||
pub mod ops;
|
pub mod ops;
|
||||||
/// Background shadow-write task — persists pipeline items to SQLite asynchronously.
|
/// Background shadow-write task — persists pipeline items to SQLite asynchronously.
|
||||||
pub mod shadow_write;
|
pub mod shadow_write;
|
||||||
|
/// Legacy YAML helpers — used by the migration and by callers reading the
|
||||||
|
/// small set of fields not yet mirrored into the CRDT.
|
||||||
|
pub(crate) mod yaml_legacy;
|
||||||
|
/// One-shot migration that strips legacy YAML front-matter from stored content (story 865).
|
||||||
|
pub mod yaml_migration;
|
||||||
|
|
||||||
pub use content_store::{all_content_ids, delete_content, read_content, write_content};
|
pub use content_store::{all_content_ids, delete_content, read_content, write_content};
|
||||||
pub use ops::{ItemMeta, delete_item, move_item_stage, next_item_number, write_item_with_content};
|
pub use ops::{ItemMeta, delete_item, move_item_stage, next_item_number, write_item_with_content};
|
||||||
@@ -30,7 +35,7 @@ pub use content_store::ensure_content_store;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
/// Helper: write a minimal story .md file with front matter.
|
/// Helper: write a minimal story .md file with front matter.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use super::content_store::{
|
|||||||
all_content_ids, delete_content, ensure_content_store, read_content, write_content,
|
all_content_ids, delete_content, ensure_content_store, read_content, write_content,
|
||||||
};
|
};
|
||||||
use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg};
|
use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg};
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use super::yaml_legacy::parse_front_matter;
|
||||||
|
|
||||||
/// Typed metadata for a pipeline item write.
|
/// Typed metadata for a pipeline item write.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
//! Legacy YAML front-matter helpers — kept ONLY for the one-shot migration
|
||||||
|
//! and for the small set of fields not yet mirrored into the CRDT
|
||||||
|
//! (`item_type`, `epic`, `review_hold`, `merge_failure` reason text, etc.).
|
||||||
|
//!
|
||||||
|
//! After the migration runs, every body in the content store is YAML-free, so
|
||||||
|
//! every helper here returns `Ok(None)` / a no-op on the next read. Callers
|
||||||
|
//! should treat this module as a deprecated escape hatch — new code should
|
||||||
|
//! read typed CRDT registers instead.
|
||||||
|
|
||||||
|
use crate::io::story_metadata::QaMode;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Front-matter fields used by the legacy `parse_front_matter` API. Mirrors
|
||||||
|
/// the original `io::story_metadata::FrontMatter`.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
pub(crate) struct FrontMatter {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub coverage_baseline: Option<String>,
|
||||||
|
pub merge_failure: Option<String>,
|
||||||
|
pub agent: Option<String>,
|
||||||
|
pub review_hold: Option<bool>,
|
||||||
|
pub qa: Option<String>,
|
||||||
|
pub retry_count: Option<u32>,
|
||||||
|
pub blocked: Option<bool>,
|
||||||
|
pub depends_on: Option<Vec<u32>>,
|
||||||
|
pub frozen: Option<bool>,
|
||||||
|
pub resume_to_stage: Option<String>,
|
||||||
|
pub run_tests_passed: Option<bool>,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub item_type: Option<String>,
|
||||||
|
pub mergemaster_attempted: Option<bool>,
|
||||||
|
pub epic: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed metadata view returned by [`parse_front_matter`].
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct StoryMetadata {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub coverage_baseline: Option<String>,
|
||||||
|
pub merge_failure: Option<String>,
|
||||||
|
pub agent: Option<String>,
|
||||||
|
pub review_hold: Option<bool>,
|
||||||
|
pub qa: Option<QaMode>,
|
||||||
|
pub retry_count: Option<u32>,
|
||||||
|
pub blocked: Option<bool>,
|
||||||
|
pub depends_on: Option<Vec<u32>>,
|
||||||
|
pub frozen: Option<bool>,
|
||||||
|
pub resume_to_stage: Option<String>,
|
||||||
|
pub run_tests_passed: Option<bool>,
|
||||||
|
pub item_type: Option<String>,
|
||||||
|
pub mergemaster_attempted: Option<bool>,
|
||||||
|
pub epic: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors that can occur when parsing legacy YAML front matter.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub(crate) enum StoryMetaError {
|
||||||
|
MissingFrontMatter,
|
||||||
|
InvalidFrontMatter(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for StoryMetaError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::MissingFrontMatter => write!(f, "Missing front matter"),
|
||||||
|
Self::InvalidFrontMatter(m) => write!(f, "Invalid front matter: {m}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the YAML front-matter block from a markdown body.
|
||||||
|
///
|
||||||
|
/// Post-migration this returns `Err(StoryMetaError::MissingFrontMatter)` for
|
||||||
|
/// every body since the front matter has been stripped. Callers that need
|
||||||
|
/// fields not stored in the CRDT (`item_type`, `epic`, …) should treat the
|
||||||
|
/// missing-front-matter case as "default value".
|
||||||
|
pub(crate) fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
||||||
|
let mut lines = contents.lines();
|
||||||
|
let first = lines.next().unwrap_or_default().trim();
|
||||||
|
if first != "---" {
|
||||||
|
return Err(StoryMetaError::MissingFrontMatter);
|
||||||
|
}
|
||||||
|
let mut front_lines = Vec::new();
|
||||||
|
for line in &mut lines {
|
||||||
|
if line.trim() == "---" {
|
||||||
|
let raw = front_lines.join("\n");
|
||||||
|
let front: FrontMatter = serde_yaml::from_str(&raw)
|
||||||
|
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
|
||||||
|
return Ok(StoryMetadata {
|
||||||
|
qa: front.qa.as_deref().and_then(QaMode::from_str),
|
||||||
|
name: front.name,
|
||||||
|
coverage_baseline: front.coverage_baseline,
|
||||||
|
merge_failure: front.merge_failure,
|
||||||
|
agent: front.agent,
|
||||||
|
review_hold: front.review_hold,
|
||||||
|
retry_count: front.retry_count,
|
||||||
|
blocked: front.blocked,
|
||||||
|
depends_on: front.depends_on,
|
||||||
|
frozen: front.frozen,
|
||||||
|
resume_to_stage: front.resume_to_stage,
|
||||||
|
run_tests_passed: front.run_tests_passed,
|
||||||
|
item_type: front.item_type,
|
||||||
|
mergemaster_attempted: front.mergemaster_attempted,
|
||||||
|
epic: front.epic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
front_lines.push(line);
|
||||||
|
}
|
||||||
|
Err(StoryMetaError::InvalidFrontMatter(
|
||||||
|
"Missing closing front matter delimiter".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert or update a `key: value` line in the YAML front matter of a
|
||||||
|
/// markdown string. Returns the input unchanged if no `---` block is found.
|
||||||
|
pub(crate) fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
|
||||||
|
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
||||||
|
if lines.is_empty() || lines[0].trim() != "---" {
|
||||||
|
return contents.to_string();
|
||||||
|
}
|
||||||
|
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
|
||||||
|
Some(i) => i + 1,
|
||||||
|
None => return contents.to_string(),
|
||||||
|
};
|
||||||
|
let key_prefix = format!("{key}:");
|
||||||
|
let existing_idx = lines[1..close_idx]
|
||||||
|
.iter()
|
||||||
|
.position(|l| l.trim_start().starts_with(&key_prefix))
|
||||||
|
.map(|i| i + 1);
|
||||||
|
let new_line = format!("{key}: {value}");
|
||||||
|
if let Some(idx) = existing_idx {
|
||||||
|
lines[idx] = new_line;
|
||||||
|
} else {
|
||||||
|
lines.insert(close_idx, new_line);
|
||||||
|
}
|
||||||
|
let mut result = lines.join("\n");
|
||||||
|
if contents.ends_with('\n') {
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a `key: value` line from the YAML front matter of a markdown string.
|
||||||
|
pub(crate) fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String {
|
||||||
|
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
||||||
|
if lines.is_empty() || lines[0].trim() != "---" {
|
||||||
|
return contents.to_string();
|
||||||
|
}
|
||||||
|
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
|
||||||
|
Some(i) => i + 1,
|
||||||
|
None => return contents.to_string(),
|
||||||
|
};
|
||||||
|
let key_prefix = format!("{key}:");
|
||||||
|
if let Some(idx) = lines[1..close_idx]
|
||||||
|
.iter()
|
||||||
|
.position(|l| l.trim_start().starts_with(&key_prefix))
|
||||||
|
.map(|i| i + 1)
|
||||||
|
{
|
||||||
|
lines.remove(idx);
|
||||||
|
} else {
|
||||||
|
return contents.to_string();
|
||||||
|
}
|
||||||
|
let mut result = lines.join("\n");
|
||||||
|
if contents.ends_with('\n') {
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append rejection notes to a markdown body.
|
||||||
|
pub(crate) fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String {
|
||||||
|
format!("{contents}\n\n## QA Rejection Notes\n\n{notes}\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write or update `merge_failure` in story content.
|
||||||
|
pub(crate) fn write_merge_failure_in_content(contents: &str, reason: &str) -> String {
|
||||||
|
let escaped = reason
|
||||||
|
.replace('"', "\\\"")
|
||||||
|
.replace('\n', " ")
|
||||||
|
.replace('\r', "");
|
||||||
|
set_front_matter_field(contents, "merge_failure", &format!("\"{escaped}\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `review_hold: true` to story content.
|
||||||
|
pub(crate) fn write_review_hold_in_content(contents: &str) -> String {
|
||||||
|
set_front_matter_field(contents, "review_hold", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `mergemaster_attempted: true` to story content.
|
||||||
|
pub(crate) fn write_mergemaster_attempted_in_content(contents: &str) -> String {
|
||||||
|
set_front_matter_field(contents, "mergemaster_attempted", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a key from the YAML front matter of a story file on disk.
|
||||||
|
///
|
||||||
|
/// Legacy filesystem-backed wrapper around
|
||||||
|
/// [`clear_front_matter_field_in_content`] for the small number of callers
|
||||||
|
/// that still read story files directly.
|
||||||
|
pub(crate) fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> {
|
||||||
|
let contents =
|
||||||
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||||
|
let updated = clear_front_matter_field_in_content(&contents, key);
|
||||||
|
if updated != contents {
|
||||||
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `review_hold: true` to the YAML front matter of a story file on disk.
|
||||||
|
///
|
||||||
|
/// Legacy filesystem-backed wrapper around [`write_review_hold_in_content`].
|
||||||
|
pub(crate) fn write_review_hold(path: &Path) -> Result<(), String> {
|
||||||
|
let contents =
|
||||||
|
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||||
|
let updated = write_review_hold_in_content(&contents);
|
||||||
|
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_front_matter_round_trips_basic_fields() {
|
||||||
|
let input = "---\nname: Test\nagent: coder-1\n---\n# Body\n";
|
||||||
|
let meta = parse_front_matter(input).expect("parse");
|
||||||
|
assert_eq!(meta.name.as_deref(), Some("Test"));
|
||||||
|
assert_eq!(meta.agent.as_deref(), Some("coder-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_front_matter_returns_missing_when_no_yaml() {
|
||||||
|
let err = parse_front_matter("# Plain markdown\n").unwrap_err();
|
||||||
|
assert_eq!(err, StoryMetaError::MissingFrontMatter);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_front_matter_field_inserts_when_absent() {
|
||||||
|
let out = set_front_matter_field("---\nname: X\n---\n# B\n", "agent", "coder-1");
|
||||||
|
assert!(out.contains("agent: coder-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_front_matter_field_removes_key() {
|
||||||
|
let out = clear_front_matter_field_in_content(
|
||||||
|
"---\nname: X\nblocked: true\n---\n# B\n",
|
||||||
|
"blocked",
|
||||||
|
);
|
||||||
|
assert!(!out.contains("blocked"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
//! One-shot migration: strip YAML front-matter from stored story content.
|
||||||
|
//!
|
||||||
|
//! Story 865 finishes the move to CRDT registers as the single source of truth
|
||||||
|
//! for pipeline metadata. At server startup we walk every entry in the content
|
||||||
|
//! store, parse its (possibly-present) YAML block one last time, assert that
|
||||||
|
//! every YAML field still agrees with the corresponding CRDT register, and
|
||||||
|
//! rewrite the body without the `---` block. Divergence between YAML and the
|
||||||
|
//! CRDT is logged as ERROR but does not stop the migration — the YAML is
|
||||||
|
//! authoritative-of-history; the CRDT is authoritative-of-truth.
|
||||||
|
//!
|
||||||
|
//! After this runs once (idempotent — a second run finds no YAML headers),
|
||||||
|
//! `parse_front_matter` and friends can be deleted. The parsing logic now
|
||||||
|
//! lives privately in this module so the rest of the codebase can drop the
|
||||||
|
//! YAML helpers entirely.
|
||||||
|
use crate::io::story_metadata::QaMode;
|
||||||
|
use crate::slog;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Snapshot of the legacy YAML front-matter fields we still need to read once
|
||||||
|
/// during the migration. After this run completes, every body in the content
|
||||||
|
/// store is YAML-free and this struct is unused.
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
struct LegacyFrontMatter {
|
||||||
|
name: Option<String>,
|
||||||
|
agent: Option<String>,
|
||||||
|
retry_count: Option<u32>,
|
||||||
|
blocked: Option<bool>,
|
||||||
|
depends_on: Option<Vec<u32>>,
|
||||||
|
qa: Option<String>,
|
||||||
|
mergemaster_attempted: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the YAML front-matter block from a markdown body.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when the body has no opening `---`. Returns an error
|
||||||
|
/// message when an opening `---` is present but parsing fails.
|
||||||
|
fn parse_legacy_front_matter(contents: &str) -> Result<Option<LegacyFrontMatter>, String> {
|
||||||
|
let mut lines = contents.lines();
|
||||||
|
let first = lines.next().unwrap_or_default().trim();
|
||||||
|
if first != "---" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut front_lines = Vec::new();
|
||||||
|
for line in &mut lines {
|
||||||
|
if line.trim() == "---" {
|
||||||
|
let raw = front_lines.join("\n");
|
||||||
|
return serde_yaml::from_str::<LegacyFrontMatter>(&raw)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| e.to_string());
|
||||||
|
}
|
||||||
|
front_lines.push(line);
|
||||||
|
}
|
||||||
|
Err("missing closing front-matter delimiter".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the YAML front-matter block (`---\n...\n---\n`) from a markdown body.
|
||||||
|
///
|
||||||
|
/// Returns the body without the leading YAML block and its trailing delimiter,
|
||||||
|
/// preserving any leading newline that follows the closing `---`. If the input
|
||||||
|
/// has no opening `---` the body is returned unchanged.
|
||||||
|
pub(super) fn strip_yaml_block(content: &str) -> String {
|
||||||
|
let mut lines = content.lines();
|
||||||
|
let first = match lines.next() {
|
||||||
|
Some(l) => l,
|
||||||
|
None => return String::new(),
|
||||||
|
};
|
||||||
|
if first.trim() != "---" {
|
||||||
|
return content.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut consumed = first.len() + 1; // +1 for the newline
|
||||||
|
for line in &mut lines {
|
||||||
|
consumed += line.len() + 1;
|
||||||
|
if line.trim() == "---" {
|
||||||
|
// Drop a single blank line that often follows the closing fence.
|
||||||
|
let rest = &content[consumed.min(content.len())..];
|
||||||
|
return rest.trim_start_matches('\n').to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No closing fence — leave content alone rather than mangle it.
|
||||||
|
content.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the one-shot YAML-strip migration over every story in the content store.
|
||||||
|
///
|
||||||
|
/// For each story:
|
||||||
|
/// 1. Read the markdown body from the content store.
|
||||||
|
/// 2. If it starts with a `---` block, parse the YAML one last time.
|
||||||
|
/// 3. Compare every CRDT-mirrored field against the YAML value; any
|
||||||
|
/// mismatch is logged at ERROR level (the migration trusts the CRDT).
|
||||||
|
/// 4. Rewrite the body in the content store and shadow table without the
|
||||||
|
/// YAML block.
|
||||||
|
///
|
||||||
|
/// Bodies that already lack a `---` opener are skipped (idempotent re-runs).
|
||||||
|
pub fn run() {
|
||||||
|
let ids = super::all_content_ids();
|
||||||
|
let mut migrated = 0usize;
|
||||||
|
let mut diverged = 0usize;
|
||||||
|
|
||||||
|
for story_id in ids {
|
||||||
|
let Some(content) = super::read_content(&story_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !content.trim_start().starts_with("---") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One last parse — this is the only remaining caller after AC2.
|
||||||
|
let yaml = match parse_legacy_front_matter(&content) {
|
||||||
|
Ok(Some(m)) => m,
|
||||||
|
Ok(None) => continue,
|
||||||
|
Err(e) => {
|
||||||
|
slog!(
|
||||||
|
"[migrate-yaml] {}: parse failed ({}); leaving content untouched",
|
||||||
|
story_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let crdt = crate::crdt_state::read_item(&story_id);
|
||||||
|
|
||||||
|
if let Some(ref view) = crdt {
|
||||||
|
// Compare every YAML field to its CRDT counterpart and log
|
||||||
|
// divergence. The CRDT wins; YAML is dropped regardless.
|
||||||
|
let mut field_diverged = false;
|
||||||
|
macro_rules! cmp {
|
||||||
|
($field:expr, $yaml:expr, $crdt:expr) => {
|
||||||
|
let yv = $yaml;
|
||||||
|
let cv = $crdt;
|
||||||
|
if yv.is_some() && yv != cv {
|
||||||
|
slog!(
|
||||||
|
"[migrate-yaml][ERROR] {} field '{}' diverged: yaml={:?} crdt={:?}",
|
||||||
|
story_id,
|
||||||
|
$field,
|
||||||
|
yv,
|
||||||
|
cv,
|
||||||
|
);
|
||||||
|
field_diverged = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
cmp!("name", yaml.name.clone(), view.name.clone());
|
||||||
|
cmp!("agent", yaml.agent.clone(), view.agent.clone());
|
||||||
|
cmp!(
|
||||||
|
"retry_count",
|
||||||
|
yaml.retry_count.map(|n| n as i64),
|
||||||
|
view.retry_count
|
||||||
|
);
|
||||||
|
cmp!("blocked", yaml.blocked, view.blocked);
|
||||||
|
cmp!(
|
||||||
|
"depends_on",
|
||||||
|
yaml.depends_on.clone(),
|
||||||
|
view.depends_on.clone()
|
||||||
|
);
|
||||||
|
cmp!(
|
||||||
|
"qa",
|
||||||
|
yaml.qa
|
||||||
|
.as_deref()
|
||||||
|
.and_then(QaMode::from_str)
|
||||||
|
.map(|q| q.as_str().to_string()),
|
||||||
|
view.qa_mode.clone()
|
||||||
|
);
|
||||||
|
cmp!(
|
||||||
|
"mergemaster_attempted",
|
||||||
|
yaml.mergemaster_attempted,
|
||||||
|
view.mergemaster_attempted
|
||||||
|
);
|
||||||
|
if field_diverged {
|
||||||
|
diverged += 1;
|
||||||
|
}
|
||||||
|
} else if yaml.name.is_some() || yaml.agent.is_some() {
|
||||||
|
// YAML had real fields but the CRDT has no entry. Log and proceed.
|
||||||
|
slog!(
|
||||||
|
"[migrate-yaml][ERROR] {} has YAML fields but no CRDT entry — yaml dropped",
|
||||||
|
story_id
|
||||||
|
);
|
||||||
|
diverged += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stripped = strip_yaml_block(&content);
|
||||||
|
if stripped == content {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the stripped body. We use `move_item_stage` with a transform
|
||||||
|
// so the shadow table stays in sync; if the CRDT has no entry we fall
|
||||||
|
// back to writing the content store directly.
|
||||||
|
if let Some(view) = crdt {
|
||||||
|
super::move_item_stage(&story_id, &view.stage, Some(&|_| stripped.clone()));
|
||||||
|
} else {
|
||||||
|
super::write_content(&story_id, &stripped);
|
||||||
|
}
|
||||||
|
migrated += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if migrated > 0 || diverged > 0 {
|
||||||
|
slog!(
|
||||||
|
"[migrate-yaml] stripped YAML from {} stories ({} had divergent CRDT)",
|
||||||
|
migrated,
|
||||||
|
diverged
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::db::{ItemMeta, ensure_content_store, read_content, write_item_with_content};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_yaml_block_removes_leading_block() {
|
||||||
|
let input = "---\nname: Test\nagent: coder-1\n---\n# Body\n";
|
||||||
|
assert_eq!(strip_yaml_block(input), "# Body\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_yaml_block_preserves_body_without_yaml() {
|
||||||
|
let input = "# Just a body\n\nNo YAML here.\n";
|
||||||
|
assert_eq!(strip_yaml_block(input), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_yaml_block_leaves_content_alone_when_unclosed() {
|
||||||
|
// No closing --- — we don't want to mangle the body.
|
||||||
|
let input = "---\nname: Test\n# Story\n";
|
||||||
|
assert_eq!(strip_yaml_block(input), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_yaml_block_drops_only_one_blank_line_after_fence() {
|
||||||
|
let input = "---\nname: Test\n---\n\n# Body\n";
|
||||||
|
// The closing `---\n` is dropped plus one blank line; the actual body starts at `# Body`.
|
||||||
|
assert_eq!(strip_yaml_block(input), "# Body\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_migration_strips_yaml_when_crdt_matches() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
ensure_content_store();
|
||||||
|
let story_id = "9001_story_strip_match";
|
||||||
|
|
||||||
|
let body = "---\nname: Match Story\nagent: coder-1\n---\n# Body\n";
|
||||||
|
write_item_with_content(
|
||||||
|
story_id,
|
||||||
|
"2_current",
|
||||||
|
body,
|
||||||
|
ItemMeta {
|
||||||
|
name: Some("Match Story".into()),
|
||||||
|
agent: Some("coder-1".into()),
|
||||||
|
..ItemMeta::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
||||||
|
let after = read_content(story_id).expect("content present");
|
||||||
|
assert!(
|
||||||
|
!after.trim_start().starts_with("---"),
|
||||||
|
"YAML block should be stripped: {after:?}"
|
||||||
|
);
|
||||||
|
assert!(after.contains("# Body"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_migration_logs_divergence_but_still_strips() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
ensure_content_store();
|
||||||
|
let story_id = "9002_story_strip_diverge";
|
||||||
|
|
||||||
|
// YAML says name = "OLD"; CRDT says name = "NEW".
|
||||||
|
let body = "---\nname: OLD\n---\n# Body\n";
|
||||||
|
write_item_with_content(
|
||||||
|
story_id,
|
||||||
|
"2_current",
|
||||||
|
body,
|
||||||
|
ItemMeta {
|
||||||
|
name: Some("NEW".into()),
|
||||||
|
..ItemMeta::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
||||||
|
let after = read_content(story_id).expect("content present");
|
||||||
|
assert!(!after.contains("---"));
|
||||||
|
// CRDT wins — name is still "NEW".
|
||||||
|
let view = crate::crdt_state::read_item(story_id).expect("crdt item");
|
||||||
|
assert_eq!(view.name.as_deref(), Some("NEW"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_migration_is_idempotent_on_already_stripped_content() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
ensure_content_store();
|
||||||
|
let story_id = "9003_story_strip_idempotent";
|
||||||
|
|
||||||
|
let body = "# Already-stripped body\n";
|
||||||
|
write_item_with_content(
|
||||||
|
story_id,
|
||||||
|
"2_current",
|
||||||
|
body,
|
||||||
|
ItemMeta {
|
||||||
|
name: Some("Already".into()),
|
||||||
|
..ItemMeta::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
run();
|
||||||
|
run();
|
||||||
|
|
||||||
|
let after = read_content(story_id).expect("content present");
|
||||||
|
assert_eq!(after, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
|
|||||||
.join(".huskies/work/3_qa")
|
.join(".huskies/work/3_qa")
|
||||||
.join(format!("{story_id}.md"));
|
.join(format!("{story_id}.md"));
|
||||||
if qa_path.exists() {
|
if qa_path.exists() {
|
||||||
let _ = crate::io::story_metadata::clear_front_matter_field(&qa_path, "review_hold");
|
let _ = crate::db::yaml_legacy::clear_front_matter_field(&qa_path, "review_hold");
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_spike(story_id) {
|
if is_spike(story_id) {
|
||||||
@@ -142,7 +142,7 @@ pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result<Str
|
|||||||
.join(format!("{story_id}.md"));
|
.join(format!("{story_id}.md"));
|
||||||
let agent_name = if story_path.exists() {
|
let agent_name = if story_path.exists() {
|
||||||
let contents = std::fs::read_to_string(&story_path).unwrap_or_default();
|
let contents = std::fs::read_to_string(&story_path).unwrap_or_default();
|
||||||
crate::io::story_metadata::parse_front_matter(&contents)
|
crate::db::yaml_legacy::parse_front_matter(&contents)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|meta| meta.agent)
|
.and_then(|meta| meta.agent)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
|
|||||||
|
|
||||||
// --- Front matter ---
|
// --- Front matter ---
|
||||||
let mut front_matter = serde_json::Map::new();
|
let mut front_matter = serde_json::Map::new();
|
||||||
if let Ok(meta) = crate::io::story_metadata::parse_front_matter(&contents) {
|
if let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&contents) {
|
||||||
if let Some(name) = &meta.name {
|
if let Some(name) = &meta.name {
|
||||||
front_matter.insert("name".to_string(), json!(name));
|
front_matter.insert("name".to_string(), json!(name));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use crate::agents::{
|
use crate::agents::{
|
||||||
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
||||||
};
|
};
|
||||||
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::workflow::{
|
use crate::http::workflow::{
|
||||||
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
||||||
@@ -13,7 +14,7 @@ use crate::http::workflow::{
|
|||||||
update_story_in_file, validate_story_dirs,
|
update_story_in_file, validate_story_dirs,
|
||||||
};
|
};
|
||||||
use crate::io::story_metadata::{
|
use crate::io::story_metadata::{
|
||||||
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
|
check_archived_deps, check_archived_deps_from_list, parse_unchecked_todos,
|
||||||
};
|
};
|
||||||
use crate::service::story::parse_test_cases;
|
use crate::service::story::parse_test_cases;
|
||||||
use crate::slog_warn;
|
use crate::slog_warn;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use crate::agents::{
|
use crate::agents::{
|
||||||
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
||||||
};
|
};
|
||||||
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::workflow::{
|
use crate::http::workflow::{
|
||||||
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
||||||
@@ -13,7 +14,7 @@ use crate::http::workflow::{
|
|||||||
update_story_in_file, validate_story_dirs,
|
update_story_in_file, validate_story_dirs,
|
||||||
};
|
};
|
||||||
use crate::io::story_metadata::{
|
use crate::io::story_metadata::{
|
||||||
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
|
check_archived_deps, check_archived_deps_from_list, parse_unchecked_todos,
|
||||||
};
|
};
|
||||||
use crate::service::story::parse_test_cases;
|
use crate::service::story::parse_test_cases;
|
||||||
use crate::slog_warn;
|
use crate::slog_warn;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
//! and refactors. They are not pipeline-driven but provide authoritative context
|
//! and refactors. They are not pipeline-driven but provide authoritative context
|
||||||
//! injected into agent prompts for all member work items.
|
//! injected into agent prompts for all member work items.
|
||||||
|
|
||||||
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::workflow::create_epic_file;
|
use crate::http::workflow::create_epic_file;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
/// Create a new epic and store it in the CRDT items list.
|
/// Create a new epic and store it in the CRDT items list.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use crate::agents::{
|
use crate::agents::{
|
||||||
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
||||||
};
|
};
|
||||||
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::workflow::{
|
use crate::http::workflow::{
|
||||||
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
||||||
@@ -13,7 +14,7 @@ use crate::http::workflow::{
|
|||||||
update_story_in_file, validate_story_dirs,
|
update_story_in_file, validate_story_dirs,
|
||||||
};
|
};
|
||||||
use crate::io::story_metadata::{
|
use crate::io::story_metadata::{
|
||||||
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
|
check_archived_deps, check_archived_deps_from_list, parse_unchecked_todos,
|
||||||
};
|
};
|
||||||
use crate::service::story::parse_test_cases;
|
use crate::service::story::parse_test_cases;
|
||||||
use crate::slog_warn;
|
use crate::slog_warn;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use crate::agents::{
|
use crate::agents::{
|
||||||
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
|
||||||
};
|
};
|
||||||
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::workflow::{
|
use crate::http::workflow::{
|
||||||
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
||||||
@@ -13,7 +14,7 @@ use crate::http::workflow::{
|
|||||||
update_story_in_file, validate_story_dirs,
|
update_story_in_file, validate_story_dirs,
|
||||||
};
|
};
|
||||||
use crate::io::story_metadata::{
|
use crate::io::story_metadata::{
|
||||||
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
|
check_archived_deps, check_archived_deps_from_list, parse_unchecked_todos,
|
||||||
};
|
};
|
||||||
use crate::service::story::parse_test_cases;
|
use crate::service::story::parse_test_cases;
|
||||||
use crate::slog_warn;
|
use crate::slog_warn;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Bug-item creation and listing operations.
|
//! Bug-item creation and listing operations.
|
||||||
|
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use super::super::{next_item_number, slugify_name, write_story_content};
|
use super::super::{next_item_number, slugify_name, write_story_content};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! Refactor-item creation and listing operations.
|
//! Refactor-item creation and listing operations.
|
||||||
|
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use super::super::{next_item_number, slugify_name, write_story_content};
|
use super::super::{next_item_number, slugify_name, write_story_content};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use super::bug::{create_bug_file, extract_bug_name_from_content, list_bug_files};
|
use super::bug::{create_bug_file, extract_bug_name_from_content, list_bug_files};
|
||||||
use super::refactor::{create_refactor_file, list_refactor_files};
|
use super::refactor::{create_refactor_file, list_refactor_files};
|
||||||
use super::spike::create_spike_file;
|
use super::spike::create_spike_file;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
fn setup_git_repo(root: &std::path::Path) {
|
fn setup_git_repo(root: &std::path::Path) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! Pipeline state — types and loading functions for the story pipeline.
|
//! Pipeline state — types and loading functions for the story pipeline.
|
||||||
|
|
||||||
use crate::agents::AgentStatus;
|
use crate::agents::AgentStatus;
|
||||||
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ pub fn create_story_file(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -262,7 +262,7 @@ mod tests {
|
|||||||
create_story_file(tmp.path(), "Type Test Story", None, None, None, None, false)
|
create_story_file(tmp.path(), "Type Test Story", None, None, None, None, false)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let content = crate::db::read_content(&story_id).expect("content must exist");
|
let content = crate::db::read_content(&story_id).expect("content must exist");
|
||||||
let meta = crate::io::story_metadata::parse_front_matter(&content)
|
let meta = crate::db::yaml_legacy::parse_front_matter(&content)
|
||||||
.expect("front matter should be valid");
|
.expect("front matter should be valid");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
meta.item_type.as_deref(),
|
meta.item_type.as_deref(),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use super::super::{
|
|||||||
slugify_name, story_stage, write_story_content,
|
slugify_name, story_stage, write_story_content,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::io::story_metadata::set_front_matter_field;
|
use crate::db::yaml_legacy::set_front_matter_field;
|
||||||
|
|
||||||
fn json_value_to_yaml_scalar(value: &Value) -> String {
|
fn json_value_to_yaml_scalar(value: &Value) -> String {
|
||||||
match value {
|
match value {
|
||||||
@@ -114,7 +114,7 @@ pub fn update_story_in_file(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
//! Test result persistence — writes structured test results into story markdown files.
|
//! Test result persistence — writes structured test results into story markdown files.
|
||||||
use crate::io::story_metadata::set_front_matter_field;
|
use crate::db::yaml_legacy::set_front_matter_field;
|
||||||
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
//! Dependency resolution: check whether story dependencies are satisfied.
|
//! Dependency resolution helpers — filesystem-backed lookups that don't
|
||||||
|
//! require any in-memory CRDT state.
|
||||||
|
//!
|
||||||
|
//! The CRDT-backed equivalents (`check_unmet_deps_crdt`,
|
||||||
|
//! `check_archived_deps_crdt`) live in `crate::crdt_state::read`; callers
|
||||||
|
//! that already have a CRDT entry should prefer those. This module exists
|
||||||
|
//! for the story-creation path, where dependency IDs are known in memory
|
||||||
|
//! before any CRDT entry has been written.
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use super::parser::parse_front_matter;
|
|
||||||
|
|
||||||
/// Return `true` if a story with the given numeric ID exists in `5_done` or `6_archived`.
|
/// Return `true` if a story with the given numeric ID exists in `5_done` or `6_archived`.
|
||||||
///
|
|
||||||
/// **Dependency semantics:** Both `5_done` and `6_archived` satisfy a `depends_on` entry.
|
|
||||||
/// Stories auto-sweep from `5_done` to `6_archived` after 4 hours, so by the time a dep
|
|
||||||
/// reaches `6_archived`, the dependent story has already been promoted. When a dep is
|
|
||||||
/// already in `6_archived` at the moment of promotion (e.g., it was manually archived or
|
|
||||||
/// abandoned before the dependent story was created), the dependency is still considered
|
|
||||||
/// satisfied — but a warning is logged so the user can see that the dep was archived, not
|
|
||||||
/// cleanly completed. Use `check_archived_deps` to detect this case.
|
|
||||||
fn dep_is_done(project_root: &Path, dep_number: u32) -> bool {
|
fn dep_is_done(project_root: &Path, dep_number: u32) -> bool {
|
||||||
let prefix = format!("{dep_number}_");
|
let prefix = format!("{dep_number}_");
|
||||||
let exact = dep_number.to_string();
|
let exact = dep_number.to_string();
|
||||||
@@ -35,8 +32,7 @@ fn dep_is_done(project_root: &Path, dep_number: u32) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `true` if a story with the given numeric ID exists specifically in `6_archived`
|
/// Return `true` if a story with the given numeric ID exists specifically in `6_archived`.
|
||||||
/// (i.e., it satisfies a `depends_on` but via the archive rather than via a clean done).
|
|
||||||
fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool {
|
fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool {
|
||||||
let prefix = format!("{dep_number}_");
|
let prefix = format!("{dep_number}_");
|
||||||
let exact = dep_number.to_string();
|
let exact = dep_number.to_string();
|
||||||
@@ -60,11 +56,37 @@ fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the list of dependency story numbers from `story_id`'s front matter
|
/// Given an explicit list of dep numbers, return those that have NOT reached
|
||||||
/// that have **not** yet reached `5_done` or `6_archived`.
|
/// `5_done` or `6_archived`.
|
||||||
///
|
///
|
||||||
/// Returns an empty `Vec` when there are no unmet dependencies (including when
|
/// Used by callers that have the dep list in memory (e.g. story update at
|
||||||
/// the story has no `depends_on` field at all).
|
/// promotion time) and want a filesystem fact rather than an in-memory CRDT
|
||||||
|
/// state which may be stale during transitions.
|
||||||
|
pub fn check_unmet_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec<u32> {
|
||||||
|
deps.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&dep| !dep_is_done(project_root, dep))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given an explicit list of dep numbers, return those already in `6_archived`.
|
||||||
|
///
|
||||||
|
/// Used at story-creation time when the dep list is known in memory (before
|
||||||
|
/// the story file has been written), so the caller does not need to parse
|
||||||
|
/// the story.
|
||||||
|
pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec<u32> {
|
||||||
|
deps.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|&dep| dep_is_archived(project_root, dep))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem-backed unmet-dep check for a story file in `<stage_dir>/`.
|
||||||
|
///
|
||||||
|
/// Reads the story's `depends_on` list from its YAML front matter and returns
|
||||||
|
/// the numeric deps still pending (not yet in `5_done` or `6_archived`). This
|
||||||
|
/// is the legacy API used by the auto-assigner when the CRDT layer is not yet
|
||||||
|
/// initialised; CRDT-aware callers should prefer `check_unmet_deps_crdt`.
|
||||||
pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
|
pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
|
||||||
let path = project_root
|
let path = project_root
|
||||||
.join(".huskies")
|
.join(".huskies")
|
||||||
@@ -75,23 +97,20 @@ pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) ->
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return Vec::new(),
|
Err(_) => return Vec::new(),
|
||||||
};
|
};
|
||||||
let deps = match parse_front_matter(&contents)
|
let deps = match crate::db::yaml_legacy::parse_front_matter(&contents)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.depends_on)
|
.and_then(|m| m.depends_on)
|
||||||
{
|
{
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return Vec::new(),
|
None => return Vec::new(),
|
||||||
};
|
};
|
||||||
deps.into_iter()
|
check_unmet_deps_from_list(project_root, &deps)
|
||||||
.filter(|&dep| !dep_is_done(project_root, dep))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the list of dependency story numbers from `story_id`'s front matter
|
/// Filesystem-backed archived-dep check for a story file in `<stage_dir>/`.
|
||||||
/// that are in `6_archived` (satisfied via archive rather than via normal done).
|
|
||||||
///
|
///
|
||||||
/// Used to emit a warning when backlog promotion fires because a dep was archived
|
/// Reads the story's `depends_on` list from its YAML front matter and returns
|
||||||
/// rather than cleanly completed. Returns an empty `Vec` when no deps are archived.
|
/// the numeric deps that satisfied via `6_archived` rather than `5_done`.
|
||||||
pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
|
pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec<u32> {
|
||||||
let path = project_root
|
let path = project_root
|
||||||
.join(".huskies")
|
.join(".huskies")
|
||||||
@@ -102,141 +121,40 @@ pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str)
|
|||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return Vec::new(),
|
Err(_) => return Vec::new(),
|
||||||
};
|
};
|
||||||
let deps = match parse_front_matter(&contents)
|
let deps = match crate::db::yaml_legacy::parse_front_matter(&contents)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.depends_on)
|
.and_then(|m| m.depends_on)
|
||||||
{
|
{
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => return Vec::new(),
|
None => return Vec::new(),
|
||||||
};
|
};
|
||||||
deps.into_iter()
|
check_archived_deps_from_list(project_root, &deps)
|
||||||
.filter(|&dep| dep_is_archived(project_root, dep))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Given an explicit list of dep numbers, return those already in `6_archived`.
|
|
||||||
///
|
|
||||||
/// Used at story-creation time when the dep list is known in memory (before the
|
|
||||||
/// story file has been written), so the caller does not need to parse the story.
|
|
||||||
pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec<u32> {
|
|
||||||
deps.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|&dep| dep_is_archived(project_root, dep))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn check_unmet_deps_returns_empty_when_no_deps() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let stage = tmp.path().join(".huskies/work/2_current");
|
|
||||||
std::fs::create_dir_all(&stage).unwrap();
|
|
||||||
std::fs::write(stage.join("10_story_foo.md"), "---\nname: Foo\n---\n").unwrap();
|
|
||||||
let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo");
|
|
||||||
assert!(unmet.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn check_unmet_deps_returns_unmet_numbers() {
|
|
||||||
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();
|
|
||||||
// Dep 477 is done, dep 478 is not.
|
|
||||||
std::fs::write(done.join("477_story_dep.md"), "---\nname: Dep\n---\n").unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
current.join("10_story_foo.md"),
|
|
||||||
"---\nname: Foo\ndepends_on: [477, 478]\n---\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo");
|
|
||||||
assert_eq!(unmet, vec![478]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn check_unmet_deps_returns_empty_when_all_deps_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("477_story_a.md"), "---\nname: A\n---\n").unwrap();
|
|
||||||
std::fs::write(done.join("478_story_b.md"), "---\nname: B\n---\n").unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
current.join("10_story_foo.md"),
|
|
||||||
"---\nname: Foo\ndepends_on: [477, 478]\n---\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo");
|
|
||||||
assert!(unmet.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dep_is_done_finds_story_in_archived() {
|
fn dep_is_done_finds_story_in_archived() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let archived = tmp.path().join(".huskies/work/6_archived");
|
let archived = tmp.path().join(".huskies/work/6_archived");
|
||||||
std::fs::create_dir_all(&archived).unwrap();
|
std::fs::create_dir_all(&archived).unwrap();
|
||||||
std::fs::write(archived.join("100_story_old.md"), "---\nname: Old\n---\n").unwrap();
|
std::fs::write(archived.join("100_story_old.md"), "# old\n").unwrap();
|
||||||
assert!(dep_is_done(tmp.path(), 100));
|
assert!(dep_is_done(tmp.path(), 100));
|
||||||
assert!(!dep_is_done(tmp.path(), 101));
|
assert!(!dep_is_done(tmp.path(), 101));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
|
|
||||||
|
|
||||||
/// check_archived_deps returns the dep IDs that are in 6_archived.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_archived_deps_returns_archived_dep_numbers() {
|
fn check_unmet_deps_from_list_returns_unmet_numbers() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let current = tmp.path().join(".huskies/work/2_current");
|
|
||||||
let archived = tmp.path().join(".huskies/work/6_archived");
|
|
||||||
std::fs::create_dir_all(¤t).unwrap();
|
|
||||||
std::fs::create_dir_all(&archived).unwrap();
|
|
||||||
// Dep 100 is in 6_archived; dep 101 is not anywhere.
|
|
||||||
std::fs::write(archived.join("100_spike_old.md"), "---\nname: Old\n---\n").unwrap();
|
|
||||||
std::fs::write(
|
|
||||||
current.join("5_story_dependent.md"),
|
|
||||||
"---\nname: Dep\ndepends_on: [100, 101]\n---\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let archived_deps = check_archived_deps(tmp.path(), "2_current", "5_story_dependent");
|
|
||||||
assert_eq!(archived_deps, vec![100]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// check_archived_deps returns empty when no deps are in 6_archived.
|
|
||||||
#[test]
|
|
||||||
fn check_archived_deps_returns_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");
|
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::create_dir_all(&done).unwrap();
|
||||||
// Dep 200 is in 5_done (not archived).
|
std::fs::write(done.join("477_story_dep.md"), "# dep\n").unwrap();
|
||||||
std::fs::write(done.join("200_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
let unmet = check_unmet_deps_from_list(tmp.path(), &[477, 478]);
|
||||||
std::fs::write(
|
assert_eq!(unmet, vec![478]);
|
||||||
backlog.join("5_story_waiting.md"),
|
|
||||||
"---\nname: Waiting\ndepends_on: [200]\n---\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let archived_deps = check_archived_deps(tmp.path(), "1_backlog", "5_story_waiting");
|
|
||||||
assert!(archived_deps.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// check_archived_deps returns empty when story has no depends_on.
|
|
||||||
#[test]
|
|
||||||
fn check_archived_deps_returns_empty_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("3_story_free.md"), "---\nname: Free\n---\n").unwrap();
|
|
||||||
let archived_deps = check_archived_deps(tmp.path(), "2_current", "3_story_free");
|
|
||||||
assert!(archived_deps.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// check_archived_deps_from_list returns archived dep IDs from an in-memory list.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_archived_deps_from_list_returns_archived_ids() {
|
fn check_archived_deps_from_list_returns_archived_ids() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -244,25 +162,12 @@ mod tests {
|
|||||||
let archived = tmp.path().join(".huskies/work/6_archived");
|
let archived = tmp.path().join(".huskies/work/6_archived");
|
||||||
std::fs::create_dir_all(&done).unwrap();
|
std::fs::create_dir_all(&done).unwrap();
|
||||||
std::fs::create_dir_all(&archived).unwrap();
|
std::fs::create_dir_all(&archived).unwrap();
|
||||||
std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
std::fs::write(done.join("10_story_done.md"), "# done\n").unwrap();
|
||||||
std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap();
|
std::fs::write(archived.join("20_story_old.md"), "# old\n").unwrap();
|
||||||
// Only 20 is archived; 10 is in done, 30 is nowhere.
|
|
||||||
let result = check_archived_deps_from_list(tmp.path(), &[10, 20, 30]);
|
let result = check_archived_deps_from_list(tmp.path(), &[10, 20, 30]);
|
||||||
assert_eq!(result, vec![20]);
|
assert_eq!(result, vec![20]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// check_archived_deps_from_list returns empty when no deps are archived.
|
|
||||||
#[test]
|
|
||||||
fn check_archived_deps_from_list_empty_when_no_archived_deps() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let done = tmp.path().join(".huskies/work/5_done");
|
|
||||||
std::fs::create_dir_all(&done).unwrap();
|
|
||||||
std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
|
||||||
let result = check_archived_deps_from_list(tmp.path(), &[10]);
|
|
||||||
assert!(result.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// dep_is_archived returns true only for stories in 6_archived, not 5_done.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dep_is_archived_distinguishes_done_from_archived() {
|
fn dep_is_archived_distinguishes_done_from_archived() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -270,13 +175,10 @@ mod tests {
|
|||||||
let archived = tmp.path().join(".huskies/work/6_archived");
|
let archived = tmp.path().join(".huskies/work/6_archived");
|
||||||
std::fs::create_dir_all(&done).unwrap();
|
std::fs::create_dir_all(&done).unwrap();
|
||||||
std::fs::create_dir_all(&archived).unwrap();
|
std::fs::create_dir_all(&archived).unwrap();
|
||||||
std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap();
|
std::fs::write(done.join("10_story_done.md"), "# done\n").unwrap();
|
||||||
std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap();
|
std::fs::write(archived.join("20_story_old.md"), "# old\n").unwrap();
|
||||||
// 10 is in 5_done only — not archived.
|
|
||||||
assert!(!dep_is_archived(tmp.path(), 10));
|
assert!(!dep_is_archived(tmp.path(), 10));
|
||||||
// 20 is in 6_archived — archived.
|
|
||||||
assert!(dep_is_archived(tmp.path(), 20));
|
assert!(dep_is_archived(tmp.path(), 20));
|
||||||
// 99 doesn't exist anywhere.
|
|
||||||
assert!(!dep_is_archived(tmp.path(), 99));
|
assert!(!dep_is_archived(tmp.path(), 99));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
//! Front-matter field manipulation: insert, update, remove, and write helpers.
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
/// Insert or update a key: value pair in the YAML front matter of a markdown string.
|
|
||||||
///
|
|
||||||
/// If no front matter (opening `---`) is found, returns the content unchanged.
|
|
||||||
pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
|
|
||||||
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
|
||||||
if lines.is_empty() || lines[0].trim() != "---" {
|
|
||||||
return contents.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find closing --- (search from index 1)
|
|
||||||
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
|
|
||||||
Some(i) => i + 1,
|
|
||||||
None => return contents.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let key_prefix = format!("{key}:");
|
|
||||||
let existing_idx = lines[1..close_idx]
|
|
||||||
.iter()
|
|
||||||
.position(|l| l.trim_start().starts_with(&key_prefix))
|
|
||||||
.map(|i| i + 1);
|
|
||||||
|
|
||||||
let new_line = format!("{key}: {value}");
|
|
||||||
if let Some(idx) = existing_idx {
|
|
||||||
lines[idx] = new_line;
|
|
||||||
} else {
|
|
||||||
lines.insert(close_idx, new_line);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result = lines.join("\n");
|
|
||||||
if contents.ends_with('\n') {
|
|
||||||
result.push('\n');
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a key: value line from the YAML front matter of a markdown string.
|
|
||||||
///
|
|
||||||
/// If no front matter (opening `---`) is found or the key is absent, returns content unchanged.
|
|
||||||
pub(super) fn remove_front_matter_field(contents: &str, key: &str) -> String {
|
|
||||||
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
|
||||||
if lines.is_empty() || lines[0].trim() != "---" {
|
|
||||||
return contents.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
|
|
||||||
Some(i) => i + 1,
|
|
||||||
None => return contents.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let key_prefix = format!("{key}:");
|
|
||||||
if let Some(idx) = lines[1..close_idx]
|
|
||||||
.iter()
|
|
||||||
.position(|l| l.trim_start().starts_with(&key_prefix))
|
|
||||||
.map(|i| i + 1)
|
|
||||||
{
|
|
||||||
lines.remove(idx);
|
|
||||||
} else {
|
|
||||||
return contents.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result = lines.join("\n");
|
|
||||||
if contents.ends_with('\n') {
|
|
||||||
result.push('\n');
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a key from the YAML front matter of a story file on disk.
|
|
||||||
///
|
|
||||||
/// If front matter is present and contains the key, the line is removed.
|
|
||||||
/// If no front matter or key is not found, the file is left unchanged.
|
|
||||||
pub fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> {
|
|
||||||
let contents =
|
|
||||||
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
|
||||||
let updated = remove_front_matter_field(&contents, key);
|
|
||||||
if updated != contents {
|
|
||||||
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `review_hold: true` to the YAML front matter of a story file.
|
|
||||||
///
|
|
||||||
/// Used to mark spikes that have passed QA and are waiting for human review.
|
|
||||||
pub fn write_review_hold(path: &Path) -> Result<(), String> {
|
|
||||||
let contents =
|
|
||||||
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
|
|
||||||
let updated = set_front_matter_field(&contents, "review_hold", "true");
|
|
||||||
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a key from the YAML front matter of a markdown string (pure function).
|
|
||||||
///
|
|
||||||
/// Returns the updated content. If no front matter or key is not found,
|
|
||||||
/// returns the original content unchanged.
|
|
||||||
pub fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String {
|
|
||||||
remove_front_matter_field(contents, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append rejection notes to a markdown string (pure function).
|
|
||||||
///
|
|
||||||
/// Returns the updated content with a `## QA Rejection Notes` section appended.
|
|
||||||
pub fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String {
|
|
||||||
let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n");
|
|
||||||
format!("{contents}{section}")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write or update `merge_failure` in story content (pure function).
|
|
||||||
pub fn write_merge_failure_in_content(contents: &str, reason: &str) -> String {
|
|
||||||
let escaped = reason
|
|
||||||
.replace('"', "\\\"")
|
|
||||||
.replace('\n', " ")
|
|
||||||
.replace('\r', "");
|
|
||||||
let yaml_value = format!("\"{escaped}\"");
|
|
||||||
set_front_matter_field(contents, "merge_failure", &yaml_value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `review_hold: true` to story content (pure function).
|
|
||||||
pub fn write_review_hold_in_content(contents: &str) -> String {
|
|
||||||
set_front_matter_field(contents, "review_hold", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write `mergemaster_attempted: true` to story content (pure function).
|
|
||||||
///
|
|
||||||
/// Used by the auto-assigner to record that a mergemaster session has been
|
|
||||||
/// spawned for a content-conflict failure, preventing repeated auto-spawns.
|
|
||||||
pub fn write_mergemaster_attempted_in_content(contents: &str) -> String {
|
|
||||||
set_front_matter_field(contents, "mergemaster_attempted", "true")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_front_matter_field_inserts_new_key() {
|
|
||||||
let input = "---\nname: My Story\n---\n# Body\n";
|
|
||||||
let output = set_front_matter_field(input, "coverage_baseline", "55.0%");
|
|
||||||
assert!(output.contains("coverage_baseline: 55.0%"));
|
|
||||||
assert!(output.contains("name: My Story"));
|
|
||||||
assert!(output.ends_with('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_front_matter_field_updates_existing_key() {
|
|
||||||
let input = "---\nname: My Story\ncoverage_baseline: 40.0%\n---\n# Body\n";
|
|
||||||
let output = set_front_matter_field(input, "coverage_baseline", "55.0%");
|
|
||||||
assert!(output.contains("coverage_baseline: 55.0%"));
|
|
||||||
assert!(!output.contains("40.0%"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn set_front_matter_field_no_op_without_front_matter() {
|
|
||||||
let input = "# No front matter\n";
|
|
||||||
let output = set_front_matter_field(input, "coverage_baseline", "55.0%");
|
|
||||||
assert_eq!(output, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_front_matter_field_removes_key() {
|
|
||||||
let input = "---\nname: My Story\nmerge_failure: \"something broke\"\n---\n# Body\n";
|
|
||||||
let output = remove_front_matter_field(input, "merge_failure");
|
|
||||||
assert!(!output.contains("merge_failure"));
|
|
||||||
assert!(output.contains("name: My Story"));
|
|
||||||
assert!(output.ends_with('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_front_matter_field_no_op_when_absent() {
|
|
||||||
let input = "---\nname: My Story\n---\n# Body\n";
|
|
||||||
let output = remove_front_matter_field(input, "merge_failure");
|
|
||||||
assert_eq!(output, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remove_front_matter_field_no_op_without_front_matter() {
|
|
||||||
let input = "# No front matter\n";
|
|
||||||
let output = remove_front_matter_field(input, "merge_failure");
|
|
||||||
assert_eq!(output, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn clear_front_matter_field_updates_file() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let path = tmp.path().join("story.md");
|
|
||||||
std::fs::write(
|
|
||||||
&path,
|
|
||||||
"---\nname: Test\nmerge_failure: \"bad\"\n---\n# Story\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
clear_front_matter_field(&path, "merge_failure").unwrap();
|
|
||||||
let contents = std::fs::read_to_string(&path).unwrap();
|
|
||||||
assert!(!contents.contains("merge_failure"));
|
|
||||||
assert!(contents.contains("name: Test"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn write_review_hold_sets_field() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let path = tmp.path().join("spike.md");
|
|
||||||
std::fs::write(&path, "---\nname: My Spike\n---\n# Spike\n").unwrap();
|
|
||||||
write_review_hold(&path).unwrap();
|
|
||||||
let contents = std::fs::read_to_string(&path).unwrap();
|
|
||||||
assert!(contents.contains("review_hold: true"));
|
|
||||||
assert!(contents.contains("name: My Spike"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
//! Story metadata — parses and modifies YAML front matter in story markdown files.
|
//! Story metadata helpers — CRDT-backed lookups plus pure-content parsers.
|
||||||
//!
|
//!
|
||||||
//! Submodules:
|
//! Story 865 stripped YAML front matter from the content store; this module
|
||||||
//! - `types` — core data types (`QaMode`, `StoryMetadata`, `StoryMetaError`) — types used internally by the other submodules
|
//! no longer parses or writes YAML. What remains:
|
||||||
//! - `parser` — YAML front-matter parsing and QA-mode resolution
|
//! - `types` — `QaMode` enum.
|
||||||
//! - `fields` — front-matter field insertion, update, and removal helpers
|
//! - `parser` — `parse_unchecked_todos`, `resolve_qa_mode`, `is_story_frozen_in_store`.
|
||||||
//! - `deps` — dependency satisfaction checks (`check_unmet_deps`, etc.)
|
//! - `deps` — dependency satisfaction checks (CRDT-backed).
|
||||||
|
|
||||||
mod deps;
|
mod deps;
|
||||||
mod fields;
|
|
||||||
mod parser;
|
mod parser;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use deps::{check_archived_deps, check_archived_deps_from_list, check_unmet_deps};
|
pub use deps::{check_archived_deps, check_archived_deps_from_list, check_unmet_deps};
|
||||||
pub use fields::{
|
|
||||||
clear_front_matter_field, clear_front_matter_field_in_content, set_front_matter_field,
|
|
||||||
write_merge_failure_in_content, write_mergemaster_attempted_in_content,
|
|
||||||
write_rejection_notes_to_content, write_review_hold, write_review_hold_in_content,
|
|
||||||
};
|
|
||||||
pub use parser::{
|
pub use parser::{
|
||||||
is_story_frozen_in_store, parse_front_matter, parse_unchecked_todos, resolve_qa_mode,
|
is_story_frozen_in_store, parse_unchecked_todos, resolve_qa_mode, resolve_qa_mode_from_content,
|
||||||
resolve_qa_mode_from_content,
|
|
||||||
};
|
};
|
||||||
pub use types::QaMode;
|
pub use types::QaMode;
|
||||||
|
|||||||
@@ -1,90 +1,9 @@
|
|||||||
//! Parsing logic for story YAML front matter and todo checkboxes.
|
//! Pure-content helpers and CRDT-backed metadata lookups.
|
||||||
use serde::Deserialize;
|
//!
|
||||||
use std::fs;
|
//! Story 865 stripped YAML front matter from stored content and the codebase
|
||||||
use std::path::Path;
|
//! at large; the only remaining functions here read the CRDT or operate on
|
||||||
|
//! the markdown body directly.
|
||||||
use super::types::{QaMode, StoryMetaError, StoryMetadata};
|
use super::types::QaMode;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub(super) struct FrontMatter {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub coverage_baseline: Option<String>,
|
|
||||||
pub merge_failure: Option<String>,
|
|
||||||
pub agent: Option<String>,
|
|
||||||
pub review_hold: Option<bool>,
|
|
||||||
/// Configurable QA mode field: "human", "server", or "agent".
|
|
||||||
pub qa: Option<String>,
|
|
||||||
/// Number of times this story has been retried at its current pipeline stage.
|
|
||||||
pub retry_count: Option<u32>,
|
|
||||||
/// When `true`, auto-assign will skip this story (retry limit exceeded).
|
|
||||||
pub blocked: Option<bool>,
|
|
||||||
/// Story numbers this story depends on.
|
|
||||||
pub depends_on: Option<Vec<u32>>,
|
|
||||||
/// When `true`, the story is frozen.
|
|
||||||
pub frozen: Option<bool>,
|
|
||||||
/// Stage directory to restore on unfreeze (e.g. `"2_current"`).
|
|
||||||
pub resume_to_stage: Option<String>,
|
|
||||||
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
|
|
||||||
/// Used by the bug-645 salvage path to distinguish a genuine test-passing
|
|
||||||
/// session from one that merely compiled.
|
|
||||||
pub run_tests_passed: Option<bool>,
|
|
||||||
/// Item type: "story", "bug", "spike", or "refactor".
|
|
||||||
#[serde(rename = "type")]
|
|
||||||
pub item_type: Option<String>,
|
|
||||||
/// Set to `true` when the auto-assigner has already spawned a mergemaster
|
|
||||||
/// session for a content-conflict failure.
|
|
||||||
pub mergemaster_attempted: Option<bool>,
|
|
||||||
/// Epic this item belongs to (numeric ID as string, e.g. "880").
|
|
||||||
pub epic: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the YAML front matter block from a story markdown string.
|
|
||||||
pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
|
|
||||||
let mut lines = contents.lines();
|
|
||||||
|
|
||||||
let first = lines.next().unwrap_or_default().trim();
|
|
||||||
if first != "---" {
|
|
||||||
return Err(StoryMetaError::MissingFrontMatter);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut front_lines = Vec::new();
|
|
||||||
for line in &mut lines {
|
|
||||||
let trimmed = line.trim();
|
|
||||||
if trimmed == "---" {
|
|
||||||
let raw = front_lines.join("\n");
|
|
||||||
let front: FrontMatter = serde_yaml::from_str(&raw)
|
|
||||||
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
|
|
||||||
return Ok(build_metadata(front));
|
|
||||||
}
|
|
||||||
front_lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(StoryMetaError::InvalidFrontMatter(
|
|
||||||
"Missing closing front matter delimiter".to_string(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_metadata(front: FrontMatter) -> StoryMetadata {
|
|
||||||
let qa = front.qa.as_deref().and_then(QaMode::from_str);
|
|
||||||
|
|
||||||
StoryMetadata {
|
|
||||||
name: front.name,
|
|
||||||
coverage_baseline: front.coverage_baseline,
|
|
||||||
merge_failure: front.merge_failure,
|
|
||||||
agent: front.agent,
|
|
||||||
review_hold: front.review_hold,
|
|
||||||
qa,
|
|
||||||
retry_count: front.retry_count,
|
|
||||||
blocked: front.blocked,
|
|
||||||
depends_on: front.depends_on,
|
|
||||||
frozen: front.frozen,
|
|
||||||
resume_to_stage: front.resume_to_stage,
|
|
||||||
run_tests_passed: front.run_tests_passed,
|
|
||||||
item_type: front.item_type,
|
|
||||||
mergemaster_attempted: front.mergemaster_attempted,
|
|
||||||
epic: front.epic,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse unchecked todo items (`- [ ] ...`) from a markdown string.
|
/// Parse unchecked todo items (`- [ ] ...`) from a markdown string.
|
||||||
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
||||||
@@ -97,41 +16,27 @@ pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the effective QA mode for a story file.
|
/// Resolve the effective QA mode for a story by ID via the CRDT.
|
||||||
///
|
///
|
||||||
/// Reads the `qa` front matter field. If absent, falls back to `default`.
|
/// Returns `default` when the story has no entry or its `qa_mode` register is
|
||||||
/// Spikes are **not** handled here — the caller is responsible for overriding
|
/// unset. Spikes are **not** handled here — callers override to `Human` for
|
||||||
/// to `Human` for spikes.
|
/// spikes themselves.
|
||||||
pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode {
|
pub fn resolve_qa_mode(story_id: &str, default: QaMode) -> QaMode {
|
||||||
let contents = match fs::read_to_string(path) {
|
crate::crdt_state::read_item(story_id)
|
||||||
Ok(c) => c,
|
.and_then(|view| view.qa_mode)
|
||||||
Err(_) => return default,
|
.as_deref()
|
||||||
};
|
.and_then(QaMode::from_str)
|
||||||
match parse_front_matter(&contents) {
|
.unwrap_or(default)
|
||||||
Ok(meta) => meta.qa.unwrap_or(default),
|
|
||||||
Err(_) => default,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the effective QA mode for a story by story ID.
|
/// Resolve the effective QA mode by parsing legacy YAML front matter from a
|
||||||
///
|
/// markdown body. Used during one-time fallbacks when the CRDT register isn't
|
||||||
/// Checks the typed `qa_mode` CRDT register first. If the register holds a
|
/// set; new code should always read `qa_mode` from the CRDT.
|
||||||
/// recognised value (`"server"`, `"agent"`, or `"human"`), returns it.
|
pub fn resolve_qa_mode_from_content(_story_id: &str, content: &str, default: QaMode) -> QaMode {
|
||||||
/// Otherwise falls back to parsing the `qa` YAML front-matter field from
|
crate::db::yaml_legacy::parse_front_matter(content)
|
||||||
/// `contents`. If neither source provides a value, returns `default`.
|
.ok()
|
||||||
pub fn resolve_qa_mode_from_content(story_id: &str, contents: &str, default: QaMode) -> QaMode {
|
.and_then(|m| m.qa)
|
||||||
// CRDT register takes precedence over YAML front matter.
|
.unwrap_or(default)
|
||||||
if let Some(view) = crate::crdt_state::read_item(story_id)
|
|
||||||
&& let Some(ref s) = view.qa_mode
|
|
||||||
&& let Some(mode) = QaMode::from_str(s)
|
|
||||||
{
|
|
||||||
return mode;
|
|
||||||
}
|
|
||||||
// Fall back to YAML front matter for backward compatibility.
|
|
||||||
match parse_front_matter(contents) {
|
|
||||||
Ok(meta) => meta.qa.unwrap_or(default),
|
|
||||||
Err(_) => default,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `true` if the story is in the `Frozen` pipeline stage.
|
/// Return `true` if the story is in the `Frozen` pipeline stage.
|
||||||
@@ -150,48 +55,6 @@ pub fn is_story_frozen_in_store(story_id: &str) -> bool {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_front_matter_metadata() {
|
|
||||||
let input = r#"---
|
|
||||||
name: Establish the TDD Workflow and Gates
|
|
||||||
workflow: tdd
|
|
||||||
---
|
|
||||||
# Story 26
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
|
||||||
assert_eq!(
|
|
||||||
meta.name.as_deref(),
|
|
||||||
Some("Establish the TDD Workflow and Gates")
|
|
||||||
);
|
|
||||||
assert_eq!(meta.coverage_baseline, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_coverage_baseline_from_front_matter() {
|
|
||||||
let input = "---\nname: Test Story\ncoverage_baseline: 78.5%\n---\n# Story\n";
|
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
|
||||||
assert_eq!(meta.coverage_baseline.as_deref(), Some("78.5%"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_missing_front_matter() {
|
|
||||||
let input = "# Story 26\n";
|
|
||||||
assert_eq!(
|
|
||||||
parse_front_matter(input),
|
|
||||||
Err(StoryMetaError::MissingFrontMatter)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rejects_unclosed_front_matter() {
|
|
||||||
let input = "---\nname: Test\n";
|
|
||||||
assert!(matches!(
|
|
||||||
parse_front_matter(input),
|
|
||||||
Err(StoryMetaError::InvalidFrontMatter(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_unchecked_todos_mixed() {
|
fn parse_unchecked_todos_mixed() {
|
||||||
let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n";
|
let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n";
|
||||||
@@ -220,75 +83,11 @@ workflow: tdd
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_review_hold_from_front_matter() {
|
fn resolve_qa_mode_falls_back_to_default_when_crdt_empty() {
|
||||||
let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n";
|
crate::crdt_state::init_for_test();
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
assert_eq!(
|
||||||
assert_eq!(meta.review_hold, Some(true));
|
resolve_qa_mode("9999_no_such_story", QaMode::Server),
|
||||||
}
|
QaMode::Server
|
||||||
|
);
|
||||||
#[test]
|
|
||||||
fn review_hold_defaults_to_none() {
|
|
||||||
let input = "---\nname: Story\n---\n# Story\n";
|
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
|
||||||
assert_eq!(meta.review_hold, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_qa_mode_from_front_matter() {
|
|
||||||
let input = "---\nname: Story\nqa: server\n---\n# Story\n";
|
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
|
||||||
assert_eq!(meta.qa, Some(QaMode::Server));
|
|
||||||
|
|
||||||
let input = "---\nname: Story\nqa: agent\n---\n# Story\n";
|
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
|
||||||
assert_eq!(meta.qa, Some(QaMode::Agent));
|
|
||||||
|
|
||||||
let input = "---\nname: Story\nqa: human\n---\n# Story\n";
|
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
|
||||||
assert_eq!(meta.qa, Some(QaMode::Human));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn qa_mode_defaults_to_none() {
|
|
||||||
let input = "---\nname: Story\n---\n# Story\n";
|
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
|
||||||
assert_eq!(meta.qa, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_qa_mode_uses_file_value() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let path = tmp.path().join("story.md");
|
|
||||||
std::fs::write(&path, "---\nname: Test\nqa: human\n---\n# Story\n").unwrap();
|
|
||||||
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Human);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_qa_mode_falls_back_to_default() {
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
|
||||||
let path = tmp.path().join("story.md");
|
|
||||||
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
|
|
||||||
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server);
|
|
||||||
assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_qa_mode_missing_file_uses_default() {
|
|
||||||
let path = std::path::Path::new("/nonexistent/story.md");
|
|
||||||
assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_depends_on_from_front_matter() {
|
|
||||||
let input = "---\nname: Story\ndepends_on: [477, 478]\n---\n# Story\n";
|
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
|
||||||
assert_eq!(meta.depends_on, Some(vec![477, 478]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn depends_on_defaults_to_none() {
|
|
||||||
let input = "---\nname: Story\n---\n# Story\n";
|
|
||||||
let meta = parse_front_matter(input).expect("front matter");
|
|
||||||
assert_eq!(meta.depends_on, None);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Core data types for story front-matter metadata.
|
//! Core data types for story metadata.
|
||||||
|
|
||||||
/// QA mode for a story: determines how the pipeline handles post-coder review.
|
/// QA mode for a story: determines how the pipeline handles post-coder review.
|
||||||
///
|
///
|
||||||
@@ -39,58 +39,3 @@ impl std::fmt::Display for QaMode {
|
|||||||
f.write_str(self.as_str())
|
f.write_str(self.as_str())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed YAML front-matter fields from a story markdown file.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
||||||
pub struct StoryMetadata {
|
|
||||||
pub name: Option<String>,
|
|
||||||
pub coverage_baseline: Option<String>,
|
|
||||||
pub merge_failure: Option<String>,
|
|
||||||
pub agent: Option<String>,
|
|
||||||
pub review_hold: Option<bool>,
|
|
||||||
pub qa: Option<QaMode>,
|
|
||||||
/// Number of times this story has been retried at its current pipeline stage.
|
|
||||||
pub retry_count: Option<u32>,
|
|
||||||
/// When `true`, auto-assign will skip this story (retry limit exceeded).
|
|
||||||
pub blocked: Option<bool>,
|
|
||||||
/// Story numbers this story depends on. Auto-assign will skip this story
|
|
||||||
/// until all dependencies have reached `5_done` or `6_archived`.
|
|
||||||
pub depends_on: Option<Vec<u32>>,
|
|
||||||
/// When `true`, the story is frozen: auto-assign skips it, the pipeline
|
|
||||||
/// does not advance it, and no mergemaster is spawned.
|
|
||||||
pub frozen: Option<bool>,
|
|
||||||
/// Pipeline stage to restore when unfreezing (e.g. `"2_current"`).
|
|
||||||
/// Written by `transition_to_frozen`; cleared by `transition_to_unfrozen`.
|
|
||||||
pub resume_to_stage: Option<String>,
|
|
||||||
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
|
|
||||||
/// Used by the bug-645 salvage path to require real test evidence, not just
|
|
||||||
/// compilation success.
|
|
||||||
pub run_tests_passed: Option<bool>,
|
|
||||||
/// Item type: "story", "bug", "spike", or "refactor".
|
|
||||||
///
|
|
||||||
/// Present on items created with numeric-only IDs (no slug suffix).
|
|
||||||
/// Used by the pipeline to determine routing (e.g. spikes skip QA).
|
|
||||||
pub item_type: Option<String>,
|
|
||||||
/// Set to `true` when the auto-assigner has already spawned a mergemaster
|
|
||||||
/// session for a content-conflict failure. Prevents repeated spawns.
|
|
||||||
pub mergemaster_attempted: Option<bool>,
|
|
||||||
/// Epic this item belongs to. The value is the epic's numeric ID (e.g. "880").
|
|
||||||
/// Set on story/bug/spike/refactor items to declare membership in an epic.
|
|
||||||
pub epic: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Errors that can occur when parsing story front-matter metadata.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum StoryMetaError {
|
|
||||||
MissingFrontMatter,
|
|
||||||
InvalidFrontMatter(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for StoryMetaError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
StoryMetaError::MissingFrontMatter => write!(f, "Missing front matter"),
|
|
||||||
StoryMetaError::InvalidFrontMatter(msg) => write!(f, "Invalid front matter: {msg}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyErro
|
|||||||
let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?;
|
let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?;
|
||||||
let resume_dir = item.stage.dir_name().to_string();
|
let resume_dir = item.stage.dir_name().to_string();
|
||||||
let transform = move |content: &str| -> String {
|
let transform = move |content: &str| -> String {
|
||||||
crate::io::story_metadata::set_front_matter_field(content, "resume_to_stage", &resume_dir)
|
crate::db::yaml_legacy::set_front_matter_field(content, "resume_to_stage", &resume_dir)
|
||||||
};
|
};
|
||||||
apply_transition(story_id, PipelineEvent::Freeze, Some(&transform))
|
apply_transition(story_id, PipelineEvent::Freeze, Some(&transform))
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyErro
|
|||||||
/// the `resume_to_stage` field from the front matter.
|
/// the `resume_to_stage` field from the front matter.
|
||||||
pub fn transition_to_unfrozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
pub fn transition_to_unfrozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
||||||
let transform = |content: &str| -> String {
|
let transform = |content: &str| -> String {
|
||||||
crate::io::story_metadata::clear_front_matter_field_in_content(content, "resume_to_stage")
|
crate::db::yaml_legacy::clear_front_matter_field_in_content(content, "resume_to_stage")
|
||||||
};
|
};
|
||||||
apply_transition(story_id, PipelineEvent::Unfreeze, Some(&transform))
|
apply_transition(story_id, PipelineEvent::Unfreeze, Some(&transform))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
|||||||
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
|
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
|
||||||
let resume_to = crate::db::read_content(&view.story_id)
|
let resume_to = crate::db::read_content(&view.story_id)
|
||||||
.and_then(|content| {
|
.and_then(|content| {
|
||||||
crate::io::story_metadata::parse_front_matter(&content)
|
crate::db::yaml_legacy::parse_front_matter(&content)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|m| m.resume_to_stage)
|
.and_then(|m| m.resume_to_stage)
|
||||||
.and_then(|dir| Stage::from_dir(&dir))
|
.and_then(|dir| Stage::from_dir(&dir))
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ pub fn get_work_item_content(
|
|||||||
|
|
||||||
for (stage_dir, stage_name) in &stages {
|
for (stage_dir, stage_name) in &stages {
|
||||||
if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? {
|
if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? {
|
||||||
let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
|
let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok();
|
||||||
return Ok(WorkItemContent {
|
return Ok(WorkItemContent {
|
||||||
content,
|
content,
|
||||||
stage: stage_name.to_string(),
|
stage: stage_name.to_string(),
|
||||||
@@ -215,7 +215,7 @@ pub fn get_work_item_content(
|
|||||||
})
|
})
|
||||||
.unwrap_or("unknown")
|
.unwrap_or("unknown")
|
||||||
.to_string();
|
.to_string();
|
||||||
let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
|
let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok();
|
||||||
return Ok(WorkItemContent {
|
return Ok(WorkItemContent {
|
||||||
content,
|
content,
|
||||||
stage,
|
stage,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//! side effects: reading from the CRDT content store, loading configuration,
|
//! side effects: reading from the CRDT content store, loading configuration,
|
||||||
//! and spawning the background listener task.
|
//! and spawning the background listener task.
|
||||||
|
|
||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::db::yaml_legacy::parse_front_matter;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
mod listener;
|
mod listener;
|
||||||
|
|||||||
@@ -157,6 +157,10 @@ pub(crate) async fn init_subsystems(app_state: &Arc<SessionState>, cwd: &Path) {
|
|||||||
{
|
{
|
||||||
worktree::migrate_slug_paths(project_root, &id_migrations);
|
worktree::migrate_slug_paths(project_root, &id_migrations);
|
||||||
}
|
}
|
||||||
|
// Story 865: one-shot strip of legacy YAML front-matter from
|
||||||
|
// every stored body. Idempotent — bodies without `---` are
|
||||||
|
// skipped on subsequent runs.
|
||||||
|
db::yaml_migration::run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user