story-kit: merge 224_story_expand_work_item_to_full_screen_detail_view
This commit is contained in:
@@ -101,6 +101,12 @@ export interface Message {
|
|||||||
tool_call_id?: string;
|
tool_call_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkItemContent {
|
||||||
|
content: string;
|
||||||
|
stage: string;
|
||||||
|
name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
name: string;
|
name: string;
|
||||||
kind: "file" | "dir";
|
kind: "file" | "dir";
|
||||||
@@ -267,6 +273,13 @@ export const api = {
|
|||||||
cancelChat(baseUrl?: string) {
|
cancelChat(baseUrl?: string) {
|
||||||
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
return requestJson<boolean>("/chat/cancel", { method: "POST" }, baseUrl);
|
||||||
},
|
},
|
||||||
|
getWorkItemContent(storyId: string, baseUrl?: string) {
|
||||||
|
return requestJson<WorkItemContent>(
|
||||||
|
`/work-items/${encodeURIComponent(storyId)}`,
|
||||||
|
{},
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export class ChatWebSocket {
|
export class ChatWebSocket {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { ChatInput } from "./ChatInput";
|
|||||||
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
import { LozengeFlyProvider } from "./LozengeFlyContext";
|
||||||
import { MessageItem } from "./MessageItem";
|
import { MessageItem } from "./MessageItem";
|
||||||
import { StagePanel } from "./StagePanel";
|
import { StagePanel } from "./StagePanel";
|
||||||
|
import { WorkItemDetailPanel } from "./WorkItemDetailPanel";
|
||||||
|
|
||||||
const { useCallback, useEffect, useMemo, useRef, useState } = React;
|
const { useCallback, useEffect, useMemo, useRef, useState } = React;
|
||||||
|
|
||||||
@@ -189,6 +190,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
const [agentStateVersion, setAgentStateVersion] = useState(0);
|
const [agentStateVersion, setAgentStateVersion] = useState(0);
|
||||||
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
const [needsOnboarding, setNeedsOnboarding] = useState(false);
|
||||||
const onboardingTriggeredRef = useRef(false);
|
const onboardingTriggeredRef = useRef(false);
|
||||||
|
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [queuedMessages, setQueuedMessages] = useState<
|
const [queuedMessages, setQueuedMessages] = useState<
|
||||||
{ id: string; text: string }[]
|
{ id: string; text: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -879,16 +883,45 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LozengeFlyProvider pipeline={pipeline}>
|
<LozengeFlyProvider pipeline={pipeline}>
|
||||||
<AgentPanel
|
{selectedWorkItemId ? (
|
||||||
configVersion={agentConfigVersion}
|
<WorkItemDetailPanel
|
||||||
stateVersion={agentStateVersion}
|
storyId={selectedWorkItemId}
|
||||||
/>
|
onClose={() => setSelectedWorkItemId(null)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AgentPanel
|
||||||
|
configVersion={agentConfigVersion}
|
||||||
|
stateVersion={agentStateVersion}
|
||||||
|
/>
|
||||||
|
|
||||||
<StagePanel title="Done" items={pipeline.done ?? []} />
|
<StagePanel
|
||||||
<StagePanel title="To Merge" items={pipeline.merge} />
|
title="Done"
|
||||||
<StagePanel title="QA" items={pipeline.qa} />
|
items={pipeline.done ?? []}
|
||||||
<StagePanel title="Current" items={pipeline.current} />
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
<StagePanel title="Upcoming" items={pipeline.upcoming} />
|
/>
|
||||||
|
<StagePanel
|
||||||
|
title="To Merge"
|
||||||
|
items={pipeline.merge}
|
||||||
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
|
/>
|
||||||
|
<StagePanel
|
||||||
|
title="QA"
|
||||||
|
items={pipeline.qa}
|
||||||
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
|
/>
|
||||||
|
<StagePanel
|
||||||
|
title="Current"
|
||||||
|
items={pipeline.current}
|
||||||
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
|
/>
|
||||||
|
<StagePanel
|
||||||
|
title="Upcoming"
|
||||||
|
items={pipeline.upcoming}
|
||||||
|
onItemClick={(item) => setSelectedWorkItemId(item.story_id)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</LozengeFlyProvider>
|
</LozengeFlyProvider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface StagePanelProps {
|
|||||||
title: string;
|
title: string;
|
||||||
items: PipelineStageItem[];
|
items: PipelineStageItem[];
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
onItemClick?: (item: PipelineStageItem) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentLozenge({
|
function AgentLozenge({
|
||||||
@@ -119,6 +120,7 @@ export function StagePanel({
|
|||||||
title,
|
title,
|
||||||
items,
|
items,
|
||||||
emptyMessage = "Empty.",
|
emptyMessage = "Empty.",
|
||||||
|
onItemClick,
|
||||||
}: StagePanelProps) {
|
}: StagePanelProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -189,6 +191,20 @@ export function StagePanel({
|
|||||||
gap: "2px",
|
gap: "2px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
const cardStyle = {
|
||||||
|
border: item.agent ? "1px solid #2a3a4a" : "1px solid #2a2a2a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: item.agent ? "#161e2a" : "#191919",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column" as const,
|
||||||
|
gap: "2px",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left" as const,
|
||||||
|
cursor: onItemClick ? "pointer" : "default",
|
||||||
|
};
|
||||||
|
const cardInner = (
|
||||||
|
<>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
|
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
|
||||||
{hasMergeFailure && (
|
{hasMergeFailure && (
|
||||||
@@ -260,6 +276,25 @@ export function StagePanel({
|
|||||||
{item.agent && (
|
{item.agent && (
|
||||||
<AgentLozenge agent={item.agent} storyId={item.story_id} />
|
<AgentLozenge agent={item.agent} storyId={item.story_id} />
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return onItemClick ? (
|
||||||
|
<button
|
||||||
|
key={`${title}-${item.story_id}`}
|
||||||
|
type="button"
|
||||||
|
data-testid={`card-${item.story_id}`}
|
||||||
|
onClick={() => onItemClick(item)}
|
||||||
|
style={cardStyle}
|
||||||
|
>
|
||||||
|
{cardInner}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={`${title}-${item.story_id}`}
|
||||||
|
data-testid={`card-${item.story_id}`}
|
||||||
|
style={cardStyle}
|
||||||
|
>
|
||||||
|
{cardInner}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
217
frontend/src/components/WorkItemDetailPanel.tsx
Normal file
217
frontend/src/components/WorkItemDetailPanel.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import Markdown from "react-markdown";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
|
||||||
|
const { useEffect, useRef, useState } = React;
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
upcoming: "Upcoming",
|
||||||
|
current: "Current",
|
||||||
|
qa: "QA",
|
||||||
|
merge: "To Merge",
|
||||||
|
done: "Done",
|
||||||
|
archived: "Archived",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WorkItemDetailPanelProps {
|
||||||
|
storyId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkItemDetailPanel({
|
||||||
|
storyId,
|
||||||
|
onClose,
|
||||||
|
}: WorkItemDetailPanelProps) {
|
||||||
|
const [content, setContent] = useState<string | null>(null);
|
||||||
|
const [stage, setStage] = useState<string>("");
|
||||||
|
const [name, setName] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
api
|
||||||
|
.getWorkItemContent(storyId)
|
||||||
|
.then((data) => {
|
||||||
|
setContent(data.content);
|
||||||
|
setStage(data.stage);
|
||||||
|
setName(data.name);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load content");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [storyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const stageLabel = STAGE_LABELS[stage] ?? stage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="work-item-detail-panel"
|
||||||
|
ref={panelRef}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
background: "#1a1a1a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderBottom: "1px solid #333",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "2px",
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-testid="detail-panel-title"
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.95em",
|
||||||
|
color: "#ececec",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name ?? storyId}
|
||||||
|
</div>
|
||||||
|
{stage && (
|
||||||
|
<div
|
||||||
|
data-testid="detail-panel-stage"
|
||||||
|
style={{ fontSize: "0.75em", color: "#888" }}
|
||||||
|
>
|
||||||
|
{stageLabel}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="detail-panel-close"
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#aaa",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: "4px 10px",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable content area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "16px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<div
|
||||||
|
data-testid="detail-panel-loading"
|
||||||
|
style={{ color: "#666", fontSize: "0.85em" }}
|
||||||
|
>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
data-testid="detail-panel-error"
|
||||||
|
style={{ color: "#ff7b72", fontSize: "0.85em" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && content !== null && (
|
||||||
|
<div
|
||||||
|
data-testid="detail-panel-content"
|
||||||
|
className="markdown-body"
|
||||||
|
style={{ fontSize: "0.9em", lineHeight: 1.6 }}
|
||||||
|
>
|
||||||
|
<Markdown>{content}</Markdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Placeholder sections for future content */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ id: "agent-logs", label: "Agent Logs" },
|
||||||
|
{ id: "test-output", label: "Test Output" },
|
||||||
|
{ id: "coverage", label: "Coverage" },
|
||||||
|
] as { id: string; label: string }[]
|
||||||
|
).map(({ id, label }) => (
|
||||||
|
<div
|
||||||
|
key={id}
|
||||||
|
data-testid={`placeholder-${id}`}
|
||||||
|
style={{
|
||||||
|
border: "1px solid #2a2a2a",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "10px 12px",
|
||||||
|
background: "#161616",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "0.8em",
|
||||||
|
color: "#555",
|
||||||
|
marginBottom: "4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "0.75em", color: "#444" }}>
|
||||||
|
Coming soon
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::config::ProjectConfig;
|
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 crate::worktree;
|
||||||
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
|
use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -61,6 +61,14 @@ struct WorktreeListEntry {
|
|||||||
path: String,
|
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/`.
|
/// 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`
|
/// 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.
|
/// Remove a git worktree and its feature branch for a story.
|
||||||
#[oai(path = "/agents/worktrees/:story_id", method = "delete")]
|
#[oai(path = "/agents/worktrees/:story_id", method = "delete")]
|
||||||
async fn remove_worktree(&self, story_id: Path<String>) -> OpenApiResult<Json<bool>> {
|
async fn remove_worktree(&self, story_id: Path<String>) -> OpenApiResult<Json<bool>> {
|
||||||
@@ -627,6 +681,86 @@ allowed_tools = ["Read", "Bash"]
|
|||||||
assert!(result.is_err());
|
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 ---
|
// --- create_worktree error path ---
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ pub fn bad_request(message: String) -> poem::Error {
|
|||||||
poem::Error::from_string(message, StatusCode::BAD_REQUEST)
|
poem::Error::from_string(message, StatusCode::BAD_REQUEST)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn not_found(message: String) -> poem::Error {
|
||||||
|
poem::Error::from_string(message, StatusCode::NOT_FOUND)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -102,5 +106,8 @@ mod tests {
|
|||||||
assert_eq!(PermissionDecision::AlwaysAllow, PermissionDecision::AlwaysAllow);
|
assert_eq!(PermissionDecision::AlwaysAllow, PermissionDecision::AlwaysAllow);
|
||||||
assert_ne!(PermissionDecision::Deny, PermissionDecision::Approve);
|
assert_ne!(PermissionDecision::Deny, PermissionDecision::Approve);
|
||||||
assert_ne!(PermissionDecision::Approve, PermissionDecision::AlwaysAllow);
|
assert_ne!(PermissionDecision::Approve, PermissionDecision::AlwaysAllow);
|
||||||
|
fn not_found_returns_404_status() {
|
||||||
|
let err = not_found("item not found".to_string());
|
||||||
|
assert_eq!(err.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user