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:
Dave
2026-02-19 15:54:02 +00:00
11 changed files with 521 additions and 2 deletions

View File

@@ -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 {

View File

@@ -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");
}
}