use crate::http::context::{AppContext, OpenApiResult, bad_request}; use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos}; use crate::workflow::{ CoverageReport, StoryTestResults, TestCaseResult, TestStatus, evaluate_acceptance_with_coverage, parse_coverage_json, 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 CoverageReportResponse { pub current_percent: f64, pub threshold_percent: f64, pub baseline_percent: Option, } #[derive(Object)] struct AcceptanceResponse { pub can_accept: bool, pub reasons: Vec, pub warning: Option, pub summary: TestRunSummaryResponse, pub missing_categories: Vec, pub coverage_report: Option, } #[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, pub coverage_report: Option, } #[derive(Deserialize, Object)] struct RecordCoveragePayload { pub story_id: String, pub current_percent: f64, pub threshold_percent: Option, } #[derive(Deserialize, Object)] struct CollectCoverageRequest { pub story_id: String, pub threshold_percent: Option, } #[derive(Object)] struct ReviewListResponse { pub stories: Vec, } #[derive(Object)] struct StoryTodosResponse { pub story_id: String, pub story_name: Option, pub todos: Vec, } #[derive(Object)] struct TodoListResponse { pub stories: Vec, } #[derive(Object)] struct UpcomingStory { pub story_id: String, pub name: Option, } #[derive(Object)] struct UpcomingStoriesResponse { pub stories: Vec, } fn load_upcoming_stories(ctx: &AppContext) -> Result, String> { let root = ctx.state.get_project_root()?; let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming"); if !upcoming_dir.exists() { return Ok(Vec::new()); } let mut stories = Vec::new(); for entry in fs::read_dir(&upcoming_dir) .map_err(|e| format!("Failed to read upcoming stories directory: {e}"))? { let entry = entry.map_err(|e| format!("Failed to read upcoming 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 name = parse_front_matter(&contents) .ok() .and_then(|meta| meta.name); stories.push(UpcomingStory { story_id, name }); } stories.sort_by(|a, b| a.story_id.cmp(&b.story_id)); Ok(stories) } 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, coverage: Option<&CoverageReport>, ) -> ReviewStory { let decision = evaluate_acceptance_with_coverage(results, coverage); 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(); let coverage_report = coverage.map(|c| CoverageReportResponse { current_percent: c.current_percent, threshold_percent: c.threshold_percent, baseline_percent: c.baseline_percent, }); 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, coverage_report, } } 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, coverage) = { let workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; let results = workflow .results .get(&payload.0.story_id) .cloned() .unwrap_or_default(); let coverage = workflow.coverage.get(&payload.0.story_id).cloned(); (results, coverage) }; let decision = evaluate_acceptance_with_coverage(&results, coverage.as_ref()); 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(); let coverage_report = coverage.map(|c| CoverageReportResponse { current_percent: c.current_percent, threshold_percent: c.threshold_percent, baseline_percent: c.baseline_percent, }); Ok(Json(AcceptanceResponse { can_accept, reasons, warning: decision.warning, summary: TestRunSummaryResponse { total: summary.total, passed: summary.passed, failed: summary.failed, }, missing_categories, coverage_report, })) } /// 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)| { let coverage = workflow.coverage.get(story_id); to_review_story(story_id, results, coverage) }) .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(); let coverage = workflow.coverage.get(&story_id); to_review_story(&story_id, &results, coverage) }) .collect::>() }; Ok(Json(ReviewListResponse { stories })) } /// Record coverage data for a story. #[oai(path = "/workflow/coverage/record", method = "post")] async fn record_coverage( &self, payload: Json, ) -> OpenApiResult> { let mut workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; workflow.record_coverage( payload.0.story_id, payload.0.current_percent, payload.0.threshold_percent, ); Ok(Json(true)) } /// Run coverage collection: execute test:coverage, parse output, record result. #[oai(path = "/workflow/coverage/collect", method = "post")] async fn collect_coverage( &self, payload: Json, ) -> OpenApiResult> { let root = self .ctx .state .get_project_root() .map_err(bad_request)?; let frontend_dir = root.join("frontend"); // Run pnpm run test:coverage in the frontend directory let output = tokio::task::spawn_blocking(move || { std::process::Command::new("pnpm") .args(["run", "test:coverage"]) .current_dir(&frontend_dir) .output() }) .await .map_err(|e| bad_request(format!("Task join error: {e}")))? .map_err(|e| bad_request(format!("Failed to run coverage command: {e}")))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(bad_request(format!("Coverage command failed: {stderr}"))); } // Read the coverage summary JSON let summary_path = root .join("frontend") .join("coverage") .join("coverage-summary.json"); let json_str = fs::read_to_string(&summary_path) .map_err(|e| bad_request(format!("Failed to read coverage summary: {e}")))?; let current_percent = parse_coverage_json(&json_str).map_err(bad_request)?; // Record coverage in workflow state let coverage_report = { let mut workflow = self .ctx .workflow .lock() .map_err(|e| bad_request(e.to_string()))?; workflow.record_coverage( payload.0.story_id.clone(), current_percent, payload.0.threshold_percent, ); workflow .coverage .get(&payload.0.story_id) .cloned() .expect("just inserted") }; Ok(Json(CoverageReportResponse { current_percent: coverage_report.current_percent, threshold_percent: coverage_report.threshold_percent, baseline_percent: coverage_report.baseline_percent, })) } /// List unchecked acceptance criteria (TODOs) for all current stories. #[oai(path = "/workflow/todos", method = "get")] async fn story_todos(&self) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; let current_dir = root.join(".story_kit").join("stories").join("current"); if !current_dir.exists() { return Ok(Json(TodoListResponse { stories: Vec::new(), })); } let mut stories = Vec::new(); let mut entries: Vec<_> = fs::read_dir(¤t_dir) .map_err(|e| bad_request(format!("Failed to read current stories: {e}")))? .filter_map(|e| e.ok()) .collect(); entries.sort_by_key(|e| e.file_name()); for entry in entries { 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()) .unwrap_or_default() .to_string(); let contents = fs::read_to_string(&path) .map_err(|e| bad_request(format!("Failed to read {}: {e}", path.display())))?; let story_name = parse_front_matter(&contents) .ok() .and_then(|m| m.name); let todos = parse_unchecked_todos(&contents); stories.push(StoryTodosResponse { story_id, story_name, todos, }); } Ok(Json(TodoListResponse { stories })) } /// List upcoming stories from .story_kit/stories/upcoming/. #[oai(path = "/workflow/upcoming", method = "get")] async fn list_upcoming_stories(&self) -> OpenApiResult> { let stories = load_upcoming_stories(self.ctx.as_ref()).map_err(bad_request)?; Ok(Json(UpcomingStoriesResponse { 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'." )), } } #[cfg(test)] mod tests { use super::*; use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; #[test] fn parse_test_status_pass() { assert_eq!(parse_test_status("pass").unwrap(), TestStatus::Pass); } #[test] fn parse_test_status_fail() { assert_eq!(parse_test_status("fail").unwrap(), TestStatus::Fail); } #[test] fn parse_test_status_invalid() { let result = parse_test_status("unknown"); assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid test status")); } #[test] fn to_test_case_converts_pass() { let payload = TestCasePayload { name: "my_test".to_string(), status: "pass".to_string(), details: Some("all good".to_string()), }; let result = to_test_case(payload).unwrap(); assert_eq!(result.name, "my_test"); assert_eq!(result.status, TestStatus::Pass); assert_eq!(result.details, Some("all good".to_string())); } #[test] fn to_test_case_rejects_invalid_status() { let payload = TestCasePayload { name: "bad".to_string(), status: "maybe".to_string(), details: None, }; assert!(to_test_case(payload).is_err()); } #[test] fn to_review_story_all_passing() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None, }], integration: vec![TestCaseResult { name: "i1".to_string(), status: TestStatus::Pass, details: None, }], }; let review = to_review_story("story-29", &results, None); assert!(review.can_accept); assert!(review.reasons.is_empty()); assert!(review.missing_categories.is_empty()); assert_eq!(review.summary.total, 2); assert_eq!(review.summary.passed, 2); } #[test] fn to_review_story_missing_integration() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Pass, details: None, }], integration: vec![], }; let review = to_review_story("story-29", &results, None); assert!(!review.can_accept); assert!(review.missing_categories.contains(&"integration".to_string())); } #[test] fn to_review_story_with_failures() { let results = StoryTestResults { unit: vec![TestCaseResult { name: "u1".to_string(), status: TestStatus::Fail, details: None, }], integration: vec![TestCaseResult { name: "i1".to_string(), status: TestStatus::Pass, details: None, }], }; let review = to_review_story("story-29", &results, None); assert!(!review.can_accept); assert_eq!(review.summary.failed, 1); } #[test] fn load_upcoming_returns_empty_when_no_dir() { let tmp = tempfile::tempdir().unwrap(); let root = tmp.path().to_path_buf(); // No .story_kit directory at all let ctx = crate::http::context::AppContext::new_test(root); let result = load_upcoming_stories(&ctx).unwrap(); assert!(result.is_empty()); } #[test] fn load_upcoming_parses_metadata() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/stories/upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write( upcoming.join("31_view_upcoming.md"), "---\nname: View Upcoming\ntest_plan: pending\n---\n# Story\n", ) .unwrap(); fs::write( upcoming.join("32_worktree.md"), "---\nname: Worktree Orchestration\ntest_plan: pending\n---\n# Story\n", ) .unwrap(); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let stories = load_upcoming_stories(&ctx).unwrap(); assert_eq!(stories.len(), 2); assert_eq!(stories[0].story_id, "31_view_upcoming"); assert_eq!(stories[0].name.as_deref(), Some("View Upcoming")); assert_eq!(stories[1].story_id, "32_worktree"); assert_eq!(stories[1].name.as_deref(), Some("Worktree Orchestration")); } #[test] fn load_upcoming_skips_non_md_files() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/stories/upcoming"); fs::create_dir_all(&upcoming).unwrap(); fs::write(upcoming.join(".gitkeep"), "").unwrap(); fs::write( upcoming.join("31_story.md"), "---\nname: A Story\ntest_plan: pending\n---\n", ) .unwrap(); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let stories = load_upcoming_stories(&ctx).unwrap(); assert_eq!(stories.len(), 1); assert_eq!(stories[0].story_id, "31_story"); } }