Merge story-31: View Upcoming Stories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> # Conflicts: # frontend/src/api/workflow.ts # frontend/src/components/Chat.test.tsx # frontend/src/components/Chat.tsx # server/src/http/workflow.rs
This commit is contained in:
@@ -13,6 +13,21 @@ pub struct AppContext {
|
||||
pub agents: Arc<AgentPool>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl AppContext {
|
||||
pub fn new_test(project_root: std::path::PathBuf) -> Self {
|
||||
let state = SessionState::default();
|
||||
*state.project_root.lock().unwrap() = Some(project_root.clone());
|
||||
let store_path = project_root.join(".story_kit_store.json");
|
||||
Self {
|
||||
state: Arc::new(state),
|
||||
store: Arc::new(JsonFileStore::new(store_path).unwrap()),
|
||||
workflow: Arc::new(std::sync::Mutex::new(WorkflowState::default())),
|
||||
agents: Arc::new(AgentPool::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type OpenApiResult<T> = poem::Result<T>;
|
||||
|
||||
pub fn bad_request(message: String) -> poem::Error {
|
||||
|
||||
@@ -99,6 +99,51 @@ struct TodoListResponse {
|
||||
pub stories: Vec<StoryTodosResponse>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct UpcomingStory {
|
||||
pub story_id: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Object)]
|
||||
struct UpcomingStoriesResponse {
|
||||
pub stories: Vec<UpcomingStory>,
|
||||
}
|
||||
|
||||
fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, 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<Vec<(String, StoryMetadata)>, String> {
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let current_dir = root.join(".story_kit").join("stories").join("current");
|
||||
@@ -460,6 +505,13 @@ impl WorkflowApi {
|
||||
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<Json<UpcomingStoriesResponse>> {
|
||||
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(
|
||||
@@ -611,4 +663,57 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user