story-kit: merge 306_story_replace_manual_qa_boolean_with_configurable_qa_mode_field
This commit is contained in:
@@ -2,6 +2,45 @@ 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>,
|
||||
@@ -9,7 +48,7 @@ pub struct StoryMetadata {
|
||||
pub merge_failure: Option<String>,
|
||||
pub agent: Option<String>,
|
||||
pub review_hold: Option<bool>,
|
||||
pub manual_qa: Option<bool>,
|
||||
pub qa: Option<QaMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -34,6 +73,9 @@ struct FrontMatter {
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -63,13 +105,20 @@ pub fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaErro
|
||||
}
|
||||
|
||||
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,
|
||||
manual_qa: front.manual_qa,
|
||||
qa,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,15 +259,19 @@ pub fn write_rejection_notes(path: &Path, notes: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check whether a story requires manual QA (defaults to false).
|
||||
pub fn requires_manual_qa(path: &Path) -> bool {
|
||||
/// 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 false,
|
||||
Err(_) => return default,
|
||||
};
|
||||
match parse_front_matter(&contents) {
|
||||
Ok(meta) => meta.manual_qa.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
Ok(meta) => meta.qa.unwrap_or(default),
|
||||
Err(_) => default,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,41 +451,69 @@ workflow: tdd
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_manual_qa_from_front_matter() {
|
||||
let input = "---\nname: Story\nmanual_qa: false\n---\n# Story\n";
|
||||
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.manual_qa, Some(false));
|
||||
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 manual_qa_defaults_to_none() {
|
||||
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.manual_qa, None);
|
||||
assert_eq!(meta.qa, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_manual_qa_defaults_false() {
|
||||
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!(!requires_manual_qa(&path));
|
||||
assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server);
|
||||
assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_manual_qa_true_when_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("story.md");
|
||||
std::fs::write(&path, "---\nname: Test\nmanual_qa: true\n---\n# Story\n").unwrap();
|
||||
assert!(requires_manual_qa(&path));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requires_manual_qa_false_when_set() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("story.md");
|
||||
std::fs::write(&path, "---\nname: Test\nmanual_qa: false\n---\n# Story\n").unwrap();
|
||||
assert!(!requires_manual_qa(&path));
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user