Story 31: View Upcoming Stories

Add GET /workflow/upcoming endpoint that reads .story_kit/stories/upcoming/
and returns story IDs with names parsed from frontmatter. Add UpcomingPanel
component wired into Chat view with loading, error, empty, and list states.

12 new tests (3 backend, 9 frontend) all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 15:51:12 +00:00
parent 644644d5b3
commit 939387104b
12 changed files with 505 additions and 18 deletions

View File

@@ -87,6 +87,51 @@ struct ReviewListResponse {
pub stories: Vec<ReviewStory>,
}
#[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");
@@ -403,6 +448,13 @@ impl WorkflowApi {
}))
}
/// 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(
@@ -554,4 +606,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");
}
}