story-kit: merge 224_story_expand_work_item_to_full_screen_detail_view
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
||||
use crate::http::context::{AppContext, OpenApiResult, bad_request, not_found};
|
||||
use crate::worktree;
|
||||
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
|
||||
use serde::Serialize;
|
||||
@@ -61,6 +61,14 @@ struct WorktreeListEntry {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Response for the work item content endpoint.
|
||||
#[derive(Object, Serialize)]
|
||||
struct WorkItemContentResponse {
|
||||
content: String,
|
||||
stage: String,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
/// Returns true if the story file exists in `work/5_done/` or `work/6_archived/`.
|
||||
///
|
||||
/// Used to exclude agents for already-archived stories from the `list_agents`
|
||||
@@ -272,6 +280,52 @@ impl AgentsApi {
|
||||
))
|
||||
}
|
||||
|
||||
/// Get the markdown content of a work item by its story_id.
|
||||
///
|
||||
/// Searches all active pipeline stages for the file and returns its content
|
||||
/// along with the stage it was found in.
|
||||
#[oai(path = "/work-items/:story_id", method = "get")]
|
||||
async fn get_work_item_content(
|
||||
&self,
|
||||
story_id: Path<String>,
|
||||
) -> OpenApiResult<Json<WorkItemContentResponse>> {
|
||||
let project_root = self
|
||||
.ctx
|
||||
.agents
|
||||
.get_project_root(&self.ctx.state)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let stages = [
|
||||
("1_upcoming", "upcoming"),
|
||||
("2_current", "current"),
|
||||
("3_qa", "qa"),
|
||||
("4_merge", "merge"),
|
||||
("5_done", "done"),
|
||||
("6_archived", "archived"),
|
||||
];
|
||||
|
||||
let work_dir = project_root.join(".story_kit").join("work");
|
||||
let filename = format!("{}.md", story_id.0);
|
||||
|
||||
for (stage_dir, stage_name) in &stages {
|
||||
let file_path = work_dir.join(stage_dir).join(&filename);
|
||||
if file_path.exists() {
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| bad_request(format!("Failed to read work item: {e}")))?;
|
||||
let name = crate::io::story_metadata::parse_front_matter(&content)
|
||||
.ok()
|
||||
.and_then(|m| m.name);
|
||||
return Ok(Json(WorkItemContentResponse {
|
||||
content,
|
||||
stage: stage_name.to_string(),
|
||||
name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Err(not_found(format!("Work item not found: {}", story_id.0)))
|
||||
}
|
||||
|
||||
/// Remove a git worktree and its feature branch for a story.
|
||||
#[oai(path = "/agents/worktrees/:story_id", method = "delete")]
|
||||
async fn remove_worktree(&self, story_id: Path<String>) -> OpenApiResult<Json<bool>> {
|
||||
@@ -627,6 +681,86 @@ allowed_tools = ["Read", "Bash"]
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- get_work_item_content tests ---
|
||||
|
||||
fn make_stage_dir(root: &path::Path, stage: &str) {
|
||||
std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_returns_content_from_upcoming() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
make_stage_dir(root, "1_upcoming");
|
||||
std::fs::write(
|
||||
root.join(".story_kit/work/1_upcoming/42_story_foo.md"),
|
||||
"---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.",
|
||||
)
|
||||
.unwrap();
|
||||
let ctx = AppContext::new_test(root.to_path_buf());
|
||||
let api = AgentsApi {
|
||||
ctx: Arc::new(ctx),
|
||||
};
|
||||
let result = api
|
||||
.get_work_item_content(Path("42_story_foo".to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert!(result.content.contains("Some content."));
|
||||
assert_eq!(result.stage, "upcoming");
|
||||
assert_eq!(result.name, Some("Foo Story".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_returns_content_from_current() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path();
|
||||
make_stage_dir(root, "2_current");
|
||||
std::fs::write(
|
||||
root.join(".story_kit/work/2_current/43_story_bar.md"),
|
||||
"---\nname: \"Bar Story\"\n---\n\nBar content.",
|
||||
)
|
||||
.unwrap();
|
||||
let ctx = AppContext::new_test(root.to_path_buf());
|
||||
let api = AgentsApi {
|
||||
ctx: Arc::new(ctx),
|
||||
};
|
||||
let result = api
|
||||
.get_work_item_content(Path("43_story_bar".to_string()))
|
||||
.await
|
||||
.unwrap()
|
||||
.0;
|
||||
assert_eq!(result.stage, "current");
|
||||
assert_eq!(result.name, Some("Bar Story".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_returns_not_found_when_absent() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
let api = AgentsApi {
|
||||
ctx: Arc::new(ctx),
|
||||
};
|
||||
let result = api
|
||||
.get_work_item_content(Path("99_story_nonexistent".to_string()))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_work_item_content_returns_error_when_no_project_root() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = AppContext::new_test(tmp.path().to_path_buf());
|
||||
*ctx.state.project_root.lock().unwrap() = None;
|
||||
let api = AgentsApi {
|
||||
ctx: Arc::new(ctx),
|
||||
};
|
||||
let result = api
|
||||
.get_work_item_content(Path("42_story_foo".to_string()))
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// --- create_worktree error path ---
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user