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:
committed by
gsxdsm
parent
76b1ba8178
commit
f8c20e1b96
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user