feat(FN-4760): complete Step 5 — wire live checks into PR panel

Fusion-Task-Id: FN-4760
Fusion-Task-Lineage: 71db4a53-ee59-49f0-a1f7-232954b86660
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 08:43:25 -07:00
committed by gsxdsm
parent a58c5df697
commit 9be005cd46
3 changed files with 49 additions and 55 deletions

View File

@@ -2,6 +2,8 @@ import { useCallback, useMemo, useState } from "react";
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } from "lucide-react";
import { getErrorMessage } from "@fusion/core";
import { refreshPrStatus, type PrCheckStatus, type PrInfo, type PrRefreshResponse } from "../api";
import { usePrChecksStream } from "../hooks/usePrChecksStream";
import { PrChecksList } from "./PrChecksList";
import type { ToastType } from "../hooks/useToast";
import "./PrPanel.css";
@@ -30,13 +32,6 @@ const PASSING_STATES = new Set<PrCheckState>(["success", "neutral", "skipped"]);
const FAILING_STATES = new Set<PrCheckState>(["failure", "error", "cancelled", "timed_out", "action_required", "startup_failure"]);
const PENDING_STATES = new Set<PrCheckState>(["pending", "stale"]);
function getCheckStateTone(state: PrCheckState | string): "success" | "error" | "warning" | "muted" {
if (PASSING_STATES.has(state)) return "success";
if (FAILING_STATES.has(state)) return "error";
if (PENDING_STATES.has(state)) return "warning";
return "muted";
}
function getReviewTone(reviewDecision: PrRefreshResponse["reviewDecision"]): "success" | "error" | "warning" | "muted" {
if (reviewDecision === "APPROVED") return "success";
if (reviewDecision === "CHANGES_REQUESTED") return "error";
@@ -133,18 +128,23 @@ export function PrPanel({
const reviewDecision = refreshState?.reviewDecision ?? null;
const checkSummary = useMemo(() => {
if (!checks) return null;
return checks.reduce(
(acc, check) => {
if (PASSING_STATES.has(check.state)) acc.passing += 1;
else if (FAILING_STATES.has(check.state)) acc.failing += 1;
else if (PENDING_STATES.has(check.state)) acc.pending += 1;
return acc;
},
{ passing: 0, failing: 0, pending: 0 }
);
if (!checks) return "unknown" as const;
if (checks.some((check) => FAILING_STATES.has(check.state))) return "failure" as const;
if (checks.some((check) => PENDING_STATES.has(check.state))) return "pending" as const;
if (checks.some((check) => PASSING_STATES.has(check.state))) return "success" as const;
return "unknown" as const;
}, [checks]);
const streamChecks = usePrChecksStream({
taskId,
projectId,
prNumber: prInfo.number,
enabled: prInfo.status !== "merged" && prInfo.status !== "closed",
initialChecks: checks ?? [],
initialRollup: checkSummary,
initialLastCheckedAt: prInfo.lastCheckedAt,
});
return (
<div className="pr-section">
<h4>
@@ -168,35 +168,18 @@ export function PrPanel({
<span>{prInfo.baseBranch}</span>
</div>
<div className="pr-panel-section">
<div className="pr-panel-row-label">Checks</div>
{checkSummary ? (
<>
<div className="pr-panel-checks-rollup">
<span className="pr-panel-tone-success">{checkSummary.passing} passing</span>
<span aria-hidden="true">·</span>
<span className="pr-panel-tone-error">{checkSummary.failing} failing</span>
<span aria-hidden="true">·</span>
<span className="pr-panel-tone-warning">{checkSummary.pending} pending</span>
</div>
<details className="pr-panel-checks-details">
<summary>Recent checks</summary>
<ul className="pr-panel-check-list">
{(checks ?? []).map((check: PrCheckStatus | { name: string; required: boolean; state: string }) => (
<li key={`${check.name}-${check.state}`} className="pr-panel-check-item">
<span className={`status-dot pr-panel-check-dot status-dot--${getCheckStateTone(check.state) === "success" ? "online" : getCheckStateTone(check.state) === "error" ? "error" : "pending"}`} />
<span className="pr-panel-check-name">{check.name}</span>
{check.required && <span className="pr-panel-required">Required</span>}
<span className={`pr-panel-check-chip pr-panel-check-chip--${getCheckStateTone(check.state)}`}>{check.state}</span>
</li>
))}
</ul>
</details>
</>
) : (
<div className="pr-hint pr-hint--subtle">Checks not yet loaded refresh to fetch.</div>
)}
</div>
{prInfo.status !== "merged" && prInfo.status !== "closed" ? (
<PrChecksList
checks={streamChecks.checks}
rollup={streamChecks.rollup}
lastCheckedAt={streamChecks.lastCheckedAt}
loading={streamChecks.loading}
error={streamChecks.error}
onRefresh={() => {
void streamChecks.refresh();
}}
/>
) : null}
<div className="pr-panel-section">
<div className="pr-panel-row-label">Review</div>

View File

@@ -4,9 +4,10 @@ import { PrPanel } from "../PrPanel";
vi.mock("../../api", () => ({
refreshPrStatus: vi.fn(),
fetchPrChecks: vi.fn(),
}));
import { refreshPrStatus } from "../../api";
import { refreshPrStatus, fetchPrChecks } from "../../api";
const mockAddToast = vi.fn();
const mockOnPrUpdated = vi.fn();
@@ -26,6 +27,11 @@ const mockPrInfo = {
describe("PrPanel", () => {
beforeEach(() => {
vi.clearAllMocks();
(fetchPrChecks as ReturnType<typeof vi.fn>).mockResolvedValue({
checks: [],
rollup: "unknown",
lastCheckedAt: new Date().toISOString(),
});
});
it("renders create button and calls onRequestCreatePr", () => {
@@ -96,7 +102,7 @@ describe("PrPanel", () => {
expect(mockAddToast).toHaveBeenCalledWith("PR status refreshed", "success");
});
it("renders checks rollup and details after refresh", async () => {
it("renders checks rollup after refresh", async () => {
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
prInfo: mockPrInfo,
checks: [
@@ -111,11 +117,7 @@ describe("PrPanel", () => {
render(<PrPanel taskId="FN-001" prInfo={mockPrInfo} prAuthAvailable={true} onPrUpdated={mockOnPrUpdated} addToast={mockAddToast} />);
fireEvent.click(screen.getByTitle("Refresh PR status"));
await screen.findByText("1 passing");
expect(screen.getByText("1 failing")).toBeInTheDocument();
expect(screen.getByText("1 pending")).toBeInTheDocument();
fireEvent.click(screen.getByText(/Recent checks/i));
expect(await screen.findByText("1 passing, 1 failing, 1 pending")).toBeInTheDocument();
expect(screen.getByText("build")).toBeInTheDocument();
expect(screen.getByText("lint")).toBeInTheDocument();
expect(screen.getByText("e2e")).toBeInTheDocument();
@@ -133,8 +135,7 @@ describe("PrPanel", () => {
render(<PrPanel taskId="FN-001" prInfo={mockPrInfo} prAuthAvailable={true} onPrUpdated={mockOnPrUpdated} addToast={mockAddToast} />);
fireEvent.click(screen.getByTitle("Refresh PR status"));
expect(await screen.findByText(/Checks not yet loaded/i)).toBeInTheDocument();
expect(screen.queryByText(/Recent checks/i)).toBeNull();
expect(await screen.findByText(/No checks reported yet/i)).toBeInTheDocument();
});
it("renders review decision states", async () => {

View File

@@ -135,6 +135,16 @@ export function usePrChecksStream({
await doFetch();
}, [doFetch]);
useEffect(() => {
const signature = computeSignature(initialChecks);
if (signature && signature !== previousSignatureRef.current) {
previousSignatureRef.current = signature;
setChecks(initialChecks);
setRollup(initialRollup);
setLastCheckedAt(initialLastCheckedAt);
}
}, [computeSignature, initialChecks, initialLastCheckedAt, initialRollup]);
useEffect(() => {
if (!shouldPoll) {
clearTimer();