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, } #[derive(Deserialize, Object)] struct RecordTestsPayload { pub story_id: String, pub unit: Vec, pub integration: Vec, } #[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, pub warning: Option, pub summary: TestRunSummaryResponse, pub missing_categories: Vec, } #[derive(Object)] struct ReviewStory { pub story_id: String, pub can_accept: bool, pub reasons: Vec, pub warning: Option, pub summary: TestRunSummaryResponse, pub missing_categories: Vec, } #[derive(Object)] struct ReviewListResponse { pub stories: Vec, } fn load_current_story_metadata(ctx: &AppContext) -> Result, 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, } #[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) -> OpenApiResult> { let unit = payload .0 .unit .into_iter() .map(to_test_case) .collect::, String>>() .map_err(bad_request)?; let integration = payload .0 .integration .into_iter() .map(to_test_case) .collect::, 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, ) -> OpenApiResult> { 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> { 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::>() }; 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> { 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::>() }; 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, ) -> OpenApiResult> { 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 { let status = parse_test_status(&input.status)?; Ok(TestCaseResult { name: input.name, status, details: input.details, }) } fn parse_test_status(value: &str) -> Result { match value { "pass" => Ok(TestStatus::Pass), "fail" => Ok(TestStatus::Fail), other => Err(format!( "Invalid test status '{other}'. Use 'pass' or 'fail'." )), } }