huskies: merge 1085
This commit is contained in:
@@ -50,6 +50,29 @@ export interface AgentAssignment {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Display column for a work item — derived server-side from `Stage::pipeline()` (story 1085). */
|
||||
export type Pipeline =
|
||||
| "backlog"
|
||||
| "coding"
|
||||
| "qa"
|
||||
| "merge"
|
||||
| "done"
|
||||
| "closed"
|
||||
| "archived";
|
||||
|
||||
/** Badge/indicator for a work item — derived server-side from `Stage::status()` (story 1085). */
|
||||
export type Status =
|
||||
| "active"
|
||||
| "frozen"
|
||||
| "review-hold"
|
||||
| "blocked"
|
||||
| "merge-failure"
|
||||
| "merge-failure-final"
|
||||
| "abandoned"
|
||||
| "superseded"
|
||||
| "rejected"
|
||||
| "done";
|
||||
|
||||
/** A single item in any pipeline stage (backlog, current, QA, merge, or done). */
|
||||
export interface PipelineStageItem {
|
||||
story_id: string;
|
||||
@@ -57,6 +80,10 @@ export interface PipelineStageItem {
|
||||
error: string | null;
|
||||
merge_failure: string | null;
|
||||
agent: AgentAssignment | null;
|
||||
/** Display column (story 1085); falls back to the bucket name on legacy servers. */
|
||||
pipeline?: Pipeline;
|
||||
/** Display badge (story 1085); falls back to derived `blocked`/`frozen` on legacy servers. */
|
||||
status?: Status;
|
||||
review_hold: boolean | null;
|
||||
qa: string | null;
|
||||
depends_on: number[] | null;
|
||||
|
||||
@@ -24,10 +24,38 @@ export interface GatewayInfo {
|
||||
projects: GatewayProject[];
|
||||
}
|
||||
|
||||
/** Display column for a work item — derived server-side from `Stage::pipeline()` (story 1085). */
|
||||
export type Pipeline =
|
||||
| "backlog"
|
||||
| "coding"
|
||||
| "qa"
|
||||
| "merge"
|
||||
| "done"
|
||||
| "closed"
|
||||
| "archived";
|
||||
|
||||
/** Badge/indicator for a work item — derived server-side from `Stage::status()` (story 1085). */
|
||||
export type Status =
|
||||
| "active"
|
||||
| "frozen"
|
||||
| "review-hold"
|
||||
| "blocked"
|
||||
| "merge-failure"
|
||||
| "merge-failure-final"
|
||||
| "abandoned"
|
||||
| "superseded"
|
||||
| "rejected"
|
||||
| "done";
|
||||
|
||||
export interface PipelineItem {
|
||||
story_id: string;
|
||||
name: string;
|
||||
/** Legacy stage string (kept for back-compat); prefer `pipeline` + `status`. */
|
||||
stage: string;
|
||||
/** Display column (story 1085). Optional until all servers are upgraded. */
|
||||
pipeline?: Pipeline;
|
||||
/** Display badge (story 1085). Optional until all servers are upgraded. */
|
||||
status?: Status;
|
||||
agent?: { agent_name: string; model: string; status: string } | null;
|
||||
blocked?: boolean;
|
||||
retry_count?: number;
|
||||
|
||||
@@ -69,29 +69,34 @@ describe("StoryRow", () => {
|
||||
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", () => {
|
||||
// Story 1085: failure kind no longer derived from substring. Items in
|
||||
// the merge_failure / merge_failure_final status get a generic FAILED badge;
|
||||
// the kind detail is exposed via the typed `status` field for callers that
|
||||
// need it (instead of being squeezed into the badge text).
|
||||
it("shows ✕ FAILED badge for merge-failure status", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "73_story_conflict",
|
||||
name: "Conflict Story",
|
||||
stage: "merge",
|
||||
blocked: true,
|
||||
pipeline: "merge",
|
||||
status: "merge-failure",
|
||||
merge_failure: "Merge conflict: conflicts detected",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("ConflictDetected")).toBeInTheDocument();
|
||||
expect(screen.getByText("✕ FAILED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows GatesFailed for merge_failure with quality gates text", () => {
|
||||
it("shows ⛔ FAILED (FINAL) badge for merge-failure-final status", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "74_story_gates",
|
||||
name: "Gates Failed Story",
|
||||
stage: "merge",
|
||||
blocked: true,
|
||||
pipeline: "merge",
|
||||
status: "merge-failure-final",
|
||||
merge_failure: "Quality gates failed: cargo test failed",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("GatesFailed")).toBeInTheDocument();
|
||||
expect(screen.getByText("⛔ FAILED (FINAL)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows RECOVERING badge for merge_failure item with running mergemaster", () => {
|
||||
@@ -163,4 +168,36 @@ describe("StoryRow", () => {
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("⊘ BLOCKED")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Story 1085 AC 4 — Frozen items remain visible in their underlying column
|
||||
// with a frozen indicator. The server hands us `pipeline: "coding"` for a
|
||||
// frozen-while-coding story and the badge is decorated separately.
|
||||
it("shows ❄ FROZEN badge for a frozen item (column stays as underlying pipeline)", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "70_story_frozen_coding",
|
||||
name: "Paused Coding Story",
|
||||
stage: "current",
|
||||
pipeline: "coding",
|
||||
status: "frozen",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("❄ FROZEN")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Story 1085 AC 4 (subsumes 1052) — Done items must never get a
|
||||
// MergeFailure indicator, even if a stale `merge_failure` string is present.
|
||||
it("done items render Done badge, never MergeFailure", () => {
|
||||
const item: PipelineItem = {
|
||||
story_id: "71_story_done",
|
||||
name: "Completed Story",
|
||||
stage: "done",
|
||||
pipeline: "done",
|
||||
status: "done",
|
||||
merge_failure: "ignored stale string",
|
||||
};
|
||||
render(<StoryRow item={item} />);
|
||||
expect(screen.getByText("Done")).toBeInTheDocument();
|
||||
expect(screen.queryByText("✕ FAILED")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/FAILED/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,9 +14,42 @@ import {
|
||||
type JoinedAgent,
|
||||
type GatewayProject,
|
||||
type AllProjectsPipeline,
|
||||
type Pipeline,
|
||||
type PipelineItem,
|
||||
type Status,
|
||||
} from "../api/gateway";
|
||||
|
||||
/// Resolve an item's pipeline column. Servers running the new (story 1085)
|
||||
/// backend send `pipeline`; older servers only send `stage` so we fall back to
|
||||
/// mapping the bucket name onto the new column vocabulary.
|
||||
function itemPipeline(item: PipelineItem): Pipeline {
|
||||
if (item.pipeline) return item.pipeline;
|
||||
switch (item.stage) {
|
||||
case "current":
|
||||
return "coding";
|
||||
case "qa":
|
||||
return "qa";
|
||||
case "merge":
|
||||
return "merge";
|
||||
case "done":
|
||||
return "done";
|
||||
case "archived":
|
||||
return "archived";
|
||||
default:
|
||||
return "backlog";
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an item's badge. Falls back to `merge_failure`/`blocked` on
|
||||
/// legacy servers that don't yet emit `status`.
|
||||
function itemStatus(item: PipelineItem): Status {
|
||||
if (item.status) return item.status;
|
||||
if (item.merge_failure) return "merge-failure";
|
||||
if (item.blocked) return "blocked";
|
||||
if (item.stage === "done") return "done";
|
||||
return "active";
|
||||
}
|
||||
|
||||
const { useCallback, useEffect, useRef, useState } = React;
|
||||
|
||||
/// Seconds of silence before an agent is considered disconnected.
|
||||
@@ -48,72 +81,86 @@ const STATUS_LABELS: Record<AgentStatus, string> = {
|
||||
disconnected: "Disconnected",
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
const PIPELINE_COLORS: Record<Pipeline, string> = {
|
||||
backlog: "#8b949e",
|
||||
current: "#3fb950",
|
||||
coding: "#3fb950",
|
||||
qa: "#d2a679",
|
||||
merge: "#79c0ff",
|
||||
done: "#6e7681",
|
||||
closed: "#6e7681",
|
||||
archived: "#6e7681",
|
||||
};
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
const PIPELINE_LABELS: Record<Pipeline, string> = {
|
||||
backlog: "Backlog",
|
||||
current: "In Progress",
|
||||
coding: "In Progress",
|
||||
qa: "QA",
|
||||
merge: "Merging",
|
||||
done: "Done",
|
||||
closed: "Closed",
|
||||
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.
|
||||
/** 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 status badge. */
|
||||
export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQueuePos?: number }) {
|
||||
const isStuck = item.merge_failure != null || item.blocked;
|
||||
const isMergeActive = item.stage === "merge" && !isStuck && item.agent?.status === "running";
|
||||
const pipeline = itemPipeline(item);
|
||||
const status = itemStatus(item);
|
||||
const agentStatus = item.agent?.status;
|
||||
|
||||
let color: string;
|
||||
let label: string;
|
||||
let frozenPrefix = "";
|
||||
|
||||
if (isMergeActive) {
|
||||
color = "#58a6ff";
|
||||
label = "▶ MERGING";
|
||||
} else if (isStuck) {
|
||||
const agentStatus = item.agent?.status;
|
||||
// Frozen items keep their underlying pipeline column but get a ❄️ badge.
|
||||
// (AC 4 — story 1085, subsumes the freeze-hides-item bug.)
|
||||
if (status === "frozen") {
|
||||
color = "#79c0ff";
|
||||
label = "❄ FROZEN";
|
||||
frozenPrefix = "❄ ";
|
||||
} else if (status === "merge-failure" || status === "merge-failure-final") {
|
||||
// Done items never reach this branch — `Stage::status()` returns
|
||||
// `Status::Done` for done items (AC 4).
|
||||
if (agentStatus === "running") {
|
||||
color = "#e3b341";
|
||||
label = "⟳ RECOVERING";
|
||||
} else if (agentStatus === "pending") {
|
||||
color = "#e3b341";
|
||||
label = "⏳ QUEUED";
|
||||
} else if (item.merge_failure != null) {
|
||||
} else {
|
||||
color = "#f85149";
|
||||
label = mergeFailureKindLabel(item.merge_failure);
|
||||
label = status === "merge-failure-final" ? "⛔ FAILED (FINAL)" : "✕ FAILED";
|
||||
}
|
||||
} else if (status === "blocked") {
|
||||
if (agentStatus === "running") {
|
||||
color = "#e3b341";
|
||||
label = "⟳ RECOVERING";
|
||||
} else if (agentStatus === "pending") {
|
||||
color = "#e3b341";
|
||||
label = "⏳ QUEUED";
|
||||
} else {
|
||||
color = "#f85149";
|
||||
label = "⊘ BLOCKED";
|
||||
}
|
||||
} else if (item.stage === "merge" && item.agent?.status === "pending") {
|
||||
} else if (status === "review-hold") {
|
||||
color = "#d2a679";
|
||||
label = "REVIEW HOLD";
|
||||
} else if (status === "abandoned") {
|
||||
color = "#6e7681";
|
||||
label = "ABANDONED";
|
||||
} else if (status === "superseded") {
|
||||
color = "#6e7681";
|
||||
label = "SUPERSEDED";
|
||||
} else if (status === "rejected") {
|
||||
color = "#f85149";
|
||||
label = "REJECTED";
|
||||
} else if (pipeline === "merge" && agentStatus === "running") {
|
||||
color = "#58a6ff";
|
||||
label = "▶ MERGING";
|
||||
} else if (pipeline === "merge" && agentStatus === "pending") {
|
||||
color = "#e3b341";
|
||||
label = "⏳ QUEUED";
|
||||
} else if (item.stage === "merge") {
|
||||
} else if (pipeline === "merge") {
|
||||
color = "#6e7681";
|
||||
if (mergeQueuePos === 1) {
|
||||
label = "NEXT IN QUEUE";
|
||||
@@ -123,10 +170,11 @@ export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQue
|
||||
label = "awaiting-slot";
|
||||
}
|
||||
} else {
|
||||
color = STAGE_COLORS[item.stage] ?? "#8b949e";
|
||||
label = STAGE_LABELS[item.stage] ?? item.stage;
|
||||
color = PIPELINE_COLORS[pipeline] ?? "#8b949e";
|
||||
label = PIPELINE_LABELS[pipeline] ?? pipeline;
|
||||
}
|
||||
|
||||
const isMergeActive = pipeline === "merge" && status === "active" && agentStatus === "running";
|
||||
const idNum = item.story_id.match(/^(\d+)/)?.[1];
|
||||
|
||||
return (
|
||||
@@ -158,7 +206,7 @@ export function StoryRow({ item, mergeQueuePos }: { item: PipelineItem; mergeQue
|
||||
</span>
|
||||
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{idNum && <span style={{ color: "#8b949e", fontFamily: "monospace" }}>#{idNum}{" "}</span>}
|
||||
{item.name}
|
||||
{frozenPrefix}{item.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -388,6 +436,8 @@ function aggregateItems(
|
||||
story_id: b.story_id,
|
||||
name: b.name,
|
||||
stage: "backlog",
|
||||
pipeline: "backlog" as Pipeline,
|
||||
status: "active" as Status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -395,14 +445,14 @@ function aggregateItems(
|
||||
return {
|
||||
project,
|
||||
items: (status.active ?? []).filter(
|
||||
(i) => i.stage !== "done",
|
||||
(i) => itemPipeline(i) !== "done",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (tab === "done") {
|
||||
return {
|
||||
project,
|
||||
items: (status.active ?? []).filter((i) => i.stage === "done"),
|
||||
items: (status.active ?? []).filter((i) => itemPipeline(i) === "done"),
|
||||
};
|
||||
}
|
||||
// archived
|
||||
@@ -419,12 +469,12 @@ function tabCount(pipeline: AllProjectsPipeline, tab: TabKey): number {
|
||||
if (tab === "in-progress") {
|
||||
return (
|
||||
sum +
|
||||
(status.active ?? []).filter((i) => i.stage !== "done").length
|
||||
(status.active ?? []).filter((i) => itemPipeline(i) !== "done").length
|
||||
);
|
||||
}
|
||||
if (tab === "done") {
|
||||
return (
|
||||
sum + (status.active ?? []).filter((i) => i.stage === "done").length
|
||||
sum + (status.active ?? []).filter((i) => itemPipeline(i) === "done").length
|
||||
);
|
||||
}
|
||||
return sum + (status.archived ?? []).length;
|
||||
@@ -518,13 +568,16 @@ function ProjectStoryRow({
|
||||
);
|
||||
}
|
||||
|
||||
const IN_PROGRESS_STAGE_LABELS: Record<string, string> = {
|
||||
current: "Coding",
|
||||
const IN_PROGRESS_PIPELINE_LABELS: Record<"coding" | "qa" | "merge", string> = {
|
||||
coding: "Coding",
|
||||
qa: "QA",
|
||||
merge: "Merging",
|
||||
};
|
||||
|
||||
/// In Progress tab content — items grouped by stage (coding / qa / merging).
|
||||
/// In Progress tab content — items grouped by their `pipeline` column.
|
||||
///
|
||||
/// Frozen items appear in the column corresponding to their underlying
|
||||
/// `Stage::resume_to` (server-side), so they always show up in-place.
|
||||
function InProgressTabContent({
|
||||
groups,
|
||||
}: {
|
||||
@@ -535,25 +588,22 @@ function InProgressTabContent({
|
||||
);
|
||||
const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
|
||||
|
||||
const byStage = {
|
||||
current: allItems.filter((x) => x.item.stage === "current"),
|
||||
qa: allItems.filter((x) => x.item.stage === "qa"),
|
||||
merge: allItems.filter((x) => x.item.stage === "merge"),
|
||||
const byPipeline = {
|
||||
coding: allItems.filter((x) => itemPipeline(x.item) === "coding"),
|
||||
qa: allItems.filter((x) => itemPipeline(x.item) === "qa"),
|
||||
merge: allItems.filter((x) => itemPipeline(x.item) === "merge"),
|
||||
};
|
||||
|
||||
const stages = (["current", "qa", "merge"] as const).filter(
|
||||
(s) => byStage[s].length > 0,
|
||||
const pipelines = (["coding", "qa", "merge"] as const).filter(
|
||||
(p) => byPipeline[p].length > 0,
|
||||
);
|
||||
|
||||
// Compute queue position among clean awaiting merge items (Stage::Merge, no failure, no running agent).
|
||||
// Compute queue position among "clean" awaiting-merge items: pipeline=merge,
|
||||
// status=active, and no agent currently running.
|
||||
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"
|
||||
) {
|
||||
for (const { project, item } of byPipeline.merge) {
|
||||
if (itemStatus(item) === "active" && item.agent?.status !== "running") {
|
||||
queuePos += 1;
|
||||
mergeQueuePosMap.set(`${project}:${item.story_id}`, queuePos);
|
||||
}
|
||||
@@ -569,33 +619,33 @@ function InProgressTabContent({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{stages.map((stage) => (
|
||||
<div key={stage} style={{ marginBottom: "20px" }}>
|
||||
{pipelines.map((p) => (
|
||||
<div key={p} style={{ marginBottom: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8em",
|
||||
fontWeight: 600,
|
||||
color: STAGE_COLORS[stage] ?? "#8b949e",
|
||||
color: PIPELINE_COLORS[p] ?? "#8b949e",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.06em",
|
||||
marginBottom: "8px",
|
||||
paddingBottom: "4px",
|
||||
borderBottom: `1px solid ${STAGE_COLORS[stage] ?? "#8b949e"}33`,
|
||||
borderBottom: `1px solid ${PIPELINE_COLORS[p] ?? "#8b949e"}33`,
|
||||
}}
|
||||
>
|
||||
{IN_PROGRESS_STAGE_LABELS[stage]}{" "}
|
||||
{IN_PROGRESS_PIPELINE_LABELS[p]}{" "}
|
||||
<span style={{ color: "#6e7681" }}>
|
||||
({byStage[stage].length})
|
||||
({byPipeline[p].length})
|
||||
</span>
|
||||
</div>
|
||||
{byStage[stage].map(({ project, item }) => (
|
||||
{byPipeline[p].map(({ project, item }) => (
|
||||
<ProjectStoryRow
|
||||
key={`${project}:${item.story_id}`}
|
||||
project={project}
|
||||
item={item}
|
||||
showProject={multiProject}
|
||||
mergeQueuePos={
|
||||
stage === "merge"
|
||||
p === "merge"
|
||||
? mergeQueuePosMap.get(`${project}:${item.story_id}`)
|
||||
: undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user