feat(FN-4966): complete Step 5 — add PR conflict diagnostics panel UI
Fusion-Task-Id: FN-4966 Fusion-Task-Lineage: 8f5ea300-feaa-469c-aeed-f7304a5a1b6d
This commit is contained in:
@@ -186,6 +186,36 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-conflict-section {
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-conflict-section__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pr-conflict-files {
|
||||
margin: 0;
|
||||
padding-inline-start: var(--space-lg);
|
||||
}
|
||||
|
||||
.pr-conflict-commands {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.pr-merge-error {
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
@@ -240,4 +270,9 @@
|
||||
.pr-panel-check-chip {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pr-conflict-section__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } from "lucide-react";
|
||||
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { getErrorMessage, type DirectMergeCommitStrategy, type StructuredGhError } from "@fusion/core";
|
||||
import { fetchPrReviews, mergePr, reclaimPrConflict, refreshPrStatus, setAutoMergeOnGreen, type PrCheckStatus, type PrInfo, type PrRefreshResponse, type PrReviewsResponse } from "../api";
|
||||
import { usePrChecksStream } from "../hooks/usePrChecksStream";
|
||||
@@ -62,6 +62,8 @@ export function PrPanel({
|
||||
const [isMerging, setIsMerging] = useState(false);
|
||||
const [lastGhError, setLastGhError] = useState<(StructuredGhError & { operation: "refresh" }) | null>(null);
|
||||
const [isReclaimingConflict, setIsReclaimingConflict] = useState(false);
|
||||
const [conflictsExpanded, setConflictsExpanded] = useState(false);
|
||||
const [copiedConflicts, setCopiedConflicts] = useState(false);
|
||||
const [mergeStrategy, setMergeStrategy] = useState<"merge" | "squash" | "rebase">(
|
||||
directMergeCommitStrategy === "always-rebase"
|
||||
? "rebase"
|
||||
@@ -217,6 +219,11 @@ export function PrPanel({
|
||||
const showMergeControls = prInfo.status === "open" && (prInfo.draft ?? prInfo.isDraft) !== true;
|
||||
const hasConflictBlockingReason = blockingReasons.some((reason) => reason.toLowerCase().includes("conflict"));
|
||||
const showConflictHint = prInfo.mergeable === "conflicting" || hasConflictBlockingReason;
|
||||
const conflictDiagnostics = refreshState?.conflictDiagnostics ?? prInfo.conflictDiagnostics;
|
||||
|
||||
useEffect(() => {
|
||||
setConflictsExpanded((conflictDiagnostics?.conflictingFiles.length ?? 0) > 0);
|
||||
}, [conflictDiagnostics?.capturedAt, conflictDiagnostics?.conflictingFiles.length]);
|
||||
|
||||
return (
|
||||
<div className="pr-section">
|
||||
@@ -361,6 +368,43 @@ export function PrPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{conflictDiagnostics && (prInfo.mergeable === "conflicting" || hasConflictBlockingReason) ? (
|
||||
<div className="pr-conflict-section">
|
||||
<div className="pr-conflict-section__header">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setConflictsExpanded((value) => !value)}>
|
||||
{conflictsExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />} Conflicts
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleRefresh()} disabled={isRefreshing}>Re-check conflicts</button>
|
||||
</div>
|
||||
{conflictsExpanded ? (
|
||||
<>
|
||||
{conflictDiagnostics.conflictingFiles.length > 0 ? (
|
||||
<ul className="pr-conflict-files">
|
||||
{conflictDiagnostics.conflictingFiles.map((file) => <li key={file}>{linkifyFilePaths(file, { keyPrefix: `pr-conflict-${file}` })}</li>)}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="pr-panel-tone-muted">File list unavailable — run the suggested commands locally.</div>
|
||||
)}
|
||||
<pre className="pr-conflict-commands"><code>{conflictDiagnostics.suggestedCommands.join("\n")}</code></pre>
|
||||
<div className="pr-conflict-section__header">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(conflictDiagnostics.suggestedCommands.join("\n"));
|
||||
setCopiedConflicts(true);
|
||||
setTimeout(() => setCopiedConflicts(false), 1200);
|
||||
}}
|
||||
>
|
||||
{copiedConflicts ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<span className="pr-panel-tone-muted">Captured: {new Date(conflictDiagnostics.capturedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(prInfo.draft ?? prInfo.isDraft) === true && prInfo.status === "open" ? (
|
||||
<div className="pr-hint pr-hint--warning">Ready for review required before merging.</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { PrPanel } from "../PrPanel";
|
||||
|
||||
@@ -13,6 +13,7 @@ vi.mock("../../api", () => ({
|
||||
|
||||
import { refreshPrStatus, fetchPrChecks, fetchPrReviews, mergePr, reclaimPrConflict, setAutoMergeOnGreen } from "../../api";
|
||||
|
||||
const originalClipboard = navigator.clipboard;
|
||||
const mockAddToast = vi.fn();
|
||||
const mockOnPrUpdated = vi.fn();
|
||||
const mockOnRequestCreatePr = vi.fn();
|
||||
@@ -46,6 +47,17 @@ describe("PrPanel", () => {
|
||||
(fetchPrReviews as ReturnType<typeof vi.fn>).mockResolvedValue({ snapshot: { decision: null, items: [] }, comments: [] });
|
||||
(mergePr as ReturnType<typeof vi.fn>).mockResolvedValue({ prInfo: { ...mockPrInfo, status: "merged" } });
|
||||
(setAutoMergeOnGreen as ReturnType<typeof vi.fn>).mockResolvedValue({ prInfo: { ...mockPrInfo, autoMergeOnGreen: true } });
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: originalClipboard,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders create button and calls onRequestCreatePr", () => {
|
||||
@@ -258,6 +270,88 @@ describe("PrPanel", () => {
|
||||
expect(screen.getByText(/Please split this function/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders conflict diagnostics subsection with files and commands", () => {
|
||||
render(
|
||||
<PrPanel
|
||||
taskId="FN-001"
|
||||
prInfo={{
|
||||
...mockPrInfo,
|
||||
mergeable: "conflicting",
|
||||
conflictDiagnostics: {
|
||||
conflictingFiles: ["packages/dashboard/src/github.ts", "packages/core/src/types.ts"],
|
||||
suggestedCommands: ["git fetch origin", "git checkout fusion/fn-001", "git rebase origin/main", "# Resolve conflicts then: git add <files> && git rebase --continue"],
|
||||
capturedAt: "2026-05-18T00:00:00.000Z",
|
||||
},
|
||||
}}
|
||||
prAuthAvailable={true}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("packages/dashboard/src/github.ts")).toBeInTheDocument();
|
||||
expect(screen.getByText("packages/core/src/types.ts")).toBeInTheDocument();
|
||||
expect(screen.getByText(/git checkout fusion\/fn-001/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides conflict diagnostics subsection when not conflicting and no diagnostics", () => {
|
||||
render(<PrPanel taskId="FN-001" prInfo={{ ...mockPrInfo, mergeable: "clean" }} prAuthAvailable={true} onPrUpdated={mockOnPrUpdated} addToast={mockAddToast} />);
|
||||
expect(screen.queryByText("Conflicts")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Re-check conflicts" })).toBeNull();
|
||||
});
|
||||
|
||||
it("copies suggested commands from diagnostics", async () => {
|
||||
render(
|
||||
<PrPanel
|
||||
taskId="FN-001"
|
||||
prInfo={{
|
||||
...mockPrInfo,
|
||||
mergeable: "conflicting",
|
||||
conflictDiagnostics: {
|
||||
conflictingFiles: ["packages/dashboard/src/github.ts"],
|
||||
suggestedCommands: ["git fetch origin", "git checkout fusion/fn-001"],
|
||||
capturedAt: "2026-05-18T00:00:00.000Z",
|
||||
},
|
||||
}}
|
||||
prAuthAvailable={true}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
|
||||
await waitFor(() => {
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("git fetch origin\ngit checkout fusion/fn-001");
|
||||
});
|
||||
});
|
||||
|
||||
it("re-check conflicts triggers refreshPrStatus", async () => {
|
||||
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({ prInfo: mockPrInfo, checks: [], reviewDecision: null, blockingReasons: [] });
|
||||
render(
|
||||
<PrPanel
|
||||
taskId="FN-001"
|
||||
projectId="project-1"
|
||||
prInfo={{
|
||||
...mockPrInfo,
|
||||
mergeable: "conflicting",
|
||||
conflictDiagnostics: {
|
||||
conflictingFiles: ["packages/dashboard/src/github.ts"],
|
||||
suggestedCommands: ["git fetch origin"],
|
||||
capturedAt: "2026-05-18T00:00:00.000Z",
|
||||
},
|
||||
}}
|
||||
prAuthAvailable={true}
|
||||
onPrUpdated={mockOnPrUpdated}
|
||||
addToast={mockAddToast}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Re-check conflicts" }));
|
||||
await waitFor(() => {
|
||||
expect(refreshPrStatus).toHaveBeenCalledWith("FN-001", "project-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows conflict hint from blocking reasons after refresh", async () => {
|
||||
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
prInfo: mockPrInfo,
|
||||
|
||||
Reference in New Issue
Block a user