huskies: merge 983
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/** Tests for GatewayPanel — verifies story id and name rendering in the gateway aggregate view. */
|
||||
import { render } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PipelineItem } from "../api/gateway";
|
||||
import { StoryRow } from "./GatewayPanel";
|
||||
@@ -34,4 +34,74 @@ describe("StoryRow", () => {
|
||||
const { container } = render(<StoryRow item={item} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("shows RECOVERING badge for merge_failure item with running mergemaster", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "60_story_merge_recovering",
|
||||
name: "Merge Recovering",
|
||||
stage: "merge",
|
||||
merge_failure: "Squash merge failed",
|
||||
agent: { agent_name: "mergemaster", model: "claude", status: "running" },
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⟳ RECOVERING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows QUEUED badge for merge_failure item with pending mergemaster", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "61_story_merge_queued",
|
||||
name: "Merge Queued",
|
||||
stage: "merge",
|
||||
merge_failure: "Squash merge failed",
|
||||
agent: { agent_name: "mergemaster", model: "claude", status: "pending" },
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⏳ QUEUED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows FAILED badge for merge_failure item with no recovery agent", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "62_story_merge_final",
|
||||
name: "Merge Final",
|
||||
stage: "merge",
|
||||
merge_failure: "Squash merge failed",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("✕ FAILED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows RECOVERING badge for blocked item with running recovery agent", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "63_story_blocked_recovering",
|
||||
name: "Blocked Recovering",
|
||||
stage: "current",
|
||||
blocked: true,
|
||||
agent: { agent_name: "coder", model: "claude", status: "running" },
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⟳ RECOVERING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows QUEUED badge for blocked item with pending recovery agent", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "64_story_blocked_queued",
|
||||
name: "Blocked Queued",
|
||||
stage: "current",
|
||||
blocked: true,
|
||||
agent: { agent_name: "coder", model: "claude", status: "pending" },
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⏳ QUEUED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows BLOCKED badge for blocked item with no recovery agent", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "65_story_blocked_human",
|
||||
name: "Blocked Human",
|
||||
stage: "current",
|
||||
blocked: true,
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⊘ BLOCKED")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,70 +65,29 @@ const STAGE_LABELS: Record<string, string> = {
|
||||
/// A single story row inside a project pipeline card.
|
||||
/** Render one story row in a gateway-aggregate panel: `#<id> <name>` with stage badge. */
|
||||
export function StoryRow({ item }: { item: PipelineItem }) {
|
||||
const color = STAGE_COLORS[item.stage] ?? "#8b949e";
|
||||
const label = STAGE_LABELS[item.stage] ?? item.stage;
|
||||
const idNum = item.story_id.match(/^(\d+)/)?.[1];
|
||||
const agentStatus = item.agent?.status;
|
||||
const isStuck = item.blocked || (item.merge_failure != null && item.merge_failure !== "");
|
||||
const isStuck = item.merge_failure != null || item.blocked;
|
||||
|
||||
const recoveryBadge = isStuck
|
||||
? agentStatus === "running"
|
||||
? (
|
||||
<span
|
||||
data-testid={`recovery-badge-${item.story_id}`}
|
||||
title="Recovery in progress — no human action needed"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 700,
|
||||
color: "#e3b341",
|
||||
background: "#2a1e00",
|
||||
border: "1px solid #6e4f00",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 5px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
⟳ RECOVERING
|
||||
</span>
|
||||
)
|
||||
: agentStatus === "pending"
|
||||
? (
|
||||
<span
|
||||
data-testid={`recovery-badge-${item.story_id}`}
|
||||
title="Recovery scheduled — waiting for a slot"
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 700,
|
||||
color: "#e3b341",
|
||||
background: "#2a1e00",
|
||||
border: "1px solid #6e4f00",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 5px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
⏳ QUEUED
|
||||
</span>
|
||||
)
|
||||
: (
|
||||
<span
|
||||
data-testid={`recovery-badge-${item.story_id}`}
|
||||
title={item.merge_failure ? "Merge failed — needs human" : "Blocked — awaiting human unblock"}
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
fontWeight: 700,
|
||||
color: "#f85149",
|
||||
background: "#2a1010",
|
||||
border: "1px solid #6e1b1b",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 5px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
⊘ STUCK
|
||||
</span>
|
||||
)
|
||||
: null;
|
||||
let color: string;
|
||||
let label: string;
|
||||
|
||||
if (isStuck) {
|
||||
const agentStatus = item.agent?.status;
|
||||
if (agentStatus === "running") {
|
||||
color = "#e3b341";
|
||||
label = "⟳ RECOVERING";
|
||||
} else if (agentStatus === "pending") {
|
||||
color = "#e3b341";
|
||||
label = "⏳ QUEUED";
|
||||
} else {
|
||||
color = "#f85149";
|
||||
label = item.merge_failure != null ? "✕ FAILED" : "⊘ BLOCKED";
|
||||
}
|
||||
} else {
|
||||
color = STAGE_COLORS[item.stage] ?? "#8b949e";
|
||||
label = STAGE_LABELS[item.stage] ?? item.stage;
|
||||
}
|
||||
|
||||
const idNum = item.story_id.match(/^(\d+)/)?.[1];
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -153,7 +112,6 @@ export function StoryRow({ item }: { item: PipelineItem }) {
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{recoveryBadge}
|
||||
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{idNum && <span style={{ color: "#8b949e", fontFamily: "monospace" }}>#{idNum}{" "}</span>}
|
||||
{item.name}
|
||||
|
||||
@@ -391,4 +391,102 @@ describe("StagePanel", () => {
|
||||
screen.queryByTestId("merge-in-flight-icon-42_story_no_prop"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows spinning RECOVERING badge for blocked item with running recovery agent", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "50_story_blocked_recovering",
|
||||
name: "Blocked Recovering Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder", model: "claude", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
blocked: true,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Current" items={items} />);
|
||||
const badge = screen.getByTestId("blocked-badge-50_story_blocked_recovering");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge).toHaveTextContent("RECOVERING");
|
||||
});
|
||||
|
||||
it("shows QUEUED badge for blocked item with pending recovery agent", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "51_story_blocked_queued",
|
||||
name: "Blocked Queued Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: { agent_name: "coder", model: "claude", status: "pending" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
blocked: true,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Current" items={items} />);
|
||||
const badge = screen.getByTestId("blocked-badge-51_story_blocked_queued");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge).toHaveTextContent("QUEUED");
|
||||
});
|
||||
|
||||
it("shows red BLOCKED badge for blocked item with no recovery agent", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "52_story_blocked_human",
|
||||
name: "Blocked Human Story",
|
||||
error: null,
|
||||
merge_failure: null,
|
||||
agent: null,
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
blocked: true,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Current" items={items} />);
|
||||
const badge = screen.getByTestId("blocked-badge-52_story_blocked_human");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge).toHaveTextContent("BLOCKED");
|
||||
});
|
||||
|
||||
it("shows spinning icon for merge_failure item with running mergemaster", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "53_story_merge_recovering",
|
||||
name: "Merge Recovering Story",
|
||||
error: null,
|
||||
merge_failure: "Squash merge failed: conflicts",
|
||||
agent: { agent_name: "mergemaster", model: "claude", status: "running" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Merge" items={items} />);
|
||||
const icon = screen.getByTestId("merge-failure-icon-53_story_merge_recovering");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveTextContent("⟳");
|
||||
});
|
||||
|
||||
it("shows hourglass icon for merge_failure item with pending mergemaster", () => {
|
||||
const items: PipelineStageItem[] = [
|
||||
{
|
||||
story_id: "54_story_merge_queued",
|
||||
name: "Merge Queued Story",
|
||||
error: null,
|
||||
merge_failure: "Squash merge failed: conflicts",
|
||||
agent: { agent_name: "mergemaster", model: "claude", status: "pending" },
|
||||
review_hold: null,
|
||||
qa: null,
|
||||
depends_on: null,
|
||||
},
|
||||
];
|
||||
render(<StagePanel title="Merge" items={items} />);
|
||||
const icon = screen.getByTestId("merge-failure-icon-54_story_merge_queued");
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveTextContent("⏳");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -345,53 +345,54 @@ export function StagePanel({
|
||||
<>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: "0.9em" }}>
|
||||
{hasMergeFailure && (() => {
|
||||
const agentStatus = item.agent?.status;
|
||||
if (agentStatus === "running") {
|
||||
{hasMergeFailure &&
|
||||
(() => {
|
||||
const agentStatus = item.agent?.status;
|
||||
if (agentStatus === "running") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge recovery in progress — no human action needed"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
color: "#e3b341",
|
||||
marginRight: "6px",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
>
|
||||
⟳
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (agentStatus === "pending") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge recovery scheduled — waiting for a slot"
|
||||
style={{
|
||||
color: "#e3b341",
|
||||
marginRight: "6px",
|
||||
fontStyle: "normal",
|
||||
}}
|
||||
>
|
||||
⏳
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge recovery in progress — no human action needed"
|
||||
title="Merge failed — needs human"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
color: "#e3b341",
|
||||
marginRight: "6px",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
>
|
||||
⟳
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (agentStatus === "pending") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge recovery scheduled — waiting for a slot"
|
||||
style={{
|
||||
color: "#e3b341",
|
||||
color: "#f85149",
|
||||
marginRight: "6px",
|
||||
fontStyle: "normal",
|
||||
}}
|
||||
>
|
||||
⏳
|
||||
✕
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid={`merge-failure-icon-${item.story_id}`}
|
||||
title="Merge failed — needs human"
|
||||
style={{
|
||||
color: "#f85149",
|
||||
marginRight: "6px",
|
||||
fontStyle: "normal",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
})()}
|
||||
{mergesInFlight?.has(item.story_id) && (
|
||||
<span
|
||||
data-testid={`merge-in-flight-icon-${item.story_id}`}
|
||||
@@ -430,70 +431,74 @@ export function StagePanel({
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
{item.blocked && !item.merge_failure && (() => {
|
||||
const agentStatus = item.agent?.status;
|
||||
if (agentStatus === "running") {
|
||||
{item.blocked &&
|
||||
!item.merge_failure &&
|
||||
(() => {
|
||||
const agentStatus = item.agent?.status;
|
||||
if (agentStatus === "running") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`blocked-badge-${item.story_id}`}
|
||||
title="Recovery coder running — no human action needed"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
fontSize: "0.65em",
|
||||
fontWeight: 700,
|
||||
color: "#e3b341",
|
||||
background: "#2a1f0a",
|
||||
border: "1px solid #6e4a00",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 4px",
|
||||
marginRight: "8px",
|
||||
letterSpacing: "0.05em",
|
||||
animation: "spin 1s linear infinite",
|
||||
}}
|
||||
>
|
||||
⟳ RECOVERING
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (agentStatus === "pending") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`blocked-badge-${item.story_id}`}
|
||||
title="Recovery coder queued — waiting for a slot"
|
||||
style={{
|
||||
fontSize: "0.65em",
|
||||
fontWeight: 700,
|
||||
color: "#e3b341",
|
||||
background: "#2a1f0a",
|
||||
border: "1px solid #6e4a00",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 4px",
|
||||
marginRight: "8px",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
⏳ QUEUED
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid={`blocked-badge-${item.story_id}`}
|
||||
title="Recovery in progress — no human action needed"
|
||||
title="Blocked — awaiting human unblock"
|
||||
style={{
|
||||
fontSize: "0.65em",
|
||||
fontWeight: 700,
|
||||
color: "#e3b341",
|
||||
background: "#2a1e00",
|
||||
border: "1px solid #6e4f00",
|
||||
color: "#f85149",
|
||||
background: "#2a1010",
|
||||
border: "1px solid #6e1b1b",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 4px",
|
||||
marginRight: "8px",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
⟳ RECOVERING
|
||||
⊘ BLOCKED
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (agentStatus === "pending") {
|
||||
return (
|
||||
<span
|
||||
data-testid={`blocked-badge-${item.story_id}`}
|
||||
title="Recovery scheduled — waiting for a slot"
|
||||
style={{
|
||||
fontSize: "0.65em",
|
||||
fontWeight: 700,
|
||||
color: "#e3b341",
|
||||
background: "#2a1e00",
|
||||
border: "1px solid #6e4f00",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 4px",
|
||||
marginRight: "8px",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
⏳ QUEUED
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
data-testid={`blocked-badge-${item.story_id}`}
|
||||
title="Blocked — awaiting human unblock"
|
||||
style={{
|
||||
fontSize: "0.65em",
|
||||
fontWeight: 700,
|
||||
color: "#f85149",
|
||||
background: "#2a1010",
|
||||
border: "1px solid #6e1b1b",
|
||||
borderRadius: "4px",
|
||||
padding: "1px 4px",
|
||||
marginRight: "8px",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
⊘ BLOCKED
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
})()}
|
||||
{item.frozen && (
|
||||
<span
|
||||
data-testid={`frozen-badge-${item.story_id}`}
|
||||
|
||||
Reference in New Issue
Block a user