huskies: merge 1045

This commit is contained in:
dave
2026-05-14 16:48:20 +00:00
parent 311883f45d
commit 4553df5b21
3 changed files with 131 additions and 11 deletions
+60 -1
View File
@@ -25,7 +25,7 @@ describe("StoryRow", () => {
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("renders name without id prefix when story_id has no leading number", () => { it("renders awaiting-slot badge for merge item with no agent", () => {
const item: PipelineItem = { const item: PipelineItem = {
story_id: "no-number-id", story_id: "no-number-id",
name: "Mystery Story", name: "Mystery Story",
@@ -33,6 +33,65 @@ describe("StoryRow", () => {
}; };
const { container } = render(<StoryRow item={item} />); const { container } = render(<StoryRow item={item} />);
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
expect(screen.getByText("awaiting-slot")).toBeInTheDocument();
});
// AC1: active mergemaster is visually distinct
it("shows MERGING badge for merge item with running mergemaster (active)", () => {
const item: PipelineItem = {
story_id: "70_story_merging_active",
name: "Merging Active",
stage: "merge",
agent: { agent_name: "mergemaster", model: "claude", status: "running" },
};
render(<StoryRow item={item} />);
expect(screen.getByText("▶ MERGING")).toBeInTheDocument();
});
// AC2: awaiting-slot with queue position labels
it("shows NEXT IN QUEUE for first awaiting-slot merge item", () => {
const item: PipelineItem = {
story_id: "71_story_next_in_queue",
name: "Next in Queue",
stage: "merge",
};
render(<StoryRow item={item} mergeQueuePos={1} />);
expect(screen.getByText("NEXT IN QUEUE")).toBeInTheDocument();
});
it("shows awaiting-slot with position for subsequent queued merge items", () => {
const item: PipelineItem = {
story_id: "72_story_second_in_queue",
name: "Second in Queue",
stage: "merge",
};
render(<StoryRow item={item} mergeQueuePos={2} />);
expect(screen.getByText("awaiting-slot (#2)")).toBeInTheDocument();
});
// AC2: failure kind labels derived from merge_failure string
it("shows ConflictDetected for merge_failure with conflict text", () => {
const item: PipelineItem = {
story_id: "73_story_conflict",
name: "Conflict Story",
stage: "merge",
blocked: true,
merge_failure: "Merge conflict: conflicts detected",
};
render(<StoryRow item={item} />);
expect(screen.getByText("ConflictDetected")).toBeInTheDocument();
});
it("shows GatesFailed for merge_failure with quality gates text", () => {
const item: PipelineItem = {
story_id: "74_story_gates",
name: "Gates Failed Story",
stage: "merge",
blocked: true,
merge_failure: "Quality gates failed: cargo test failed",
};
render(<StoryRow item={item} />);
expect(screen.getByText("GatesFailed")).toBeInTheDocument();
}); });
it("shows RECOVERING badge for merge_failure item with running mergemaster", () => { it("shows RECOVERING badge for merge_failure item with running mergemaster", () => {
+65 -4
View File
@@ -66,15 +66,36 @@ const STAGE_LABELS: Record<string, string> = {
archived: "Archived", archived: "Archived",
}; };
/// Derive a short label from a merge failure string based on the failure kind.
function mergeFailureKindLabel(failure: string): string {
if (failure.includes("Merge conflict") || failure.includes("CONFLICT")) {
return "ConflictDetected";
}
if (failure.includes("Quality gates failed") || failure.includes("gates failed")) {
return "GatesFailed";
}
if (failure.includes("no code changes") || failure.includes("empty diff")) {
return "EmptyDiff";
}
if (failure.includes("No commits")) {
return "NoCommits";
}
return "✕ FAILED";
}
/// A single story row inside a project pipeline card. /// A single story row inside a project pipeline card.
/** Render one story row in a gateway-aggregate panel: `#<id> <name>` with stage badge. */ /** Render one story row in a gateway-aggregate panel: `#<id> <name>` with stage badge. */
export function StoryRow({ item }: { item: PipelineItem }) { export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) {
const isStuck = item.merge_failure != null || item.blocked; const isStuck = item.merge_failure != null || item.blocked;
const isMergeActive = item.stage === "merge" && !isStuck && item.agent?.status === "running";
let color: string; let color: string;
let label: string; let label: string;
if (isStuck) { if (isMergeActive) {
color = "#58a6ff";
label = "▶ MERGING";
} else if (isStuck) {
const agentStatus = item.agent?.status; const agentStatus = item.agent?.status;
if (agentStatus === "running") { if (agentStatus === "running") {
color = "#e3b341"; color = "#e3b341";
@@ -82,9 +103,24 @@ export function StoryRow({ item }: { item: PipelineItem }) {
} else if (agentStatus === "pending") { } else if (agentStatus === "pending") {
color = "#e3b341"; color = "#e3b341";
label = "⏳ QUEUED"; label = "⏳ QUEUED";
} else if (item.merge_failure != null) {
color = "#f85149";
label = mergeFailureKindLabel(item.merge_failure);
} else { } else {
color = "#f85149"; color = "#f85149";
label = item.merge_failure != null ? "✕ FAILED" : "⊘ BLOCKED"; label = "⊘ BLOCKED";
}
} else if (item.stage === "merge" && item.agent?.status === "pending") {
color = "#e3b341";
label = "⏳ QUEUED";
} else if (item.stage === "merge") {
color = "#6e7681";
if (mergeQueuePos === 1) {
label = "NEXT IN QUEUE";
} else if (mergeQueuePos != null) {
label = `awaiting-slot (#${mergeQueuePos})`;
} else {
label = "awaiting-slot";
} }
} else { } else {
color = STAGE_COLORS[item.stage] ?? "#8b949e"; color = STAGE_COLORS[item.stage] ?? "#8b949e";
@@ -101,6 +137,10 @@ export function StoryRow({ item }: { item: PipelineItem }) {
gap: "8px", gap: "8px",
padding: "4px 0", padding: "4px 0",
fontSize: "0.82em", fontSize: "0.82em",
background: isMergeActive ? "#58a6ff0a" : undefined,
borderRadius: isMergeActive ? "4px" : undefined,
paddingLeft: isMergeActive ? "4px" : undefined,
paddingRight: isMergeActive ? "4px" : undefined,
}} }}
> >
<span <span
@@ -446,10 +486,12 @@ function ProjectStoryRow({
project, project,
item, item,
showProject, showProject,
mergeQueuePos,
}: { }: {
project: string; project: string;
item: PipelineItem; item: PipelineItem;
showProject: boolean; showProject: boolean;
mergeQueuePos?: number;
}) { }) {
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
@@ -470,7 +512,7 @@ function ProjectStoryRow({
</span> </span>
)} )}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<StoryRow item={item} /> <StoryRow item={item} mergeQueuePos={mergeQueuePos} />
</div> </div>
</div> </div>
); );
@@ -503,6 +545,20 @@ function InProgressTabContent({
(s) => byStage[s].length > 0, (s) => byStage[s].length > 0,
); );
// Compute queue position among clean awaiting merge items (Stage::Merge, no failure, no running agent).
const mergeQueuePosMap = new Map<string, number>();
let queuePos = 0;
for (const { project, item } of byStage.merge) {
if (
!item.blocked &&
!item.merge_failure &&
item.agent?.status !== "running"
) {
queuePos += 1;
mergeQueuePosMap.set(`${project}:${item.story_id}`, queuePos);
}
}
if (allItems.length === 0) { if (allItems.length === 0) {
return ( return (
<p style={{ color: "#6e7681", padding: "16px 0" }}> <p style={{ color: "#6e7681", padding: "16px 0" }}>
@@ -538,6 +594,11 @@ function InProgressTabContent({
project={project} project={project}
item={item} item={item}
showProject={multiProject} showProject={multiProject}
mergeQueuePos={
stage === "merge"
? mergeQueuePosMap.get(`${project}:${item.story_id}`)
: undefined
}
/> />
))} ))}
</div> </div>
@@ -3,7 +3,7 @@
exports[`StoryRow > renders #id prefix before the story name 1`] = ` exports[`StoryRow > renders #id prefix before the story name 1`] = `
<div> <div>
<div <div
style="display: flex; align-items: center; gap: 8px; padding: 4px 0px; font-size: 0.82em;" style="display: flex; align-items: center; gap: 8px; padding-top: 4px; padding-bottom: 4px; font-size: 0.82em;"
> >
<span <span
style="padding: 1px 6px; border-radius: 10px; background: rgba(63, 185, 80, 0.133); color: rgb(63, 185, 80); border: 1px solid rgba(63, 185, 80, 0.267); white-space: nowrap; flex-shrink: 0;" style="padding: 1px 6px; border-radius: 10px; background: rgba(63, 185, 80, 0.133); color: rgb(63, 185, 80); border: 1px solid rgba(63, 185, 80, 0.267); white-space: nowrap; flex-shrink: 0;"
@@ -29,7 +29,7 @@ exports[`StoryRow > renders #id prefix before the story name 1`] = `
exports[`StoryRow > renders #id prefix for a backlogged story 1`] = ` exports[`StoryRow > renders #id prefix for a backlogged story 1`] = `
<div> <div>
<div <div
style="display: flex; align-items: center; gap: 8px; padding: 4px 0px; font-size: 0.82em;" style="display: flex; align-items: center; gap: 8px; padding-top: 4px; padding-bottom: 4px; font-size: 0.82em;"
> >
<span <span
style="padding: 1px 6px; border-radius: 10px; background: rgba(210, 166, 121, 0.133); color: rgb(210, 166, 121); border: 1px solid rgba(210, 166, 121, 0.267); white-space: nowrap; flex-shrink: 0;" style="padding: 1px 6px; border-radius: 10px; background: rgba(210, 166, 121, 0.133); color: rgb(210, 166, 121); border: 1px solid rgba(210, 166, 121, 0.267); white-space: nowrap; flex-shrink: 0;"
@@ -52,15 +52,15 @@ exports[`StoryRow > renders #id prefix for a backlogged story 1`] = `
</div> </div>
`; `;
exports[`StoryRow > renders name without id prefix when story_id has no leading number 1`] = ` exports[`StoryRow > renders awaiting-slot badge for merge item with no agent 1`] = `
<div> <div>
<div <div
style="display: flex; align-items: center; gap: 8px; padding: 4px 0px; font-size: 0.82em;" style="display: flex; align-items: center; gap: 8px; padding-top: 4px; padding-bottom: 4px; font-size: 0.82em;"
> >
<span <span
style="padding: 1px 6px; border-radius: 10px; background: rgba(121, 192, 255, 0.133); color: rgb(121, 192, 255); border: 1px solid rgba(121, 192, 255, 0.267); white-space: nowrap; flex-shrink: 0;" style="padding: 1px 6px; border-radius: 10px; background: rgba(110, 118, 129, 0.133); color: rgb(110, 118, 129); border: 1px solid rgba(110, 118, 129, 0.267); white-space: nowrap; flex-shrink: 0;"
> >
Merging awaiting-slot
</span> </span>
<span <span
style="color: rgb(230, 237, 243); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" style="color: rgb(230, 237, 243); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"