Files
storkit/server/src/http/workflow.rs

558 lines
17 KiB
Rust
Raw Normal View History

use crate::http::context::{AppContext, OpenApiResult, bad_request};
use crate::io::story_metadata::{StoryMetadata, parse_front_matter};
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<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 CoverageReportResponse {
pub current_percent: f64,
pub threshold_percent: f64,
pub baseline_percent: Option<f64>,
}
#[derive(Object)]
struct AcceptanceResponse {
pub can_accept: bool,
pub reasons: Vec<String>,
pub warning: Option<String>,
pub summary: TestRunSummaryResponse,
pub missing_categories: Vec<String>,
pub coverage_report: Option<CoverageReportResponse>,
}
#[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>,
pub coverage_report: Option<CoverageReportResponse>,
}
#[derive(Deserialize, Object)]
struct RecordCoveragePayload {
pub story_id: String,
pub current_percent: f64,
pub threshold_percent: Option<f64>,
}
#[derive(Deserialize, Object)]
struct CollectCoverageRequest {
pub story_id: String,
pub threshold_percent: Option<f64>,
}
#[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(&current_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<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, 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<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)| {
let coverage = workflow.coverage.get(story_id);
to_review_story(story_id, results, coverage)
})
.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();
let coverage = workflow.coverage.get(&story_id);
to_review_story(&story_id, &results, coverage)
})
.collect::<Vec<_>>()
};
Ok(Json(ReviewListResponse { stories }))
}
/// Record coverage data for a story.
#[oai(path = "/workflow/coverage/record", method = "post")]
async fn record_coverage(
&self,
payload: Json<RecordCoveragePayload>,
) -> OpenApiResult<Json<bool>> {
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<CollectCoverageRequest>,
) -> OpenApiResult<Json<CoverageReportResponse>> {
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,
}))
}
/// 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'."
)),
}
}
#[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);
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);
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);
assert!(!review.can_accept);
assert_eq!(review.summary.failed, 1);
}
}