320 lines
9.5 KiB
Rust
320 lines
9.5 KiB
Rust
|
|
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'."
|
||
|
|
)),
|
||
|
|
}
|
||
|
|
}
|