Files
storkit/server/src/io/story_metadata.rs

571 lines
20 KiB
Rust
Raw Normal View History

use serde::Deserialize;
use std::fs;
use std::path::Path;
/// QA mode for a story: determines how the pipeline handles post-coder review.
///
/// - `Server` — skip the QA agent; rely on server gate checks (clippy + tests).
/// If gates pass, advance straight to merge.
/// - `Agent` — spin up a QA agent (Claude session) to review code and run gates.
/// - `Human` — hold in QA for human approval after server gates pass.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QaMode {
Server,
Agent,
Human,
}
impl QaMode {
/// Parse a string into a `QaMode`. Returns `None` for unrecognised values.
pub fn from_str(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"server" => Some(Self::Server),
"agent" => Some(Self::Agent),
"human" => Some(Self::Human),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Server => "server",
Self::Agent => "agent",
Self::Human => "human",
}
}
}
impl std::fmt::Display for QaMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[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>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoryMetaError {
MissingFrontMatter,
InvalidFrontMatter(String),
}
2026-02-19 18:05:21 +00:00
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}"),
}
}
}
#[derive(Debug, Deserialize)]
struct FrontMatter {
name: Option<String>,
coverage_baseline: Option<String>,
merge_failure: Option<String>,
agent: Option<String>,
review_hold: Option<bool>,
/// New configurable QA mode field: "human", "server", or "agent".
qa: Option<String>,
/// Legacy boolean field — mapped to `qa: human` (true) or ignored (false/absent).
manual_qa: Option<bool>,
/// Number of times this story has been retried at its current pipeline stage.
retry_count: Option<u32>,
/// When `true`, auto-assign will skip this story (retry limit exceeded).
blocked: Option<bool>,
}
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 {
// Resolve qa mode: prefer the new `qa` field, fall back to legacy `manual_qa`.
let qa = if let Some(ref qa_str) = front.qa {
QaMode::from_str(qa_str)
} else {
front.manual_qa.and_then(|v| if v { Some(QaMode::Human) } else { None })
};
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,
}
}
/// Write or update a `coverage_baseline:` field in the YAML front matter of a story file.
///
/// If front matter is present, adds or replaces `coverage_baseline:` before the closing `---`.
/// If no front matter is present, this is a no-op (returns Ok).
pub fn write_coverage_baseline(path: &Path, coverage_pct: f64) -> 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, "coverage_baseline", &format!("{coverage_pct:.1}%"));
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
Ok(())
}
/// Write or update a `merge_failure:` field in the YAML front matter of a story file.
///
/// The reason is stored as a quoted YAML string so that colons, hashes, and newlines
/// in the failure message do not break front-matter parsing.
/// If no front matter is present, this is a no-op (returns Ok).
pub fn write_merge_failure(path: &Path, reason: &str) -> Result<(), String> {
let contents =
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
// Produce a YAML-safe inline quoted string: collapse newlines, escape inner quotes.
let escaped = reason.replace('"', "\\\"").replace('\n', " ").replace('\r', "");
let yaml_value = format!("\"{escaped}\"");
let updated = set_front_matter_field(&contents, "merge_failure", &yaml_value);
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 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(())
}
/// 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.
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
}
/// 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
}
/// Increment the `retry_count` field in the story file's front matter.
///
/// Reads the current value (defaulting to 0), increments by 1, and writes back.
/// Returns the new retry count.
pub fn increment_retry_count(path: &Path) -> Result<u32, String> {
let contents =
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
let current = parse_front_matter(&contents)
.ok()
.and_then(|m| m.retry_count)
.unwrap_or(0);
let new_count = current + 1;
let updated = set_front_matter_field(&contents, "retry_count", &new_count.to_string());
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
Ok(new_count)
}
/// Write `blocked: true` to the YAML front matter of a story file.
///
/// Used to mark stories that have exceeded the retry limit and should not
/// be auto-assigned again.
pub fn write_blocked(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, "blocked", "true");
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
Ok(())
}
/// Append rejection notes to a story file body.
///
/// Adds a `## QA Rejection Notes` section at the end of the file so the coder
/// agent can see what needs fixing.
pub fn write_rejection_notes(path: &Path, notes: &str) -> Result<(), String> {
let contents =
fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?;
let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n");
let updated = format!("{contents}{section}");
fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?;
Ok(())
}
/// Resolve the effective QA mode for a story file.
///
/// Reads the `qa` front matter field. If absent, falls back to `default`.
/// Spikes are **not** handled here — the caller is responsible for overriding
/// to `Human` for spikes.
pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode {
let contents = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return default,
};
match parse_front_matter(&contents) {
Ok(meta) => meta.qa.unwrap_or(default),
Err(_) => default,
}
}
pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
contents
.lines()
.filter_map(|line| {
let trimmed = line.trim();
trimmed
.strip_prefix("- [ ] ")
.map(|text| text.to_string())
})
.collect()
}
#[cfg(test)]
mod tests {
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 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 write_coverage_baseline_updates_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
write_coverage_baseline(&path, 82.3).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("coverage_baseline: 82.3%"));
}
#[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]
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 parse_unchecked_todos_mixed() {
let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n";
assert_eq!(
parse_unchecked_todos(input),
vec!["First thing", "Second thing"]
);
}
#[test]
fn parse_unchecked_todos_all_checked() {
let input = "- [x] Done\n- [x] Also done\n";
assert!(parse_unchecked_todos(input).is_empty());
}
#[test]
fn parse_unchecked_todos_no_checkboxes() {
let input = "# Story\nSome text\n- A bullet\n";
assert!(parse_unchecked_todos(input).is_empty());
}
#[test]
fn parse_unchecked_todos_leading_whitespace() {
let input = " - [ ] Indented item\n";
assert_eq!(parse_unchecked_todos(input), vec!["Indented item"]);
}
#[test]
fn parses_review_hold_from_front_matter() {
let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.review_hold, Some(true));
}
#[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 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"));
}
#[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 legacy_manual_qa_true_maps_to_human() {
let input = "---\nname: Story\nmanual_qa: true\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Human));
}
#[test]
fn legacy_manual_qa_false_maps_to_none() {
let input = "---\nname: Story\nmanual_qa: false\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, None);
}
#[test]
fn qa_field_takes_precedence_over_manual_qa() {
let input = "---\nname: Story\nqa: server\nmanual_qa: true\n---\n# Story\n";
let meta = parse_front_matter(input).expect("front matter");
assert_eq!(meta.qa, Some(QaMode::Server));
}
#[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 write_rejection_notes_appends_section() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("story.md");
std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap();
write_rejection_notes(&path, "Button color is wrong").unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("## QA Rejection Notes"));
assert!(contents.contains("Button color is wrong"));
}
}