import { Alert, ErrorPanel, Modal, modalInputClass, modalLabelClass, modalTextareaClass, PanelNote, } from '@app/components' import { listSpaceMembers } from '@app/domains/issues/model' import type { MergeMethod, Pull, PullGate, PullRef, ReviewerState } from '@app/domains/pulls/model' import { addAssignee, attachLabel, closePull, deletePull, detachLabel, getPull, getPullGate, listAssignees, listPullLabelOptions, listPullLabels, listRequestedReviewers, lockPull, mergePull, pullPatchUrl, refreshPullSummary, removeAssignee, removeRequestedReviewer, reopenPull, requestReviewers, unlockPull, updateBranchPull, updatePull, } from '@app/domains/pulls/model' import { fetchConflictDetail } from '@app/domains/repo/model' import type { MessageKey } from '@app/i18n' import { t as tr, useT } from '@app/i18n' import { fmtRelative, fmtTime, repoKey, useAsync } from '@app/lib' import { Button } from '@app/ui' import { IconDownload as Download, IconFileCode2 as FileCode2, IconGitMerge as GitMerge, IconListCheck as ListChecks, IconLoader2 as Loader2, IconMessages as MessagesSquare, IconPencil as Pencil, IconRotate as RotateCcw, IconShieldCheck as ShieldCheck, IconLockOpen as Unlock, IconX as X, } from '@tabler/icons-react' import { useState } from 'react' import { MERGE_METHOD_LABEL, MERGE_METHODS, REVIEWER_STATE_LABEL, TONE, userName } from '../model/meta' import { ChecksTab } from './checks-tab' import { CommitsTab } from './commits-tab' import { Conversation } from './conversation' import { FilesTab } from './files-tab' import { Avatar, MergeableChip, PullStateBadge, SearchSelect, SideSection } from './parts' export type PullSubTab = 'conversation' | 'files' | 'commits' | 'checks' export interface PullDetailProps { space: string repo: string pullRef: string /** Repo write access (space owner / repo collaborator server-side). */ canWrite: boolean 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 }, ] const SUB_TAB_KEYS: Record = { conversation: 'pull.detail.tab.conversation', files: 'pull.tabs.files', commits: 'pull.tabs.commits', checks: 'pull.detail.tab.checks', } /** PR 详情页:头部/操作 + 子页签 + 侧栏。 */ export function PullDetail({ space, repo, pullRef, canWrite, ptab, onPtabChange, onBackToList, onOpenCommit, }: PullDetailProps) { const { t } = useT() const [tick, setTick] = useState(0) const [focusFile, setFocusFile] = useState('') /** 从文件变更跳转过来时要高亮的行号(0 表示不高亮)。 */ const [focusLine] = 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), [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' ? ( ) : null } onOpenFile={(path) => { setFocusFile(path) onPtabChange('files') }} /> ) : null} {ptab === 'files' ? ( ) : null} {ptab === 'commits' ? : null} {ptab === 'checks' ? : null}
{canWrite ? (
{canWrite && pull.state === 'open' ? ( ) : canWrite && pull.state === 'closed' ? ( ) : null} {canWrite ? : null} {canWrite ? ( ) : null} {canWrite ? ( ) : null} {canWrite ? ( ) : null}
) : 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, analyzing, onChanged, onError, }: { refObj: PullRef pull: Pull gate: PullGate | null | undefined /** 合并冲突分析尚未完成:先显示“分析中”,不提前给出可合并结论。 */ analyzing: boolean 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 (

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

{analyzing ? (

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

) : 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)} />