import React from 'react'; import { RiAiGenerate2, RiCheckboxBlankLine, RiCheckboxLine, RiExternalLinkLine, RiGitPullRequestLine, RiLoader4Line, } from '@remixicon/react'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { generatePullRequestDescription } from '@/lib/gitApi'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useDeviceInfo } from '@/lib/device'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageStore } from '@/stores/messageStore'; import { useSessionStore } from '@/stores/useSessionStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import type { GitHubPullRequest, GitHubCheckRun, GitHubPullRequestContextResult, GitHubPullRequestStatus, } from '@/lib/api/types'; type MergeMethod = 'merge' | 'squash' | 'rebase'; const statusColor = (state: string | undefined | null): string => { switch (state) { case 'success': return 'bg-[color:var(--status-success)]'; case 'failure': return 'bg-[color:var(--status-error)]'; case 'pending': return 'bg-[color:var(--status-warning)]'; default: return 'bg-muted-foreground/40'; } }; const branchToTitle = (branch: string): string => { return branch .replace(/^refs\/heads\//, '') .replace(/[-_]+/g, ' ') .replace(/\s+/g, ' ') .trim() .replace(/\b\w/g, (c) => c.toUpperCase()); }; type PullRequestDraftSnapshot = { title: string; body: string; draft: boolean; isOpen: boolean; additionalContext: string; }; const pullRequestDraftSnapshots = new Map(); const openExternal = async (url: string) => { if (typeof window === 'undefined') return; const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise } }).opencodeDesktop; if (desktop?.openExternal) { try { const result = await desktop.openExternal(url); if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) { return; } } catch { // fall through } } try { window.open(url, '_blank', 'noopener,noreferrer'); } catch { // ignore } }; export const PullRequestSection: React.FC<{ directory: string; branch: string; baseBranch: string; }> = ({ directory, branch, baseBranch }) => { const { github } = useRuntimeAPIs(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); const setSidebarSection = useUIStore((state) => state.setSidebarSection); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const currentSessionId = useSessionStore((state) => state.currentSessionId); const { isMobile } = useDeviceInfo(); const openGitHubSettings = React.useCallback(() => { setSidebarSection('settings'); setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSidebarSection]); const snapshotKey = React.useMemo(() => `${directory}::${branch}`, [directory, branch]); const initialSnapshot = React.useMemo( () => pullRequestDraftSnapshots.get(snapshotKey) ?? null, [snapshotKey] ); const [isOpen, setIsOpen] = React.useState(initialSnapshot?.isOpen ?? true); const [isLoading, setIsLoading] = React.useState(false); const [status, setStatus] = React.useState(null); const [error, setError] = React.useState(null); const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch)); const [body, setBody] = React.useState(() => initialSnapshot?.body ?? ''); const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false); const [additionalContext, setAdditionalContext] = React.useState(() => initialSnapshot?.additionalContext ?? ''); const [mergeMethod, setMergeMethod] = React.useState('squash'); const [isGenerating, setIsGenerating] = React.useState(false); const [isCreating, setIsCreating] = React.useState(false); const [isMerging, setIsMerging] = React.useState(false); const [isMarkingReady, setIsMarkingReady] = React.useState(false); const [isContextOpen, setIsContextOpen] = React.useState(false); const [isContextSheetOpen, setIsContextSheetOpen] = React.useState(false); const [checksDialogOpen, setChecksDialogOpen] = React.useState(false); const [checkDetails, setCheckDetails] = React.useState(null); const [isLoadingCheckDetails, setIsLoadingCheckDetails] = React.useState(false); const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch); const pr = status?.pr ?? null; const openChecksDialog = React.useCallback(async () => { if (!github?.prContext) { toast.error('GitHub runtime API unavailable'); return; } if (!pr) return; setChecksDialogOpen(true); setIsLoadingCheckDetails(true); try { const ctx = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: true, }); setCheckDetails(ctx); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load check details', { description: message }); } finally { setIsLoadingCheckDetails(false); } }, [directory, github, pr]); const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun) => { const status = run.status || 'unknown'; const conclusion = run.conclusion ?? undefined; const statusText = conclusion ? `${status} / ${conclusion}` : status; const appName = run.app?.name || run.app?.slug; return (
{run.name}
{appName ? `${appName} · ${statusText}` : statusText}
{run.output?.summary ? (
{run.output.summary}
) : null} {run.job?.steps && run.job.steps.length > 0 ? (
Steps
{run.job.steps.map((step, idx) => { const c = (step.conclusion || '').toLowerCase(); const isFail = c && !['success', 'neutral', 'skipped'].includes(c); return (
{step.name} {step.conclusion ? {step.conclusion} : null}
); })}
) : null}
{run.detailsUrl ? ( ) : null}
); }, []); const sendFailedChecksToChat = React.useCallback(async () => { setActiveMainTab('chat'); if (!github?.prContext) { toast.error('GitHub runtime API unavailable'); return; } if (!directory || !pr) return; if (!currentSessionId) { toast.error('No active session', { description: 'Open a chat session first.' }); return; } const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); const lastUsedProvider = useMessageStore.getState().lastUsedProvider; const providerID = currentProviderId || lastUsedProvider?.providerID; const modelID = currentModelId || lastUsedProvider?.modelID; if (!providerID || !modelID) { toast.error('No model selected'); return; } try { const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: true }); const runs = context.checkRuns ?? []; const failed = runs.filter((r) => { const conclusion = typeof r.conclusion === 'string' ? r.conclusion.toLowerCase() : ''; if (!conclusion) return false; return !['success', 'neutral', 'skipped'].includes(conclusion); }); if (failed.length === 0) { toast.message('No failed checks'); return; } const visibleText = 'Review these PR failed checks and propose likely fixes. Do not implement until I confirm.'; const instructionsText = `Use the attached checks payload. - Summarize what is failing. - Identify likely root cause(s). - Propose a minimal fix plan and verification steps. - No speculation: ask for missing info if needed.`; const payloadText = `GitHub PR failed checks (JSON)\n${JSON.stringify({ repo: context.repo ?? null, pr: context.pr ?? null, failedChecks: failed, }, null, 2)}`; void useMessageStore.getState().sendMessage( visibleText, providerID, modelID, currentAgentName ?? undefined, currentSessionId, undefined, null, [ { text: instructionsText, synthetic: true }, { text: payloadText, synthetic: true }, ], currentVariant ).catch((e) => { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to send message', { description: message }); }); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load checks', { description: message }); } }, [currentSessionId, directory, github, pr, setActiveMainTab]); const sendCommentsToChat = React.useCallback(async () => { setActiveMainTab('chat'); if (!github?.prContext) { toast.error('GitHub runtime API unavailable'); return; } if (!directory || !pr) return; if (!currentSessionId) { toast.error('No active session', { description: 'Open a chat session first.' }); return; } const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState(); const lastUsedProvider = useMessageStore.getState().lastUsedProvider; const providerID = currentProviderId || lastUsedProvider?.providerID; const modelID = currentModelId || lastUsedProvider?.modelID; if (!providerID || !modelID) { toast.error('No model selected'); return; } try { const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false }); const issueComments = context.issueComments ?? []; const reviewComments = context.reviewComments ?? []; const total = issueComments.length + reviewComments.length; if (total === 0) { toast.message('No PR comments'); return; } const visibleText = 'Review these PR comments and propose the required changes and next actions. Do not implement until I confirm.'; const instructionsText = `Use the attached comments payload. - Identify required vs optional changes. - Call out intent/implementation mismatch if present. - Propose a minimal plan and verification steps. - No speculation: ask for missing info if needed.`; const payloadText = `GitHub PR comments (JSON)\n${JSON.stringify({ repo: context.repo ?? null, pr: context.pr ?? null, issueComments, reviewComments, }, null, 2)}`; void useMessageStore.getState().sendMessage( visibleText, providerID, modelID, currentAgentName ?? undefined, currentSessionId, undefined, null, [ { text: instructionsText, synthetic: true }, { text: payloadText, synthetic: true }, ], currentVariant ).catch((e) => { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to send message', { description: message }); }); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to load PR comments', { description: message }); } }, [currentSessionId, directory, github, pr, setActiveMainTab]); const refresh = React.useCallback(async () => { if (!canShow) return; if (githubAuthChecked && githubAuthStatus?.connected === false) { setStatus({ connected: false }); setError(null); setIsLoading(false); return; } if (!github?.prStatus) { setStatus(null); setError('GitHub runtime API unavailable'); return; } setIsLoading(true); setError(null); try { const next = await github.prStatus(directory, branch); setStatus(next); if (next.connected === false) { setError(null); } } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message || 'Failed to load PR status'); } finally { setIsLoading(false); } }, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus]); React.useEffect(() => { const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null; setTitle(snapshot?.title ?? branchToTitle(branch)); setBody(snapshot?.body ?? ''); setDraft(snapshot?.draft ?? false); setIsOpen(snapshot?.isOpen ?? true); void refresh(); }, [branch, refresh, snapshotKey]); React.useEffect(() => { if (githubAuthChecked && githubAuthStatus?.connected === false) { setStatus({ connected: false }); setError(null); } }, [githubAuthChecked, githubAuthStatus]); React.useEffect(() => { if (!directory || !branch) { return; } pullRequestDraftSnapshots.set(snapshotKey, { title, body, draft, isOpen, additionalContext, }); }, [snapshotKey, title, body, draft, isOpen, additionalContext, directory, branch]); const generateDescription = React.useCallback(async () => { if (isGenerating) return; if (!directory) return; setIsGenerating(true); try { const generated = await generatePullRequestDescription(directory, { base: baseBranch, head: branch, context: additionalContext, }); if (generated.title?.trim()) { setTitle(generated.title.trim()); } if (generated.body?.trim()) { setBody(generated.body.trim()); } toast.success('PR description generated'); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to generate description', { description: message }); } finally { setIsGenerating(false); } }, [baseBranch, branch, directory, isGenerating, additionalContext]); const createPr = React.useCallback(async () => { if (!github?.prCreate) { toast.error('GitHub runtime API unavailable'); return; } const trimmedTitle = title.trim(); if (!trimmedTitle) { toast.error('Title is required'); return; } setIsCreating(true); try { const pr = await github.prCreate({ directory, title: trimmedTitle, head: branch, base: baseBranch, ...(body.trim() ? { body } : {}), draft, }); toast.success('PR created'); setStatus((prev) => (prev ? { ...prev, pr } : prev)); await refresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to create PR', { description: message }); } finally { setIsCreating(false); } }, [baseBranch, body, branch, directory, draft, github, refresh, title]); const mergePr = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prMerge) { toast.error('GitHub runtime API unavailable'); return; } setIsMerging(true); try { const result = await github.prMerge({ directory, number: pr.number, method: mergeMethod }); if (result.merged) { toast.success('PR merged'); } else { toast.message('PR not merged', { description: result.message || 'Not mergeable' }); } await refresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Merge failed', { description: message }); if (pr.url) { void openExternal(pr.url); } } finally { setIsMerging(false); } }, [directory, github, mergeMethod, refresh]); const markReady = React.useCallback(async (pr: GitHubPullRequest) => { if (!github?.prReady) { toast.error('GitHub runtime API unavailable'); return; } setIsMarkingReady(true); try { await github.prReady({ directory, number: pr.number }); toast.success('Marked ready for review'); await refresh(); } catch (e) { const message = e instanceof Error ? e.message : String(e); toast.error('Failed to mark ready', { description: message }); if (pr.url) { void openExternal(pr.url); } } finally { setIsMarkingReady(false); } }, [directory, github, refresh]); if (!canShow) { return null; } const repoUrl = status?.repo?.url || null; const checks = status?.checks ?? null; const canMerge = Boolean(status?.canMerge); const isConnected = Boolean(status?.connected); const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false; return (

Pull Request

{pr ? ( #{pr.number} ) : null}
{isLoading ? : null} {checks ? ( {checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`} ) : null}
{shouldShowConnectionNotice ? (
GitHub not connected. Connect your GitHub account in settings.
) : null} {error ? (
PR status unavailable
{error}
{repoUrl ? ( ) : null}
) : null} {pr ? (
{pr.title}
{pr.state}{pr.draft ? ' (draft)' : ''} {pr.mergeable === false ? ' · not mergeable' : ''} {typeof pr.mergeableState === 'string' && pr.mergeableState ? ` · ${pr.mergeableState}` : ''}
{checks ? ( ) : null} {checks?.failure ? ( ) : null}
{canMerge && pr.draft ? (
Draft PRs must be marked ready before merge.
) : null} {!canMerge ? (
No merge permission; use Open in GitHub.
) : null}
{canMerge && pr.draft && pr.state === 'open' ? ( ) : null} {canMerge ? ( <> ) : null}
) : (
Create PR
{branch} → {baseBranch}
{repoUrl ? ( ) : null}