import { useCallback, useEffect, useMemo, useState } from "react"; import { GitPullRequest, ExternalLink, RefreshCw, Plus, MessageSquare, CircleDot, XCircle, GitMerge } 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"; 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; automationStatus?: string | null; taskColumn?: string; autoMerge?: boolean; isManualPrFlow?: boolean; prAuthAvailable: boolean; onPrUpdated: (prInfo: 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"; } export function PrPanel({ taskId, projectId, prInfo, automationStatus, taskColumn, autoMerge = false, isManualPrFlow = false, prAuthAvailable, onPrUpdated, onRequestCreatePr, directMergeCommitStrategy = "auto", addToast, }: PrPanelProps) { 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 [mergeStrategy, setMergeStrategy] = useState<"merge" | "squash" | "rebase">( directMergeCommitStrategy === "always-rebase" ? "rebase" : directMergeCommitStrategy === "always-squash" ? "squash" : "squash", ); useEffect(() => { if (!prInfo) { setReviewsState(null); return; } void fetchPrReviews(taskId, projectId) .then((data) => setReviewsState(data)) .catch(() => setReviewsState(null)); }, [taskId, projectId, prInfo]); const handleRefresh = useCallback(async () => { if (!prInfo) return; setIsRefreshing(true); setLastGhError(null); try { const updated = await refreshPrStatus(taskId, projectId); setRefreshState(updated); onPrUpdated(updated.prInfo); const latestReviews = await fetchPrReviews(taskId, projectId); 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, onPrUpdated, addToast]); const handleMerge = useCallback(async () => { if (!prInfo) return; setIsMerging(true); try { const result = await mergePr(taskId, mergeStrategy, projectId); 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, projectId, taskId]); const handleAutoMergeToggle = useCallback(async (enabled: boolean) => { if (!prInfo) return; try { const result = await setAutoMergeOnGreen(taskId, enabled, mergeStrategy, projectId); 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, projectId, taskId]); if (!prInfo) { 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.
)}
); } 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; return (

Pull Request

{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} {(prInfo.draft ?? prInfo.isDraft) === true && prInfo.status === "open" ? (
Ready for review required before merging.
) : 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
); }