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:
committed by
gsxdsm
parent
a58c5df697
commit
9be005cd46
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user