huskies: merge 1046
This commit is contained in:
@@ -38,6 +38,7 @@ export interface ProjectPipelineStatus {
|
|||||||
active: PipelineItem[];
|
active: PipelineItem[];
|
||||||
backlog: { story_id: string; name: string }[];
|
backlog: { story_id: string; name: string }[];
|
||||||
backlog_count: number;
|
backlog_count: number;
|
||||||
|
archived?: PipelineItem[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,17 +49,21 @@ const STATUS_LABELS: Record<AgentStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const STAGE_COLORS: Record<string, string> = {
|
const STAGE_COLORS: Record<string, string> = {
|
||||||
|
backlog: "#8b949e",
|
||||||
current: "#3fb950",
|
current: "#3fb950",
|
||||||
qa: "#d2a679",
|
qa: "#d2a679",
|
||||||
merge: "#79c0ff",
|
merge: "#79c0ff",
|
||||||
done: "#6e7681",
|
done: "#6e7681",
|
||||||
|
archived: "#6e7681",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STAGE_LABELS: Record<string, string> = {
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
backlog: "Backlog",
|
||||||
current: "In Progress",
|
current: "In Progress",
|
||||||
qa: "QA",
|
qa: "QA",
|
||||||
merge: "Merging",
|
merge: "Merging",
|
||||||
done: "Done",
|
done: "Done",
|
||||||
|
archived: "Archived",
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A single story row inside a project pipeline card.
|
/// A single story row inside a project pipeline card.
|
||||||
@@ -120,106 +124,6 @@ export function StoryRow({ item }: { item: PipelineItem }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pipeline status card for a single project.
|
|
||||||
function ProjectPipelineCard({
|
|
||||||
name,
|
|
||||||
pipeline,
|
|
||||||
isActive,
|
|
||||||
onSwitch,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
pipeline: AllProjectsPipeline["projects"][string];
|
|
||||||
isActive: boolean;
|
|
||||||
onSwitch: (name: string) => void;
|
|
||||||
}) {
|
|
||||||
const activeItems = pipeline.active ?? [];
|
|
||||||
const backlogItems = pipeline.backlog ?? [];
|
|
||||||
const backlogCount = pipeline.backlog_count ?? 0;
|
|
||||||
const remainingBacklog = backlogCount - backlogItems.length;
|
|
||||||
const hasError = Boolean(pipeline.error);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid={`pipeline-card-${name}`}
|
|
||||||
onClick={() => onSwitch(name)}
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
background: "#161b22",
|
|
||||||
border: `1px solid ${isActive ? "#238636" : "#30363d"}`,
|
|
||||||
borderRadius: "8px",
|
|
||||||
marginBottom: "8px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
marginBottom: activeItems.length > 0 || backlogItems.length > 0 ? "8px" : 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontWeight: 600, color: "#e6edf3" }}>{name}</span>
|
|
||||||
{isActive && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "0.7em",
|
|
||||||
padding: "1px 6px",
|
|
||||||
borderRadius: "10px",
|
|
||||||
background: "#23863622",
|
|
||||||
color: "#3fb950",
|
|
||||||
border: "1px solid #23863644",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
active
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasError ? (
|
|
||||||
<div style={{ fontSize: "0.8em", color: "#f85149" }}>{pipeline.error}</div>
|
|
||||||
) : activeItems.length === 0 && backlogItems.length === 0 ? (
|
|
||||||
<div style={{ fontSize: "0.8em", color: "#6e7681" }}>
|
|
||||||
{backlogCount > 0 ? `${backlogCount} in backlog` : "No active stories"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
{activeItems.map((item) => (
|
|
||||||
<StoryRow key={item.story_id} item={item} />
|
|
||||||
))}
|
|
||||||
{backlogItems.length > 0 && (
|
|
||||||
<div style={{ marginTop: activeItems.length > 0 ? "6px" : 0, borderTop: activeItems.length > 0 ? "1px solid #21262d" : "none", paddingTop: activeItems.length > 0 ? "6px" : 0 }}>
|
|
||||||
{backlogItems.map((item) => {
|
|
||||||
const idNum = item.story_id.match(/^(\d+)/)?.[1];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.story_id}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
padding: "2px 0",
|
|
||||||
fontSize: "0.82em",
|
|
||||||
color: "#6e7681",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{idNum && <span style={{ fontFamily: "monospace", flexShrink: 0 }}>#{idNum}</span>}
|
|
||||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.name}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{remainingBacklog > 0 && (
|
|
||||||
<div style={{ fontSize: "0.75em", color: "#6e7681", paddingTop: "2px" }}>
|
|
||||||
…and {remainingBacklog} more
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TokenDisplay({ token }: { token: string }) {
|
function TokenDisplay({ token }: { token: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -411,6 +315,270 @@ function AgentRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabKey = "backlog" | "in-progress" | "done" | "archived";
|
||||||
|
|
||||||
|
const TAB_STORAGE_KEY = "gateway_selected_tab";
|
||||||
|
|
||||||
|
/// Read the persisted tab from localStorage, defaulting to "in-progress".
|
||||||
|
function readStoredTab(): TabKey {
|
||||||
|
const stored = localStorage.getItem(TAB_STORAGE_KEY);
|
||||||
|
if (
|
||||||
|
stored === "backlog" ||
|
||||||
|
stored === "in-progress" ||
|
||||||
|
stored === "done" ||
|
||||||
|
stored === "archived"
|
||||||
|
) {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return "in-progress";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregate pipeline items from all projects for a given tab.
|
||||||
|
function aggregateItems(
|
||||||
|
pipeline: AllProjectsPipeline,
|
||||||
|
tab: TabKey,
|
||||||
|
): { project: string; items: PipelineItem[] }[] {
|
||||||
|
return Object.entries(pipeline.projects)
|
||||||
|
.map(([project, status]) => {
|
||||||
|
if (status.error) return { project, items: [] };
|
||||||
|
if (tab === "backlog") {
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
items: (status.backlog ?? []).map((b) => ({
|
||||||
|
story_id: b.story_id,
|
||||||
|
name: b.name,
|
||||||
|
stage: "backlog",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (tab === "in-progress") {
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
items: (status.active ?? []).filter(
|
||||||
|
(i) => i.stage !== "done",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (tab === "done") {
|
||||||
|
return {
|
||||||
|
project,
|
||||||
|
items: (status.active ?? []).filter((i) => i.stage === "done"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// archived
|
||||||
|
return { project, items: status.archived ?? [] };
|
||||||
|
})
|
||||||
|
.filter((g) => g.items.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count total items across all projects for a given tab.
|
||||||
|
function tabCount(pipeline: AllProjectsPipeline, tab: TabKey): number {
|
||||||
|
return Object.values(pipeline.projects).reduce((sum, status) => {
|
||||||
|
if (status.error) return sum;
|
||||||
|
if (tab === "backlog") return sum + (status.backlog_count ?? 0);
|
||||||
|
if (tab === "in-progress") {
|
||||||
|
return (
|
||||||
|
sum +
|
||||||
|
(status.active ?? []).filter((i) => i.stage !== "done").length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tab === "done") {
|
||||||
|
return (
|
||||||
|
sum + (status.active ?? []).filter((i) => i.stage === "done").length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sum + (status.archived ?? []).length;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tab bar button.
|
||||||
|
function TabButton({
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
count: number;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: "6px 6px 0 0",
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: active ? "#30363d" : "transparent",
|
||||||
|
borderBottomColor: active ? "#0d1117" : "transparent",
|
||||||
|
background: active ? "#0d1117" : "none",
|
||||||
|
color: active ? "#e6edf3" : "#8b949e",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
fontWeight: active ? 600 : 400,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{count > 0 && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
background: active ? "#21262d" : "#161b22",
|
||||||
|
color: active ? "#e6edf3" : "#6e7681",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A project-labelled story row used in the aggregate tab view.
|
||||||
|
function ProjectStoryRow({
|
||||||
|
project,
|
||||||
|
item,
|
||||||
|
showProject,
|
||||||
|
}: {
|
||||||
|
project: string;
|
||||||
|
item: PipelineItem;
|
||||||
|
showProject: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
|
{showProject && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75em",
|
||||||
|
padding: "1px 6px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
background: "#161b22",
|
||||||
|
color: "#8b949e",
|
||||||
|
border: "1px solid #30363d",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{project}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<StoryRow item={item} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const IN_PROGRESS_STAGE_LABELS: Record<string, string> = {
|
||||||
|
current: "Coding",
|
||||||
|
qa: "QA",
|
||||||
|
merge: "Merging",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// In Progress tab content — items grouped by stage (coding / qa / merging).
|
||||||
|
function InProgressTabContent({
|
||||||
|
groups,
|
||||||
|
}: {
|
||||||
|
groups: { project: string; items: PipelineItem[] }[];
|
||||||
|
}) {
|
||||||
|
const allItems = groups.flatMap((g) =>
|
||||||
|
g.items.map((item) => ({ project: g.project, item })),
|
||||||
|
);
|
||||||
|
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 stages = (["current", "qa", "merge"] as const).filter(
|
||||||
|
(s) => byStage[s].length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ color: "#6e7681", padding: "16px 0" }}>
|
||||||
|
No items in progress.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{stages.map((stage) => (
|
||||||
|
<div key={stage} style={{ marginBottom: "20px" }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8em",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: STAGE_COLORS[stage] ?? "#8b949e",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
marginBottom: "8px",
|
||||||
|
paddingBottom: "4px",
|
||||||
|
borderBottom: `1px solid ${STAGE_COLORS[stage] ?? "#8b949e"}33`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{IN_PROGRESS_STAGE_LABELS[stage]}{" "}
|
||||||
|
<span style={{ color: "#6e7681" }}>
|
||||||
|
({byStage[stage].length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{byStage[stage].map(({ project, item }) => (
|
||||||
|
<ProjectStoryRow
|
||||||
|
key={`${project}:${item.story_id}`}
|
||||||
|
project={project}
|
||||||
|
item={item}
|
||||||
|
showProject={multiProject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flat list tab content for Backlog, Done, and Archived.
|
||||||
|
function FlatTabContent({
|
||||||
|
groups,
|
||||||
|
emptyMessage,
|
||||||
|
}: {
|
||||||
|
groups: { project: string; items: PipelineItem[] }[];
|
||||||
|
emptyMessage: string;
|
||||||
|
}) {
|
||||||
|
const allItems = groups.flatMap((g) =>
|
||||||
|
g.items.map((item) => ({ project: g.project, item })),
|
||||||
|
);
|
||||||
|
const multiProject = new Set(allItems.map((x) => x.project)).size > 1;
|
||||||
|
|
||||||
|
if (allItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ color: "#6e7681", padding: "16px 0" }}>{emptyMessage}</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{allItems.map(({ project, item }) => (
|
||||||
|
<ProjectStoryRow
|
||||||
|
key={`${project}:${item.story_id}`}
|
||||||
|
project={project}
|
||||||
|
item={item}
|
||||||
|
showProject={multiProject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Gateway management panel — rendered when running in `--gateway` mode.
|
/// Gateway management panel — rendered when running in `--gateway` mode.
|
||||||
export function GatewayPanel() {
|
export function GatewayPanel() {
|
||||||
const [agents, setAgents] = useState<JoinedAgent[]>([]);
|
const [agents, setAgents] = useState<JoinedAgent[]>([]);
|
||||||
@@ -419,6 +587,7 @@ export function GatewayPanel() {
|
|||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pipeline, setPipeline] = useState<AllProjectsPipeline | null>(null);
|
const [pipeline, setPipeline] = useState<AllProjectsPipeline | null>(null);
|
||||||
|
const [selectedTab, setSelectedTab] = useState<TabKey>(readStoredTab);
|
||||||
|
|
||||||
// Keep stable refs so polling intervals don't recreate on state changes.
|
// Keep stable refs so polling intervals don't recreate on state changes.
|
||||||
const setAgentsRef = useRef(setAgents);
|
const setAgentsRef = useRef(setAgents);
|
||||||
@@ -494,20 +663,9 @@ export function GatewayPanel() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSwitchProject = useCallback(async (name: string) => {
|
const handleSelectTab = useCallback((tab: TabKey) => {
|
||||||
setError(null);
|
setSelectedTab(tab);
|
||||||
try {
|
localStorage.setItem(TAB_STORAGE_KEY, tab);
|
||||||
const result = await gatewayApi.switchProject(name);
|
|
||||||
if (!result.ok) {
|
|
||||||
setError(result.error ?? "Failed to switch project");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Refresh pipeline to reflect new active project.
|
|
||||||
const updated = await gatewayApi.getAllProjectsPipeline();
|
|
||||||
setPipeline(updated);
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
@@ -529,29 +687,62 @@ export function GatewayPanel() {
|
|||||||
Manage build agents connected to this gateway.
|
Manage build agents connected to this gateway.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Cross-project pipeline status */}
|
{/* Cross-project pipeline tabs */}
|
||||||
<section style={{ marginBottom: "32px" }}>
|
<section style={{ marginBottom: "32px" }}>
|
||||||
<h2
|
{/* Tab bar */}
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "1.1em",
|
display: "flex",
|
||||||
fontWeight: 600,
|
gap: "2px",
|
||||||
marginBottom: "12px",
|
borderBottom: "1px solid #30363d",
|
||||||
borderBottom: "1px solid #21262d",
|
marginBottom: "16px",
|
||||||
paddingBottom: "8px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pipeline Status
|
{(
|
||||||
</h2>
|
[
|
||||||
{pipeline ? (
|
{ key: "backlog", label: "Backlog" },
|
||||||
Object.entries(pipeline.projects).map(([name, status]) => (
|
{ key: "in-progress", label: "In Progress" },
|
||||||
<ProjectPipelineCard
|
{ key: "done", label: "Done" },
|
||||||
key={name}
|
{ key: "archived", label: "Archived" },
|
||||||
name={name}
|
] as { key: TabKey; label: string }[]
|
||||||
pipeline={status}
|
).map(({ key, label }) => (
|
||||||
isActive={name === pipeline.active}
|
<TabButton
|
||||||
onSwitch={handleSwitchProject}
|
key={key}
|
||||||
|
label={label}
|
||||||
|
count={pipeline ? tabCount(pipeline, key) : 0}
|
||||||
|
active={selectedTab === key}
|
||||||
|
onClick={() => handleSelectTab(key)}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{pipeline ? (
|
||||||
|
<>
|
||||||
|
{selectedTab === "backlog" && (
|
||||||
|
<FlatTabContent
|
||||||
|
groups={aggregateItems(pipeline, "backlog")}
|
||||||
|
emptyMessage="No items in backlog."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedTab === "in-progress" && (
|
||||||
|
<InProgressTabContent
|
||||||
|
groups={aggregateItems(pipeline, "in-progress")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedTab === "done" && (
|
||||||
|
<FlatTabContent
|
||||||
|
groups={aggregateItems(pipeline, "done")}
|
||||||
|
emptyMessage="No completed items."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{selectedTab === "archived" && (
|
||||||
|
<FlatTabContent
|
||||||
|
groups={aggregateItems(pipeline, "archived")}
|
||||||
|
emptyMessage="No archived items."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ color: "#6e7681" }}>Loading pipeline status…</p>
|
<p style={{ color: "#6e7681" }}>Loading pipeline status…</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -90,10 +90,17 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, Strin
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let archived: Vec<Value> = state
|
||||||
|
.archived
|
||||||
|
.iter()
|
||||||
|
.map(|s| json!({ "story_id": s.story_id, "name": s.name, "stage": "archived" }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"active": active,
|
"active": active,
|
||||||
"backlog": backlog,
|
"backlog": backlog,
|
||||||
"backlog_count": backlog.len(),
|
"backlog_count": backlog.len(),
|
||||||
|
"archived": archived,
|
||||||
"deterministic_merges_in_flight": running_merges,
|
"deterministic_merges_in_flight": running_merges,
|
||||||
}))
|
}))
|
||||||
.map_err(|e| format!("Serialization error: {e}"))
|
.map_err(|e| format!("Serialization error: {e}"))
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ pub struct PipelineState {
|
|||||||
pub done: Vec<UpcomingStory>,
|
pub done: Vec<UpcomingStory>,
|
||||||
/// Abandoned, superseded, and rejected items (story 984).
|
/// Abandoned, superseded, and rejected items (story 984).
|
||||||
pub closed: Vec<UpcomingStory>,
|
pub closed: Vec<UpcomingStory>,
|
||||||
|
/// Items swept from Done into the archived terminal state.
|
||||||
|
pub archived: Vec<UpcomingStory>,
|
||||||
/// Story IDs that currently have a deterministic merge in progress.
|
/// Story IDs that currently have a deterministic merge in progress.
|
||||||
pub deterministic_merges_in_flight: Vec<String>,
|
pub deterministic_merges_in_flight: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -104,6 +106,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
|||||||
merge: Vec::new(),
|
merge: Vec::new(),
|
||||||
done: Vec::new(),
|
done: Vec::new(),
|
||||||
closed: Vec::new(),
|
closed: Vec::new(),
|
||||||
|
archived: Vec::new(),
|
||||||
deterministic_merges_in_flight,
|
deterministic_merges_in_flight,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -194,7 +197,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
|||||||
Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
|
Stage::Abandoned { .. } | Stage::Superseded { .. } | Stage::Rejected { .. } => {
|
||||||
state.closed.push(story)
|
state.closed.push(story)
|
||||||
}
|
}
|
||||||
Stage::Archived { .. } => {} // Completed/MergeFailed/ReviewHeld stay hidden
|
Stage::Archived { .. } => state.archived.push(story),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +208,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
|||||||
state.merge.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
state.merge.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||||
state.done.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
state.done.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||||
state.closed.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
state.closed.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||||
|
state.archived.sort_by(|a, b| a.story_id.cmp(&b.story_id));
|
||||||
|
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,11 +234,13 @@ pub async fn fetch_one_project_pipeline_items(url: &str, client: &Client) -> Val
|
|||||||
match serde_json::from_str::<Value>(text) {
|
match serde_json::from_str::<Value>(text) {
|
||||||
Ok(pipeline) => {
|
Ok(pipeline) => {
|
||||||
let active = pipeline.get("active").cloned().unwrap_or(json!([]));
|
let active = pipeline.get("active").cloned().unwrap_or(json!([]));
|
||||||
|
let backlog = pipeline.get("backlog").cloned().unwrap_or(json!([]));
|
||||||
let backlog_count = pipeline
|
let backlog_count = pipeline
|
||||||
.get("backlog_count")
|
.get("backlog_count")
|
||||||
.and_then(|n| n.as_u64())
|
.and_then(|n| n.as_u64())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
json!({ "active": active, "backlog_count": backlog_count })
|
let archived = pipeline.get("archived").cloned().unwrap_or(json!([]));
|
||||||
|
json!({ "active": active, "backlog": backlog, "backlog_count": backlog_count, "archived": archived })
|
||||||
}
|
}
|
||||||
Err(_) => json!({ "error": "invalid pipeline JSON" }),
|
Err(_) => json!({ "error": "invalid pipeline JSON" }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ mod tests {
|
|||||||
epic_id: None,
|
epic_id: None,
|
||||||
}],
|
}],
|
||||||
closed: vec![],
|
closed: vec![],
|
||||||
|
archived: vec![],
|
||||||
deterministic_merges_in_flight: vec![],
|
deterministic_merges_in_flight: vec![],
|
||||||
};
|
};
|
||||||
let resp = pipeline_state_to_response(state);
|
let resp = pipeline_state_to_response(state);
|
||||||
@@ -273,6 +274,7 @@ mod tests {
|
|||||||
merge: vec![],
|
merge: vec![],
|
||||||
done: vec![],
|
done: vec![],
|
||||||
closed: vec![],
|
closed: vec![],
|
||||||
|
archived: vec![],
|
||||||
deterministic_merges_in_flight: vec![],
|
deterministic_merges_in_flight: vec![],
|
||||||
};
|
};
|
||||||
let resp = pipeline_state_to_response(state);
|
let resp = pipeline_state_to_response(state);
|
||||||
@@ -311,6 +313,7 @@ mod tests {
|
|||||||
merge: vec![],
|
merge: vec![],
|
||||||
done: vec![],
|
done: vec![],
|
||||||
closed: vec![],
|
closed: vec![],
|
||||||
|
archived: vec![],
|
||||||
deterministic_merges_in_flight: vec![],
|
deterministic_merges_in_flight: vec![],
|
||||||
};
|
};
|
||||||
let resp: WsResponse = state.into();
|
let resp: WsResponse = state.into();
|
||||||
|
|||||||
Reference in New Issue
Block a user