Story 26: Establish TDD workflow and quality gates
Add workflow engine with acceptance gates, test recording, and review queue. Frontend displays gate status (blocked/ready), test summaries, failing badges, and warnings. Proceed action is disabled when gates are not met. Includes 13 unit tests (Vitest) and 9 E2E tests (Playwright) covering all five acceptance criteria. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use crate::state::SessionState;
|
||||
use crate::store::JsonFileStore;
|
||||
use crate::workflow::WorkflowState;
|
||||
use poem::http::StatusCode;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -7,6 +8,7 @@ use std::sync::Arc;
|
||||
pub struct AppContext {
|
||||
pub state: Arc<SessionState>,
|
||||
pub store: Arc<JsonFileStore>,
|
||||
pub workflow: Arc<std::sync::Mutex<WorkflowState>>,
|
||||
}
|
||||
|
||||
pub type OpenApiResult<T> = poem::Result<T>;
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod context;
|
||||
pub mod health;
|
||||
pub mod io;
|
||||
pub mod model;
|
||||
pub mod workflow;
|
||||
|
||||
pub mod project;
|
||||
pub mod ws;
|
||||
@@ -19,6 +20,7 @@ use poem::{Route, get};
|
||||
use poem_openapi::OpenApiService;
|
||||
use project::ProjectApi;
|
||||
use std::sync::Arc;
|
||||
use workflow::WorkflowApi;
|
||||
|
||||
pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
||||
let ctx_arc = std::sync::Arc::new(ctx);
|
||||
@@ -36,7 +38,14 @@ pub fn build_routes(ctx: AppContext) -> impl poem::Endpoint {
|
||||
.data(ctx_arc)
|
||||
}
|
||||
|
||||
type ApiTuple = (ProjectApi, ModelApi, AnthropicApi, IoApi, ChatApi);
|
||||
type ApiTuple = (
|
||||
ProjectApi,
|
||||
ModelApi,
|
||||
AnthropicApi,
|
||||
IoApi,
|
||||
ChatApi,
|
||||
WorkflowApi,
|
||||
);
|
||||
|
||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||
|
||||
@@ -48,6 +57,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
AnthropicApi::new(ctx.clone()),
|
||||
IoApi { ctx: ctx.clone() },
|
||||
ChatApi { ctx: ctx.clone() },
|
||||
WorkflowApi { ctx: ctx.clone() },
|
||||
);
|
||||
|
||||
let api_service =
|
||||
@@ -58,7 +68,8 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
||||
ModelApi { ctx: ctx.clone() },
|
||||
AnthropicApi::new(ctx.clone()),
|
||||
IoApi { ctx: ctx.clone() },
|
||||
ChatApi { ctx },
|
||||
ChatApi { ctx: ctx.clone() },
|
||||
WorkflowApi { ctx },
|
||||
);
|
||||
|
||||
let docs_service =
|
||||
|
||||
319
server/src/http/workflow.rs
Normal file
319
server/src/http/workflow.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::io::story_metadata::{StoryMetadata, parse_front_matter};
|
||||
use crate::workflow::{
|
||||
StoryTestResults, TestCaseResult, TestStatus, evaluate_acceptance, summarize_results,
|
||||
};
|
||||
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Tags)]
|
||||
enum WorkflowTags {
|
||||
Workflow,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct TestCasePayload {
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct RecordTestsPayload {
|
||||
pub story_id: String,
|
||||
pub unit: Vec<TestCasePayload>,
|
||||
pub integration: Vec<TestCasePayload>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Object)]
|
||||
struct AcceptanceRequest {
|
||||
pub story_id: String,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct TestRunSummaryResponse {
|
||||
pub total: usize,
|
||||
pub passed: usize,
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct AcceptanceResponse {
|
||||
pub can_accept: bool,
|
||||
pub reasons: Vec<String>,
|
||||
pub warning: Option<String>,
|
||||
pub summary: TestRunSummaryResponse,
|
||||
pub missing_categories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct ReviewStory {
|
||||
pub story_id: String,
|
||||
pub can_accept: bool,
|
||||
pub reasons: Vec<String>,
|
||||
pub warning: Option<String>,
|
||||
pub summary: TestRunSummaryResponse,
|
||||
pub missing_categories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct ReviewListResponse {
|
||||
pub stories: Vec<ReviewStory>,
|
||||
}
|
||||
|
||||
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let current_dir = root.join(".story_kit").join("stories").join("current");
|
||||
|
||||
if !current_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut stories = Vec::new();
|
||||
for entry in fs::read_dir(¤t_dir)
|
||||
.map_err(|e| format!("Failed to read current stories directory: {e}"))?
|
||||
{
|
||||
let entry = entry.map_err(|e| format!("Failed to read current story entry: {e}"))?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
let story_id = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.ok_or_else(|| "Invalid story file name.".to_string())?
|
||||
.to_string();
|
||||
let contents = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?;
|
||||
let metadata = parse_front_matter(&contents)
|
||||
.map_err(|e| format!("Failed to parse front matter for {story_id}: {e:?}"))?;
|
||||
stories.push((story_id, metadata));
|
||||
}
|
||||
|
||||
Ok(stories)
|
||||
}
|
||||
|
||||
fn to_review_story(story_id: &str, results: &StoryTestResults) -> ReviewStory {
|
||||
let decision = evaluate_acceptance(results);
|
||||
let summary = summarize_results(results);
|
||||
|
||||
let mut missing_categories = Vec::new();
|
||||
let mut reasons = decision.reasons;
|
||||
|
||||
if results.unit.is_empty() {
|
||||
missing_categories.push("unit".to_string());
|
||||
reasons.push("Missing unit test results.".to_string());
|
||||
}
|
||||
if results.integration.is_empty() {
|
||||
missing_categories.push("integration".to_string());
|
||||
reasons.push("Missing integration test results.".to_string());
|
||||
}
|
||||
|
||||
let can_accept = decision.can_accept && missing_categories.is_empty();
|
||||
|
||||
ReviewStory {
|
||||
story_id: story_id.to_string(),
|
||||
can_accept,
|
||||
reasons,
|
||||
warning: decision.warning,
|
||||
summary: TestRunSummaryResponse {
|
||||
total: summary.total,
|
||||
passed: summary.passed,
|
||||
failed: summary.failed,
|
||||
},
|
||||
missing_categories,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WorkflowApi {
|
||||
pub ctx: Arc<AppContext>,
|
||||
}
|
||||
|
||||
#[OpenApi(tag = "WorkflowTags::Workflow")]
|
||||
impl WorkflowApi {
|
||||
/// Record test results for a story (unit + integration).
|
||||
#[oai(path = "/workflow/tests/record", method = "post")]
|
||||
async fn record_tests(&self, payload: Json<RecordTestsPayload>) -> OpenApiResult<Json<bool>> {
|
||||
let unit = payload
|
||||
.0
|
||||
.unit
|
||||
.into_iter()
|
||||
.map(to_test_case)
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
.map_err(bad_request)?;
|
||||
let integration = payload
|
||||
.0
|
||||
.integration
|
||||
.into_iter()
|
||||
.map(to_test_case)
|
||||
.collect::<Result<Vec<_>, String>>()
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let mut workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
workflow
|
||||
.record_test_results_validated(payload.0.story_id, unit, integration)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
/// Evaluate acceptance readiness for a story.
|
||||
#[oai(path = "/workflow/acceptance", method = "post")]
|
||||
async fn acceptance(
|
||||
&self,
|
||||
payload: Json<AcceptanceRequest>,
|
||||
) -> OpenApiResult<Json<AcceptanceResponse>> {
|
||||
let results = {
|
||||
let workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
workflow
|
||||
.results
|
||||
.get(&payload.0.story_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let decision = evaluate_acceptance(&results);
|
||||
let summary = summarize_results(&results);
|
||||
|
||||
let mut missing_categories = Vec::new();
|
||||
let mut reasons = decision.reasons;
|
||||
|
||||
if results.unit.is_empty() {
|
||||
missing_categories.push("unit".to_string());
|
||||
reasons.push("Missing unit test results.".to_string());
|
||||
}
|
||||
if results.integration.is_empty() {
|
||||
missing_categories.push("integration".to_string());
|
||||
reasons.push("Missing integration test results.".to_string());
|
||||
}
|
||||
|
||||
let can_accept = decision.can_accept && missing_categories.is_empty();
|
||||
|
||||
Ok(Json(AcceptanceResponse {
|
||||
can_accept,
|
||||
reasons,
|
||||
warning: decision.warning,
|
||||
summary: TestRunSummaryResponse {
|
||||
total: summary.total,
|
||||
passed: summary.passed,
|
||||
failed: summary.failed,
|
||||
},
|
||||
missing_categories,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List stories that are ready for human review.
|
||||
#[oai(path = "/workflow/review", method = "get")]
|
||||
async fn review_queue(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
||||
let stories = {
|
||||
let workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
workflow
|
||||
.results
|
||||
.iter()
|
||||
.map(|(story_id, results)| to_review_story(story_id, results))
|
||||
.filter(|story| story.can_accept)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
Ok(Json(ReviewListResponse { stories }))
|
||||
}
|
||||
|
||||
/// List stories in the review queue, including blocked items and current stories.
|
||||
#[oai(path = "/workflow/review/all", method = "get")]
|
||||
async fn review_queue_all(&self) -> OpenApiResult<Json<ReviewListResponse>> {
|
||||
let current_stories =
|
||||
load_current_story_metadata(self.ctx.as_ref()).map_err(bad_request)?;
|
||||
let stories = {
|
||||
let mut workflow = self
|
||||
.ctx
|
||||
.workflow
|
||||
.lock()
|
||||
.map_err(|e| bad_request(e.to_string()))?;
|
||||
|
||||
if !current_stories.is_empty() {
|
||||
workflow.load_story_metadata(current_stories);
|
||||
}
|
||||
|
||||
let mut story_ids = BTreeSet::new();
|
||||
|
||||
for story_id in workflow.results.keys() {
|
||||
story_ids.insert(story_id.clone());
|
||||
}
|
||||
for story_id in workflow.stories.keys() {
|
||||
story_ids.insert(story_id.clone());
|
||||
}
|
||||
|
||||
story_ids
|
||||
.into_iter()
|
||||
.map(|story_id| {
|
||||
let results = workflow.results.get(&story_id).cloned().unwrap_or_default();
|
||||
to_review_story(&story_id, &results)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
Ok(Json(ReviewListResponse { stories }))
|
||||
}
|
||||
|
||||
/// Ensure a story can be accepted; returns an error when gates fail.
|
||||
#[oai(path = "/workflow/acceptance/ensure", method = "post")]
|
||||
async fn ensure_acceptance(
|
||||
&self,
|
||||
payload: Json<AcceptanceRequest>,
|
||||
) -> OpenApiResult<Json<bool>> {
|
||||
let response = self.acceptance(payload).await?.0;
|
||||
if response.can_accept {
|
||||
return Ok(Json(true));
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
if !response.reasons.is_empty() {
|
||||
parts.push(response.reasons.join("; "));
|
||||
}
|
||||
if let Some(warning) = response.warning {
|
||||
parts.push(warning);
|
||||
}
|
||||
|
||||
let message = if parts.is_empty() {
|
||||
"Acceptance is blocked.".to_string()
|
||||
} else {
|
||||
format!("Acceptance is blocked: {}", parts.join("; "))
|
||||
};
|
||||
|
||||
Err(bad_request(message))
|
||||
}
|
||||
}
|
||||
|
||||
fn to_test_case(input: TestCasePayload) -> Result<TestCaseResult, String> {
|
||||
let status = parse_test_status(&input.status)?;
|
||||
Ok(TestCaseResult {
|
||||
name: input.name,
|
||||
status,
|
||||
details: input.details,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_test_status(value: &str) -> Result<TestStatus, String> {
|
||||
match value {
|
||||
"pass" => Ok(TestStatus::Pass),
|
||||
"fail" => Ok(TestStatus::Fail),
|
||||
other => Err(format!(
|
||||
"Invalid test status '{other}'. Use 'pass' or 'fail'."
|
||||
)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user