feat(FN-4757): complete Step 3 — wire PrPanel and remove PrSection

Fusion-Task-Id: FN-4757
Fusion-Task-Lineage: 32236172-0b3b-4c19-b3a1-8ee0b91ea3ac
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 03:21:09 -07:00
committed by gsxdsm
parent 3c6be85b96
commit 0c8cc02ca3
8 changed files with 10 additions and 685 deletions

View File

@@ -7,7 +7,7 @@ const themeDataPath = path.resolve(__dirname, "../public/theme-data.css");
/**
* Theme-safety regression tests for status color tokens across
* TaskCard, GitHubBadge, and PrSection components.
* TaskCard, GitHubBadge, and PrPanel components.
*
* These tests verify that hardcoded rgba/hex colors have been replaced
* with theme-aware CSS custom properties using color-mix(), ensuring
@@ -228,8 +228,8 @@ describe("GitHubBadge theme safety", () => {
});
});
describe("PrSection theme safety", () => {
const componentPath = path.resolve(__dirname, "../components/PrSection.tsx");
describe("PrPanel theme safety", () => {
const componentPath = path.resolve(__dirname, "../components/PrPanel.tsx");
let source: string;
beforeAll(() => {

View File

@@ -1,261 +0,0 @@
import { useState, useCallback } from "react";
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } from "lucide-react";
import type { PrInfo } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { createPr, refreshPrStatus, type PrRefreshResponse } from "../api";
import type { ToastType } from "../hooks/useToast";
interface PrSectionProps {
taskId: string;
projectId?: string;
prInfo?: PrInfo;
automationStatus?: string | null;
autoMerge?: boolean;
isManualPrFlow?: boolean;
prAuthAvailable: boolean;
onPrCreated: (prInfo: PrInfo) => void;
onPrUpdated: (prInfo: PrInfo) => void;
addToast: (message: string, type?: ToastType) => void;
}
const STATUS_ICONS: Record<string, React.ReactNode> = {
open: <CircleDot size={16} />,
closed: <XCircle size={16} />,
merged: <GitMerge size={16} />,
};
export function PrSection({
taskId,
projectId,
prInfo,
automationStatus,
autoMerge = false,
isManualPrFlow = false,
prAuthAvailable,
onPrCreated,
onPrUpdated,
addToast,
}: PrSectionProps) {
const [showCreateForm, setShowCreateForm] = useState(false);
const [prTitle, setPrTitle] = useState("");
const [prBody, setPrBody] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [refreshState, setRefreshState] = useState<PrRefreshResponse | null>(null);
const handleCreate = useCallback(async () => {
if (!prTitle.trim()) return;
setIsCreating(true);
try {
const newPr = await createPr(taskId, {
title: prTitle.trim(),
body: prBody.trim() || undefined,
}, projectId);
onPrCreated(newPr);
setShowCreateForm(false);
setPrTitle("");
setPrBody("");
addToast(`Created PR #${newPr.number}`, "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to create PR", "error");
} finally {
setIsCreating(false);
}
}, [taskId, prTitle, prBody, projectId, onPrCreated, addToast]);
const handleRefresh = useCallback(async () => {
if (!prInfo) return;
setIsRefreshing(true);
try {
const updated = await refreshPrStatus(taskId, projectId);
setRefreshState(updated);
onPrUpdated(updated.prInfo);
addToast("PR status refreshed", "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to refresh PR", "error");
} finally {
setIsRefreshing(false);
}
}, [taskId, prInfo, projectId, onPrUpdated, addToast]);
// No PR yet - show create button or automation state
if (!prInfo) {
if (automationStatus === "creating-pr") {
return (
<div className="pr-section">
<h4>
<GitPullRequest size={16} className="pr-section-icon" />
Pull Request
</h4>
<div className="pr-hint pr-hint--muted">
fn is creating a pull request automatically for this task.
</div>
</div>
);
}
if (autoMerge) {
return (
<div className="pr-section">
<h4>
<GitPullRequest size={16} className="pr-section-icon" />
Pull Request
</h4>
<div className="pr-hint pr-hint--muted">
Auto-merge will handle this task automatically.
</div>
</div>
);
}
if (showCreateForm) {
return (
<div className="pr-section">
<h4>
<GitPullRequest size={16} className="pr-section-icon" />
Create Pull Request
</h4>
<div className="pr-form">
<input
type="text"
placeholder="PR title"
value={prTitle}
onChange={(e) => setPrTitle(e.target.value)}
disabled={isCreating}
className="pr-input"
/>
<textarea
placeholder="PR description (optional)"
value={prBody}
onChange={(e) => setPrBody(e.target.value)}
disabled={isCreating}
className="pr-textarea"
rows={3}
/>
<div className="pr-actions">
<button
className="btn btn-sm"
onClick={() => setShowCreateForm(false)}
disabled={isCreating}
>
Cancel
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleCreate}
disabled={!prTitle.trim() || isCreating}
>
{isCreating ? "Creating…" : "Create PR"}
</button>
</div>
</div>
</div>
);
}
return (
<div className="pr-section">
<h4>
<GitPullRequest size={16} className="pr-section-icon" />
Pull Request
</h4>
<button
className="btn btn-primary btn-sm"
onClick={() => setShowCreateForm(true)}
disabled={!prAuthAvailable}
title={prAuthAvailable ? "Create a PR for this task" : "PR auth unavailable — run 'gh auth login'"}
>
<Plus size={14} className="pr-section-icon--sm" />
Create PR
</button>
{isManualPrFlow && (
<div className="pr-hint pr-hint--subtle">
Use the footer action to run PR-first completion for this task.
</div>
)}
{!prAuthAvailable && (
<div className="pr-hint pr-hint--subtle">
Run <code>gh auth login</code> to enable PR creation.
</div>
)}
</div>
);
}
// PR exists - show PR card
const statusIcon = STATUS_ICONS[prInfo.status] ?? <CircleDot size={16} />;
const blockingReasons = refreshState?.blockingReasons ?? [];
return (
<div className="pr-section">
<h4>
<GitPullRequest size={16} className="pr-section-icon" />
Pull Request
</h4>
<div
className={`pr-card pr-card--status-${prInfo.status}`}
>
<div className="pr-header">
<span className="pr-status-icon">{statusIcon}</span>
<span
className={`pr-status-badge pr-status-badge--${prInfo.status}`}
>
{prInfo.status}
</span>
<span className="pr-number">#{prInfo.number}</span>
<div className="pr-spacer" />
<button
className="btn btn-sm pr-refresh-btn"
onClick={handleRefresh}
disabled={isRefreshing}
title="Refresh PR status"
>
<RefreshCw size={14} style={{ verticalAlign: "middle", opacity: isRefreshing ? 0.5 : 1 }} />
</button>
</div>
<div className="pr-title">{prInfo.title}</div>
<div className="pr-meta">
<span>{prInfo.headBranch}</span>
<span className="pr-meta-arrow"></span>
<span>{prInfo.baseBranch}</span>
</div>
{automationStatus === "merging-pr" && (
<div className="pr-hint pr-hint--info">
fn is merging this pull request automatically.
</div>
)}
{automationStatus === "awaiting-pr-checks" && (
<div className="pr-hint pr-hint--info">
{blockingReasons.length > 0
? `Waiting for: ${blockingReasons.join("; ")}`
: "Waiting for required checks or review feedback before auto-merge."}
</div>
)}
{prInfo.status === "merged" && (
<div className="pr-hint pr-hint--info">
This PR is merged. fn will finish local cleanup and move the task to Done.
</div>
)}
<div className="pr-footer">
{prInfo.commentCount > 0 && (
<span className="pr-comments">
<MessageSquare size={14} />
{prInfo.commentCount}
</span>
)}
<a
href={prInfo.url}
target="_blank"
rel="noopener noreferrer"
className="pr-link"
>
<ExternalLink size={14} />
View on GitHub
</a>
</div>
</div>
</div>
);
}

View File

@@ -25,7 +25,7 @@ import { useAgentLogs } from "../hooks/useAgentLogs";
import { useConfirm } from "../hooks/useConfirm";
import { AgentLogViewer } from "./AgentLogViewer";
import { ModelSelectorTab } from "./ModelSelectorTab";
import { PrSection } from "./PrSection";
import { PrPanel } from "./PrPanel";
import { TaskComments } from "./TaskComments";
import { TaskReviewTab } from "./TaskReviewTab";
import { MergeDetails } from "./MergeDetails";
@@ -3284,7 +3284,7 @@ export function TaskDetailContent({
);
})()}
<div className="detail-section detail-pr-section">
<PrSection
<PrPanel
taskId={task.id}
projectId={projectId}
prInfo={task.prInfo}
@@ -3292,10 +3292,8 @@ export function TaskDetailContent({
autoMerge={settings?.autoMerge ?? false}
isManualPrFlow={isManualPrFlow}
prAuthAvailable={prAuthAvailable ?? false}
onPrCreated={(prInfo) => {
// Update task locally to show new PR
(task as TaskDetail).prInfo = prInfo;
}}
// TODO(FN-4758): wire create-PR modal trigger
onRequestCreatePr={undefined}
onPrUpdated={(prInfo) => {
(task as TaskDetail).prInfo = prInfo;
}}

View File

@@ -1,407 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { PrSection } from "../PrSection";
// Mock the API module
vi.mock("../../api", () => ({
createPr: vi.fn(),
refreshPrStatus: vi.fn(),
}));
import { createPr, refreshPrStatus } from "../../api";
const mockAddToast = vi.fn();
const mockOnPrCreated = vi.fn();
const mockOnPrUpdated = vi.fn();
const mockPrInfo = {
url: "https://github.com/owner/repo/pull/42",
number: 42,
status: "open" as const,
title: "Fix the bug",
headBranch: "fusion/fn-001",
baseBranch: "main",
commentCount: 3,
lastCommentAt: "2026-01-01T00:00:00.000Z",
};
describe("PrSection", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("when task has no PR", () => {
it("shows create PR button when PR auth is available", () => {
render(
<PrSection
taskId="FN-001"
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.getByText("Create PR")).toBeDefined();
});
it("shows disabled button and hint when PR auth is unavailable", () => {
render(
<PrSection
taskId="FN-001"
prAuthAvailable={false}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
const button = screen.getByText("Create PR") as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(button.title).toContain("gh auth login");
expect(screen.getByText(/gh auth login/i)).toBeDefined();
});
it("shows create form when clicking create button", () => {
render(
<PrSection
taskId="FN-001"
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
fireEvent.click(screen.getByText("Create PR"));
expect(screen.getByPlaceholderText("PR title")).toBeDefined();
expect(screen.getByPlaceholderText("PR description (optional)")).toBeDefined();
expect(screen.getByText("Cancel")).toBeDefined();
});
it("hides form when clicking cancel", () => {
render(
<PrSection
taskId="FN-001"
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
fireEvent.click(screen.getByText("Create PR"));
fireEvent.click(screen.getByText("Cancel"));
expect(screen.queryByPlaceholderText("PR title")).toBeNull();
});
it("creates PR when form is submitted", async () => {
(createPr as ReturnType<typeof vi.fn>).mockResolvedValue(mockPrInfo);
render(
<PrSection
taskId="FN-001"
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
fireEvent.click(screen.getByText("Create PR"));
fireEvent.change(screen.getByPlaceholderText("PR title"), {
target: { value: "My PR Title" },
});
fireEvent.click(screen.getByText("Create PR"));
await waitFor(() => {
expect(createPr).toHaveBeenCalledWith("FN-001", {
title: "My PR Title",
body: undefined,
}, undefined);
});
expect(mockOnPrCreated).toHaveBeenCalledWith(mockPrInfo);
expect(mockAddToast).toHaveBeenCalledWith("Created PR #42", "success");
});
it("shows error when PR creation fails", async () => {
(createPr as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("API error"));
render(
<PrSection
taskId="FN-001"
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
fireEvent.click(screen.getByText("Create PR"));
fireEvent.change(screen.getByPlaceholderText("PR title"), {
target: { value: "My PR Title" },
});
fireEvent.click(screen.getByText("Create PR"));
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("API error", "error");
});
});
});
describe("when autoMerge is enabled", () => {
it("hides manual PR controls and shows auto-merge messaging when no PR exists", () => {
render(
<PrSection
taskId="FN-001"
autoMerge={true}
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.queryByRole("button", { name: "Create PR" })).toBeNull();
expect(screen.queryByText(/gh auth login/i)).toBeNull();
expect(screen.getByText("Auto-merge will handle this task automatically.")).toBeDefined();
});
it("does not show PR auth hint when auto-merge is enabled and PR auth is unavailable", () => {
render(
<PrSection
taskId="FN-001"
autoMerge={true}
prAuthAvailable={false}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.queryByText(/gh auth login/i)).toBeNull();
expect(screen.getByText("Auto-merge will handle this task automatically.")).toBeDefined();
});
it("still shows the creating-pr automation message when automation is active", () => {
render(
<PrSection
taskId="FN-001"
autoMerge={true}
automationStatus="creating-pr"
prAuthAvailable={false}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.getByText(/creating a pull request automatically/i)).toBeDefined();
expect(screen.queryByText("Auto-merge will handle this task automatically.")).toBeNull();
expect(screen.queryByRole("button", { name: "Create PR" })).toBeNull();
});
it("shows manual PR-footer hint only when manual PR flow is active", () => {
const { rerender } = render(
<PrSection
taskId="FN-001"
autoMerge={false}
isManualPrFlow={true}
prAuthAvailable={false}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.getByRole("button", { name: "Create PR" })).toBeDefined();
expect(screen.getByText(/Use the footer action to run PR-first completion/i)).toBeDefined();
expect(screen.getByText(/gh auth login/i)).toBeDefined();
expect(screen.queryByText("Auto-merge will handle this task automatically.")).toBeNull();
rerender(
<PrSection
taskId="FN-001"
autoMerge={false}
isManualPrFlow={false}
prAuthAvailable={false}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.queryByText(/Use the footer action to run PR-first completion/i)).toBeNull();
});
});
describe("when task has a PR", () => {
it("displays PR info for open PR", () => {
render(
<PrSection
taskId="FN-001"
prInfo={mockPrInfo}
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.getByText("#42")).toBeDefined();
expect(screen.getByText("Fix the bug")).toBeDefined();
expect(screen.getByText("open")).toBeDefined();
expect(screen.getByText("View on GitHub")).toBeDefined();
});
it("shows correct status badge for merged PR", () => {
render(
<PrSection
taskId="FN-001"
prInfo={{ ...mockPrInfo, status: "merged" }}
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.getByText("merged")).toBeDefined();
expect(screen.getByText(/finish local cleanup and move the task to Done/i)).toBeDefined();
});
it("shows correct status badge for closed PR", () => {
render(
<PrSection
taskId="FN-001"
prInfo={{ ...mockPrInfo, status: "closed" }}
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.getByText("closed")).toBeDefined();
});
it("displays comment count when PR has comments", () => {
render(
<PrSection
taskId="FN-001"
prInfo={{ ...mockPrInfo, commentCount: 5 }}
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
// The comment count should be rendered
expect(screen.getByText("5")).toBeDefined();
});
it("refreshes PR status when refresh button is clicked", async () => {
const updatedPr = { ...mockPrInfo, status: "merged" as const };
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
prInfo: updatedPr,
mergeReady: true,
blockingReasons: [],
reviewDecision: "APPROVED",
checks: [{ name: "ci", required: true, state: "success" }],
automationStatus: null,
});
render(
<PrSection
taskId="FN-001"
prInfo={mockPrInfo}
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
const refreshButton = screen.getByTitle("Refresh PR status");
fireEvent.click(refreshButton);
await waitFor(() => {
expect(refreshPrStatus).toHaveBeenCalledWith("FN-001", undefined);
});
expect(mockOnPrUpdated).toHaveBeenCalledWith(updatedPr);
expect(mockAddToast).toHaveBeenCalledWith("PR status refreshed", "success");
});
it("shows error when refresh fails", async () => {
(refreshPrStatus as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network error"));
render(
<PrSection
taskId="FN-001"
prInfo={mockPrInfo}
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
const refreshButton = screen.getByTitle("Refresh PR status");
fireEvent.click(refreshButton);
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith("Network error", "error");
});
});
it("shows automatic PR creation message while PR-first automation is creating a PR", () => {
render(
<PrSection
taskId="FN-001"
automationStatus="creating-pr"
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
expect(screen.getByText(/creating a pull request automatically/i)).toBeDefined();
expect(screen.queryByText("Create PR")).toBeNull();
});
it("shows awaiting-checks messaging from refreshed merge blockers", async () => {
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
prInfo: mockPrInfo,
mergeReady: false,
blockingReasons: ["required checks not successful: ci (pending)"],
reviewDecision: null,
checks: [{ name: "ci", required: true, state: "pending" }],
automationStatus: "awaiting-pr-checks",
});
render(
<PrSection
taskId="FN-001"
prInfo={mockPrInfo}
automationStatus="awaiting-pr-checks"
prAuthAvailable={true}
onPrCreated={mockOnPrCreated}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>
);
fireEvent.click(screen.getByTitle("Refresh PR status"));
await waitFor(() => {
expect(screen.getByText(/Waiting for: required checks not successful: ci \(pending\)/)).toBeDefined();
});
});
});
});

View File

@@ -42,7 +42,7 @@ vi.mock("../../api", async (importOriginal) => {
});
});
// Mock lucide-react icons used by TaskDetailModal, TaskForm, PrSection, CustomModelDropdown
// Mock lucide-react icons used by TaskDetailModal, TaskForm, PrPanel, CustomModelDropdown
vi.mock("lucide-react", () => ({
Pencil: () => null,
Sparkles: () => null,

View File

@@ -1927,11 +1927,6 @@ input[type="range"]:focus-visible {
margin-right: var(--space-sm);
}
.pr-section-icon--sm {
vertical-align: middle;
margin-right: var(--space-xs);
}
.pr-hint--muted {
opacity: 0.8;
font-size: 13px;