import { useState } from 'react' import { Download, FileCode2, GitMerge, ListChecks, MessagesSquare, Pencil, RotateCcw, Send, ShieldCheck, Sparkles, Unlock, X, } from 'lucide-react' import { Alert, Button, ErrorPanel, Modal, PanelNote, modalInputClass, modalLabelClass, modalTextareaClass } from '@/shared/ui' import { fmtRelative, fmtTime, repoKey, useAsync } from '@/shared/lib' import { t as tr, useT } from '@/shared/i18n' import { fetchConflictDetail } from '@/entities/repo' import { addAssignee, attachLabel, closePull, deletePull, detachLabel, getPull, getPullGate, listAssignees, listPullLabels, listRequestedReviewers, lockPull, mergePull, pullPatchUrl, refreshPullSummary, removeAssignee, removeRequestedReviewer, reopenPull, requestReviewers, unlockPull, updateBranchPull, updatePull, } from '@/entities/pull-request' import type { MessageKey } from '@/shared/i18n' import type { MergeMethod, Pull, PullGate, PullRef, ReviewerState } from '@/entities/pull-request' import { MERGE_METHOD_LABEL, REVIEWER_STATE_LABEL, TONE, userName } from '../model/meta' import { Avatar, MergeableChip, PullStateBadge, SideSection } from './parts' import { Conversation } from './conversation' import { FilesTab } from './files-tab' import { CommitsTab } from './commits-tab' import { ChecksTab } from './checks-tab' import { AiTab } from './ai-tab' export type PullSubTab = 'conversation' | 'files' | 'commits' | 'checks' | 'ai' export interface PullDetailProps { space: string repo: string pullRef: string ptab: PullSubTab onPtabChange: (next: PullSubTab) => void onBackToList: () => void onOpenCommit: (sha: string) => void } const SUB_TABS: Array<{ id: PullSubTab; icon: typeof MessagesSquare }> = [ { id: 'conversation', icon: MessagesSquare }, { id: 'files', icon: FileCode2 }, { id: 'commits', icon: GitMerge }, { id: 'checks', icon: ListChecks }, { id: 'ai', icon: Sparkles }, ] const SUB_TAB_KEYS: Record = { conversation: 'pull.detail.tab.conversation', files: 'pull.tabs.files', commits: 'pull.tabs.commits', checks: 'pull.detail.tab.checks', ai: 'pull.tabs.ai', } /** PR 详情页:头部/操作 + 子页签 + 侧栏。 */ export function PullDetail({ space, repo, pullRef, ptab, onPtabChange, onBackToList, onOpenCommit }: PullDetailProps) { const { t } = useT() const [tick, setTick] = useState(0) const [focusFile, setFocusFile] = useState('') /** 从 AI 发现跳转过来时要高亮的行号(0 表示不高亮)。 */ const [focusLine, setFocusLine] = useState(0) const [notice, setNotice] = useState(null) const [error, setError] = useState(null) const refObj: PullRef = { space, repo, pull: pullRef } const pullState = useAsync(() => getPull(refObj), [space, repo, pullRef, tick], { cacheKey: repoKey(space, repo, `pull:${pullRef}:${tick}`), }) const gateState = useAsync(() => getPullGate(refObj).catch(() => null), [space, repo, pullRef, tick]) const pull = pullState.data const gate = gateState.data const failure = pullState.error as { code?: number; message?: string } | undefined const refresh = () => setTick(t => t + 1) async function run(label: string, fn: () => Promise) { setNotice(null) setError(null) try { await fn() setNotice(t('pull.detail.complete', { label })) refresh() } catch (f) { setError(t('pull.detail.actionFailed', { label, reason: f instanceof Error ? f.message : t('common.state.unknown') })) } } if (pullState.loading && !pull) return if (!pull) { return failure ? ( ) : ( ) } return (
/ #{pull.number}

{pull.title}

{userName(pull.author)}{' '} {t('pull.detail.requestsMerge', { head: pull.head.branch, base: pull.base.branch })} ·{t('pull.detail.updatedAt', { time: fmtRelative(pull.updated_at ?? pull.created_at) }) } {pull.locked ? {t('pull.detail.locked')} : null} {pull.auto_merge ? {t('pull.detail.autoMergeOn')} : null}

{notice ? {notice} : null} {error ? {error} : null}
{SUB_TABS.map(tab => { const Icon = tab.icon return ( ) })}
{ptab === 'conversation' ? ( { setFocusFile(path) onPtabChange('files') }} /> ) : null} {ptab === 'files' ? ( ) : null} {ptab === 'commits' ? : null} {ptab === 'checks' ? : null} {ptab === 'ai' ? ( { setFocusFile(path) setFocusLine(line) onPtabChange('files') }} /> ) : null}
{pull.state === 'open' ? : null}
{pull.state === 'open' ? ( ) : pull.state === 'closed' ? ( ) : null}
{t('pull.detail.aheadBehind')}
{pull.commits_ahead ?? '—'} / {pull.commits_behind ?? '—'}
{t('pull.detail.filesChanged')}
{pull.files_changed}
{t('pull.detail.lines')}
+{pull.additions} −{pull.deletions}
{t('pull.detail.created')}
{fmtRelative(pull.created_at)}
{pull.merged_at ?
{t('pull.detail.mergedAt')}
{fmtRelative(pull.merged_at)}
: null} {pull.merged_commit_sha ?
{t('pull.detail.mergeCommit')}
{pull.merged_commit_sha.slice(0, 10)}
: null}
) } /* ------------------------------------------------------------ merge box */ function MergeBox({ refObj, pull, gate, onChanged, onError }: { refObj: PullRef pull: Pull gate: PullGate | null | undefined onChanged: () => void onError: (message: string) => void }) { const { t } = useT() const [method, setMethod] = useState(pull.merge_method || 'merge') const [title, setTitle] = useState('') const [message, setMessage] = useState('') const [deleteAfter, setDeleteAfter] = useState(false) const [busy, setBusy] = useState(false) const g = gate?.gate const conflicts = gate?.conflicts ?? pull.conflicts ?? [] const blocked = !g?.allowed && g !== null && g !== undefined const stale = (pull.commits_behind ?? 0) > 0 async function merge() { setBusy(true) try { const outcome = await mergePull(refObj, { method, title: title.trim() || null, message: message.trim() || null, delete_branch_after_merge: deleteAfter, expect_head_sha: pull.head.sha, }) onChanged() window.alert(t('pull.detail.mergeSucceeded', { sha: outcome.result.new_sha.slice(0, 10) })) } catch (f) { onError(t('pull.detail.mergeFailed', { reason: f instanceof Error ? f.message : t('common.state.unknown') })) } finally { setBusy(false) } } return ( {pull.is_draft ? (

{t('pull.detail.draftMergeHint')}

) : conflicts.length ? (
{t('pull.detail.conflicts', { count: conflicts.length })}
    {conflicts.map(path => (
  • ))}
) : g && !g.allowed ? (

{t('pull.detail.blockedByRules', { reasons: g.reasons.join('; ') || t('pull.detail.needsApprovals', { required: g.required_approvals, current: g.approvals }) })} {g.bypassed ? t('pull.detail.canBypass') : ''}

) : stale ? (

{t('pull.detail.staleBranch', { count: pull.commits_behind ?? 0 })}

) : (

{t('pull.detail.readyToMerge')}{g ? t('pull.detail.approvals', { current: g.approvals, required: g.required_approvals }) : ''}

)} {g?.outdated && stale ? ( ) : null}
setTitle(event.target.value)} />