import { useCallback, useEffect, useMemo, useState } from "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, unlinkPr, 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"; import { linkifyFilePaths } from "../utils/filePathLinkify"; import "./PrPanel.css"; interface PrPanelProps { taskId: string; projectId?: string; prInfo?: PrInfo; prInfos?: PrInfo[]; automationStatus?: string | null; taskColumn?: string; autoMerge?: boolean; isManualPrFlow?: boolean; prAuthAvailable: boolean; onPrUpdated: (prInfo: PrInfo) => void; onPrUnlinked?: (prNumber: number) => void; onPrsRefreshed?: (prInfos: PrInfo[]) => void; onRequestCreatePr?: () => void; directMergeCommitStrategy?: DirectMergeCommitStrategy; addToast: (message: string, type?: ToastType) => void; } const STATUS_ICONS: Record = { open: , closed: , merged: , }; type PrCheckState = PrCheckStatus["state"]; const PASSING_STATES = new Set(["success", "neutral", "skipped"]); const FAILING_STATES = new Set(["failure", "error", "cancelled", "timed_out", "action_required", "startup_failure"]); const PENDING_STATES = new Set(["pending", "stale"]); function getReviewTone(reviewDecision: PrRefreshResponse["reviewDecision"]): "success" | "error" | "warning" | "muted" { if (reviewDecision === "APPROVED") return "success"; if (reviewDecision === "CHANGES_REQUESTED") return "error"; if (reviewDecision === "REVIEW_REQUIRED") return "warning"; return "muted"; } function getPrRollupState(prInfo: PrInfo): "conflicting" | "failing checks" | "changes_requested" | "draft" | "open" | "merged" | "closed" { if (prInfo.mergeable === "conflicting") return "conflicting"; if (prInfo.lastReviewDecision === "CHANGES_REQUESTED") return "changes_requested"; if ((prInfo.draft ?? prInfo.isDraft) && prInfo.status === "open") return "draft"; if (prInfo.status === "merged") return "merged"; if (prInfo.status === "closed") return "closed"; return "open"; } const ROLLUP_PRIORITY: Array> = ["conflicting", "failing checks", "changes_requested", "draft", "open", "merged", "closed"]; function getWorstRollupState(prInfos: PrInfo[]): string { const states = prInfos.map((prInfo) => getPrRollupState(prInfo)); return ROLLUP_PRIORITY.find((state) => states.includes(state)) ?? "open"; } function PrCard({ taskId, projectId, prInfo, automationStatus, taskColumn, onPrUpdated, onPrUnlinked, onPrsRefreshed, directMergeCommitStrategy, addToast, }: { taskId: string; projectId?: string; prInfo: PrInfo; automationStatus?: string | null; taskColumn?: string; onPrUpdated: (prInfo: PrInfo) => void; onPrUnlinked?: (prNumber: number) => void; onPrsRefreshed?: (prInfos: PrInfo[]) => void; directMergeCommitStrategy: DirectMergeCommitStrategy; addToast: (message: string, type?: ToastType) => void; }) { const [isRefreshing, setIsRefreshing] = useState(false); const [refreshState, setRefreshState] = useState(null); const [reviewsState, setReviewsState] = useState(null); 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" : directMergeCommitStrategy === "always-squash" ? "squash" : "squash", ); useEffect(() => { void fetchPrReviews(taskId, projectId, prInfo.number) .then((data) => setReviewsState(data)) .catch(() => setReviewsState(null)); }, [taskId, projectId, prInfo.number]); const handleRefresh = useCallback(async () => { setIsRefreshing(true); setLastGhError(null); try { const updated = await refreshPrStatus(taskId, projectId); setRefreshState(updated); const refreshedPrInfos = updated.all?.map((entry) => entry.prInfo) ?? [updated.prInfo]; const targetPr = refreshedPrInfos.find((entry) => entry.number === prInfo.number); if (onPrsRefreshed) onPrsRefreshed(refreshedPrInfos); if (targetPr) onPrUpdated(targetPr); const latestReviews = await fetchPrReviews(taskId, projectId, prInfo.number); setReviewsState(latestReviews); addToast("PR status refreshed", "success"); } catch (err) { const details = (err as { details?: { githubError?: StructuredGhError } })?.details?.githubError; const structured = details ? { ...details, operation: "refresh" as const } : { code: "unknown" as const, message: getErrorMessage(err) || "Failed to refresh PR", retryable: true, action: { kind: "retry" as const }, operation: "refresh" as const }; setLastGhError(structured); addToast(structured.message || "Failed to refresh PR", "error"); } finally { setIsRefreshing(false); } }, [taskId, projectId, prInfo.number, onPrUpdated, onPrsRefreshed, addToast]); const handleMerge = useCallback(async () => { setIsMerging(true); try { const result = await mergePr(taskId, mergeStrategy, projectId, prInfo.number); onPrUpdated(result.prInfo); addToast("Pull request merged", "success"); } catch (err) { addToast(getErrorMessage(err) || "Failed to merge pull request", "error"); } finally { setIsMerging(false); } }, [addToast, mergeStrategy, onPrUpdated, prInfo.number, projectId, taskId]); const handleAutoMergeToggle = useCallback(async (enabled: boolean) => { try { const result = await setAutoMergeOnGreen(taskId, enabled, mergeStrategy, projectId, prInfo.number); onPrUpdated(result.prInfo); addToast(enabled ? "Auto-merge enabled" : "Auto-merge disabled", "success"); } catch (err) { addToast(getErrorMessage(err) || "Failed to update auto-merge", "error"); } }, [addToast, mergeStrategy, onPrUpdated, prInfo.number, projectId, taskId]); const handleUnlink = useCallback(async () => { if (!window.confirm(`Unlink PR #${prInfo.number} from this task? The PR will not be closed.`)) { return; } await unlinkPr(taskId, prInfo.number, projectId); onPrUnlinked?.(prInfo.number); addToast(`Unlinked PR #${prInfo.number}`, "success"); }, [addToast, onPrUnlinked, prInfo.number, projectId, taskId]); const statusIcon = STATUS_ICONS[prInfo.status] ?? ; const blockingReasons = refreshState?.blockingReasons ?? []; const checks = refreshState?.checks; const reviewDecision = refreshState?.reviewDecision ?? reviewsState?.snapshot.decision ?? prInfo.lastReviewDecision ?? null; const groupedReviews = useMemo(() => { const grouped = new Map>(); for (const item of reviewsState?.snapshot.items ?? []) { const key = item.author.login; const list = grouped.get(key) ?? []; list.push(item); grouped.set(key, list); } return Array.from(grouped.entries()); }, [reviewsState]); const checkSummary = useMemo(() => { 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, }); 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; const conflictDiagnostics = refreshState?.conflictDiagnostics ?? prInfo.conflictDiagnostics; useEffect(() => { setConflictsExpanded((conflictDiagnostics?.conflictingFiles.length ?? 0) > 0); }, [conflictDiagnostics?.capturedAt, conflictDiagnostics?.conflictingFiles.length]); return (
{statusIcon} {prInfo.status} #{prInfo.number}
{prInfo.title}
{lastGhError ? (
{lastGhError.message}
{lastGhError.hint ?
{lastGhError.hint}
: null}
{lastGhError.action?.kind === "shell" ?
Action: run {lastGhError.action.command}
: null} {lastGhError.retryable ? : null}
) : null}
{prInfo.headBranch}{prInfo.baseBranch}
{prInfo.status !== "merged" && prInfo.status !== "closed" ? ( { void streamChecks.refresh(); }} /> ) : null}
Review
{reviewDecision ? {reviewDecision} : No reviews yet}
Reviews
{groupedReviews.length === 0 ? No review comments synced yet : null} {groupedReviews.map(([reviewer, items]) => (
@{reviewer}{items.at(-1)?.state ?? "COMMENTED"}
{items.map((item) => {linkifyFilePaths(item.body, { keyPrefix: item.id })})}
))}
{showMergeControls ? (
Merge
{prInfo.lastMergeError ?
{prInfo.lastMergeError}
: null}
) : null} {showConflictHint ?
Merge conflict detected. Resolve/rebase branch and retry reclaim.
: null} {conflictDiagnostics && (prInfo.mergeable === "conflicting" || hasConflictBlockingReason) ? (
{conflictsExpanded ? <>
    {conflictDiagnostics.conflictingFiles.map((file) =>
  • {linkifyFilePaths(file, { keyPrefix: `pr-conflict-${file}` })}
  • )}
{conflictDiagnostics.suggestedCommands.join("\n")}
Captured: {new Date(conflictDiagnostics.capturedAt).toLocaleString()}
: null}
) : null} {reviewDecision === "CHANGES_REQUESTED" && taskColumn === "todo" &&
Auto-moved to Todo — reviewer feedback ready
} {automationStatus === "merging-pr" &&
fn is merging this pull request automatically.
} {automationStatus === "awaiting-pr-checks" &&
{blockingReasons.length > 0 ? `Waiting for: ${blockingReasons.join("; ")}` : "Waiting for required checks or review feedback before auto-merge."}
} {prInfo.status === "merged" &&
Merged — task moved to Done
}
{prInfo.commentCount}{prInfo.lastCommentAt ? Last: {new Date(prInfo.lastCommentAt).toLocaleString()} : null}View on GitHub
); } export function PrPanel({ taskId, projectId, prInfo, prInfos, automationStatus, taskColumn, autoMerge = false, isManualPrFlow = false, prAuthAvailable, onPrUpdated, onPrUnlinked, onPrsRefreshed, onRequestCreatePr, directMergeCommitStrategy = "auto", addToast }: PrPanelProps) { const prList = prInfos ?? (prInfo ? [prInfo] : []); if (prList.length === 0) { if (automationStatus === "creating-pr") { return

Pull Request

fn is creating a pull request automatically for this task.
; } if (autoMerge) { return

Pull Request

Auto-merge will handle this task automatically.
; } const createDisabled = !prAuthAvailable || !onRequestCreatePr; return

Pull Request

{isManualPrFlow &&
Use the footer action to run PR-first completion for this task.
}{(!prAuthAvailable || !onRequestCreatePr) &&
Run gh auth login to enable PR creation.
}
; } return (

Pull Request

{prList.length > 1 ?
{prList.length} pull requests{getWorstRollupState(prList)}
: null}
{prList.map((prEntry) => ( ))}
); }