feat(FN-4763): complete Step 5 — surface PR conflict reclaim in panel

Fusion-Task-Id: FN-4763
Fusion-Task-Lineage: b3628e63-682b-4a39-9a7d-2c96278c5366
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 13:25:27 -07:00
committed by gsxdsm
parent 76b1ba8178
commit f8c20e1b96
4 changed files with 110 additions and 2 deletions

View File

@@ -2213,10 +2213,12 @@ export interface PrStatusResponse {
export interface PrRefreshResponse {
prInfo: PrInfo;
mergeReady: boolean;
mergeable?: PrInfo["mergeable"];
blockingReasons: string[];
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
checks: PrCheckStatus[];
automationStatus?: string | null;
conflictReclaimQueued?: boolean;
}
export interface PrMergeResponse {
@@ -2354,6 +2356,12 @@ export function refreshPrStatus(id: string, projectId?: string): Promise<PrRefre
});
}
export function reclaimPrConflict(id: string, projectId?: string): Promise<{ queued: boolean; reason?: string }> {
return api<{ queued: boolean; reason?: string }>(withProjectId(`/tasks/${id}/pr/reclaim-conflict`, projectId), {
method: "POST",
});
}
export function mergePr(id: string, method?: "merge" | "squash" | "rebase", projectId?: string): Promise<PrMergeResponse> {
return api<PrMergeResponse>(withProjectId(`/tasks/${id}/pr/merge`, projectId), {
method: "POST",

View File

@@ -158,6 +158,20 @@
padding: var(--space-sm);
}
.pr-hint--conflict {
align-items: center;
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border: var(--btn-border-width) solid var(--color-warning);
border-radius: var(--radius-md);
color: var(--color-warning);
display: flex;
flex-wrap: wrap;
font-size: 0.8125rem;
gap: var(--space-sm);
justify-content: space-between;
padding: var(--space-sm);
}
.pr-hint--success {
background: color-mix(in srgb, var(--color-success) 12%, transparent);
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-success) 35%, transparent);

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } from "lucide-react";
import { getErrorMessage, type DirectMergeCommitStrategy } from "@fusion/core";
import { fetchPrReviews, mergePr, refreshPrStatus, setAutoMergeOnGreen, type PrCheckStatus, type PrInfo, type PrRefreshResponse, type PrReviewsResponse } from "../api";
import { fetchPrReviews, mergePr, reclaimPrConflict, refreshPrStatus, setAutoMergeOnGreen, type PrCheckStatus, type PrInfo, type PrRefreshResponse, type PrReviewsResponse } from "../api";
import { usePrChecksStream } from "../hooks/usePrChecksStream";
import { PrChecksList } from "./PrChecksList";
import type { ToastType } from "../hooks/useToast";
@@ -60,6 +60,7 @@ export function PrPanel({
const [refreshState, setRefreshState] = useState<PrRefreshResponse | null>(null);
const [reviewsState, setReviewsState] = useState<PrReviewsResponse | null>(null);
const [isMerging, setIsMerging] = useState(false);
const [isReclaimingConflict, setIsReclaimingConflict] = useState(false);
const [mergeStrategy, setMergeStrategy] = useState<"merge" | "squash" | "rebase">(
directMergeCommitStrategy === "always-rebase"
? "rebase"
@@ -208,6 +209,8 @@ export function PrPanel({
const mergeReady = (refreshState?.mergeReady ?? false) && prInfo.status === "open";
const blockingReasonsTitle = (refreshState?.blockingReasons ?? []).join("; ");
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;
return (
<div className="pr-section">
@@ -311,6 +314,36 @@ export function PrPanel({
</div>
) : null}
{showConflictHint ? (
<div className="pr-hint pr-hint--conflict">
Merge conflict detected. Resolve/rebase branch and retry reclaim.
<button
className="btn btn-sm"
onClick={async () => {
setIsReclaimingConflict(true);
try {
const result = await reclaimPrConflict(taskId, projectId);
if (result.queued) {
addToast("Conflict reclaim queued", "success");
const updated = await refreshPrStatus(taskId, projectId);
setRefreshState(updated);
onPrUpdated(updated.prInfo);
} else {
addToast(result.reason ?? "Conflict reclaim unavailable", "warning");
}
} catch (err) {
addToast(getErrorMessage(err) || "Failed to queue conflict reclaim", "error");
} finally {
setIsReclaimingConflict(false);
}
}}
disabled={isReclaimingConflict}
>
Retry conflict reclaim
</button>
</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}

View File

@@ -7,10 +7,11 @@ vi.mock("../../api", () => ({
fetchPrChecks: vi.fn(),
fetchPrReviews: vi.fn(),
mergePr: vi.fn(),
reclaimPrConflict: vi.fn(),
setAutoMergeOnGreen: vi.fn(),
}));
import { refreshPrStatus, fetchPrChecks, fetchPrReviews, mergePr, setAutoMergeOnGreen } from "../../api";
import { refreshPrStatus, fetchPrChecks, fetchPrReviews, mergePr, reclaimPrConflict, setAutoMergeOnGreen } from "../../api";
const mockAddToast = vi.fn();
const mockOnPrUpdated = vi.fn();
@@ -162,6 +163,58 @@ describe("PrPanel", () => {
expect(await screen.findByText("No reviews yet")).toBeInTheDocument();
});
it("renders conflict hint and retries conflict reclaim", async () => {
(reclaimPrConflict as ReturnType<typeof vi.fn>).mockResolvedValue({ queued: true });
(refreshPrStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
prInfo: { ...mockPrInfo, mergeable: "conflicting" },
checks: [],
reviewDecision: null,
blockingReasons: ["conflict"],
mergeReady: false,
});
render(
<PrPanel
taskId="FN-001"
projectId="project-1"
prInfo={{ ...mockPrInfo, mergeable: "conflicting" }}
prAuthAvailable={true}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Retry conflict reclaim/i }));
await waitFor(() => {
expect(reclaimPrConflict).toHaveBeenCalledWith("FN-001", "project-1");
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,
checks: [],
reviewDecision: null,
blockingReasons: ["merge conflict"],
mergeReady: false,
});
render(
<PrPanel
taskId="FN-001"
prInfo={mockPrInfo}
prAuthAvailable={true}
onPrUpdated={mockOnPrUpdated}
addToast={mockAddToast}
/>,
);
fireEvent.click(screen.getByTitle("Refresh PR status"));
expect(await screen.findByRole("button", { name: /Retry conflict reclaim/i })).toBeInTheDocument();
});
it("shows error toast when refresh fails", async () => {
(refreshPrStatus as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("refresh failed"));