/** * 「改动」tab 的扩展侧 handler:实时从 Gitea 读整个 PR 的改动文件清单,并按需取 * 单个文件改动前后的原文,交给 webview 内联渲染(主从式 diff)。 * * 数据全程实时拉取,只把「已查看」确认态落到 `.spx/pr-review-confirmed.json` * (按文件变更指纹失效:作者推新提交后,仅真改动过的文件掉勾,没动的保留)。diff * 左右两侧用 merge_base → head 的原文,与 Gitea 网页 diff 口径一致;getRawFile 对 * 404 兜底空串,故新增/删除/改名天然成立。 */ import type { KanbanWebviewPanel } from '../KanbanPanel' import { createPatch } from 'diff' import { workspace } from 'vscode' import { getToken } from '../../auth/secrets' import { listClaudeProfiles } from '../../cc/profiles' import { spawnClaude } from '../../cc/spawnClaude' import { detectRepo } from '../../git/remote' import { getPullRequest, getRawFile, listPullRequestFiles } from '../../gitea/api' import { readStateJsonComment } from '../../gitea/stateJson' import { readFileSummaries, readSummarySession, writeFileSummary, writeSummarySession } from '../../sessions/prFileSummaryStore' import { readConfirmedSigs, writeConfirmedSigs } from '../../sessions/prReviewStore' import { parsePrDiffMode, type PrDiffMode } from '../../cc/prDiffMode' import { makeNonce } from '../KanbanPanel' /** * 解析当前工作区的 Gitea 仓库与 token。三者任一缺失返回 undefined, * 调用方据此走空态/错误分支。 */ async function resolveRepoContext(panel: KanbanWebviewPanel): Promise< { host: string, owner: string, repo: string, token: string } | undefined > { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) return undefined const remote = await detectRepo(workspaceRoot) if (!remote) return undefined const token = await getToken(panel.context, remote.host) if (!token) return undefined return { host: remote.host, owner: remote.owner, repo: remote.repo, token } } /** 从工单的 state JSON 取 PR 号。无则返回 undefined(前端按空态处理,不算错误)。 */ async function resolvePrNumber( ctx: { host: string, owner: string, repo: string, token: string }, issueNumber: number, ): Promise { const stateObj = await readStateJsonComment({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, issueNumber, }) const pr = typeof stateObj?.pr === 'string' && stateObj.pr.length > 0 ? stateObj.pr : undefined return pr ? Number(pr) : undefined } /** 由文件扩展名推断高亮语言 id(highlight.js/lowlight 口径);未知则回退扩展名本身。 */ function langFromPath(path: string): string { const ext = path.split('.').pop()?.toLowerCase() ?? '' const map: Record = { ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', json: 'json', py: 'python', go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', kts: 'kotlin', rb: 'ruby', php: 'php', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp', swift: 'swift', md: 'markdown', markdown: 'markdown', yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'ini', sh: 'bash', bash: 'bash', zsh: 'bash', sql: 'sql', css: 'css', scss: 'scss', less: 'less', html: 'xml', htm: 'xml', xml: 'xml', vue: 'xml', svelte: 'xml', } return map[ext] ?? ext } /** 文件「变更指纹」:内容一改,status/additions/deletions 几乎必变其一;用于判定已查看是否仍有效。 */ function fileSig(f: { status: string, additions: number, deletions: number }): string { return `${f.status}|${f.additions}|${f.deletions}` } /** * 拉取工单对应 PR 的全部改动文件清单 + 已查看集合 + 已生成说明,推给 webview。 * 无 PR/缺上下文回空(前端按空态处理);真出错才带 error。 * * 已查看按「变更指纹」判定:取每个文件当前的 status|additions|deletions,与落盘记录的 * 指纹比对,仅相符的文件仍算已查看——作者推新提交后只有真改动过的文件掉勾。比对后顺手把 * 已失配/已消失的条目从盘上剪掉。 */ export async function handleGetPrFiles(panel: KanbanWebviewPanel, issueNumber: number): Promise { try { const ctx = await resolveRepoContext(panel) if (!ctx) { panel.postMessage({ type: 'pr-files/show', issueNumber, files: [], confirmed: [], summaries: {} }) return } const index = await resolvePrNumber(ctx, issueNumber) if (index === undefined) { panel.postMessage({ type: 'pr-files/show', issueNumber, files: [], confirmed: [], summaries: {} }) return } const files = await listPullRequestFiles({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index, }) const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath let confirmed: string[] = [] let summaries: Record = {} if (workspaceRoot) { const [stored, sums] = await Promise.all([ readConfirmedSigs(workspaceRoot, issueNumber), readFileSummaries(workspaceRoot, issueNumber), ]) summaries = sums // 当前每文件的变更指纹;仅指纹相符的已查看条目仍有效。 const cur = new Map(files.map(f => [f.path, fileSig(f)])) confirmed = Object.keys(stored).filter(p => cur.get(p) === stored[p]) // 有失配/消失才写盘,剪掉陈旧条目,避免无谓磁盘写入。 if (confirmed.length !== Object.keys(stored).length) { const pruned: Record = {} for (const p of confirmed) pruned[p] = stored[p] await writeConfirmedSigs(workspaceRoot, issueNumber, pruned) } } panel.postMessage({ type: 'pr-files/show', issueNumber, files, confirmed, summaries }) } catch (err) { panel.postMessage({ type: 'pr-files/show', issueNumber, files: [], confirmed: [], summaries: {}, error: err instanceof Error ? err.message : String(err), }) } } /** * 取单个文件改动前后的原文:左 = previousPath@mergeBase(无改名则 path), * 右 = path@head。两侧原文 + 语言推断回给 webview,由 git-diff-view 内联渲染。 */ export async function handleGetPrFileDiff( panel: KanbanWebviewPanel, args: { issueNumber: number, path: string, previousPath?: string }, ): Promise { try { const ctx = await resolveRepoContext(panel) if (!ctx) { panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' }) return } const index = await resolvePrNumber(ctx, args.issueNumber) if (index === undefined) { panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '' }) return } const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index }) const mergeBase = pr.mergeBase || pr.baseSha const oldPath = args.previousPath ?? args.path const [oldContent, newContent] = await Promise.all([ getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: oldPath, ref: mergeBase }), getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: args.path, ref: pr.headSha }), ]) panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent, newContent, oldLang: langFromPath(oldPath), newLang: langFromPath(args.path), }) } catch (err) { panel.postMessage({ type: 'pr-file-diff/show', issueNumber: args.issueNumber, path: args.path, oldContent: '', newContent: '', error: err instanceof Error ? err.message : String(err), }) } } /** * 持久化某 PR 的「已查看文件 → 变更指纹」映射到 `.spx/pr-review-confirmed.json`。 * 拿不到工作区根则静默跳过;写盘失败兜底打日志、不抛——确认态丢失不该中断 UI。 */ export async function handleSetPrReviewConfirmed( _panel: KanbanWebviewPanel, args: { issueNumber: number, confirmed: Record }, ): Promise { try { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath if (!workspaceRoot) return await writeConfirmedSigs(workspaceRoot, args.issueNumber, args.confirmed) } catch (err) { console.warn('handleSetPrReviewConfirmed 写盘失败', err) } } /** * 生成说明的 prompt:只要一句「这个文件改了什么」,给审查者扫一眼用。 * 首条垫入 PR 描述当背景并立下问法;续会话只发新文件,复用已有 PR 理解、省 token。 */ function buildSummaryPrompt(prNumber: number, prBody: string, filePath: string, patch: string, isFirst: boolean): string { if (isFirst) { const body = prBody.trim() const prContext = body ? `我在逐个审查 PR #${prNumber} 的改动,本 PR 的说明如下:\n${body}\n\n` : `我在逐个审查 PR #${prNumber} 的改动。` return `${prContext}针对下面这个文件的改动,用一句简短中文说明改了什么 / 改了哪些东西。只输出这一句话,不要分点、不要 ①②、不要 markdown 加粗,不要解释为什么改、不要写不做的后果,不要复述整段代码或罗列函数/结构。\n\n文件: ${filePath}\n\n\`\`\`diff\n${patch}\n\`\`\`` } return `下一个文件,同样只用一句简短中文说明改了什么,不要分点、不要 ①②、不要解释为什么或后果。\n文件: ${filePath}\n\`\`\`diff\n${patch}\n\`\`\`` } /** * 用 deepseek 给单个文件生成「一句话说明改了什么」的改动说明。增量复用同一会话:首个文件开新会话并把 * sessionId 记到 `.spx/pr-file-summaries.json`,后续文件 `--resume` 接上去,省去每次重新理解 * PR 的开销。说明也落盘,重载后仍显示。 * * diff 取 merge_base → head 两版原文用 jsdiff 拼 unified diff 喂给模型(比塞两份全文省 token)。 * spawnClaude 走 spawn 数组参数、不经 shell,故 patch 里的引号/换行无需转义。 */ export async function handleGeneratePrFileSummary( panel: KanbanWebviewPanel, args: { issueNumber: number, path: string, previousPath?: string }, ): Promise { const { issueNumber, path: filePath } = args try { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath const ctx = await resolveRepoContext(panel) if (!ctx || !workspaceRoot) { panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '当前工作区没有 Gitea 仓库或未配置 token' }) return } // deepseek profile 与「一键提交」同一来源;缺失则提示用户创建并清掉前端 loading。 const profiles = await listClaudeProfiles() const deepseek = profiles.find(p => p.name === 'deepseek-v4-pro') if (!deepseek) { panel.postMessage({ type: 'toast/show', id: makeNonce(), level: 'error', message: '未找到 deepseek profile(deepseek-v4-pro / deepseek),请在 Claude profiles 目录下创建对应 .json', dismissOnTimer: 8000, }) panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '未找到 deepseek profile' }) return } const index = await resolvePrNumber(ctx, issueNumber) if (index === undefined) { panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: '该工单还没有 PR' }) return } const pr = await getPullRequest({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, index }) const mergeBase = pr.mergeBase || pr.baseSha const oldPath = args.previousPath ?? filePath const [oldContent, newContent] = await Promise.all([ getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: oldPath, ref: mergeBase }), getRawFile({ host: ctx.host, owner: ctx.owner, repo: ctx.repo, token: ctx.token, filepath: filePath, ref: pr.headSha }), ]) const patch = createPatch(filePath, oldContent, newContent, '', '') const prevSession = await readSummarySession(workspaceRoot, issueNumber) let result try { result = await spawnClaude({ prompt: buildSummaryPrompt(index, pr.body, filePath, patch, !prevSession), cwd: workspaceRoot, profilePath: deepseek.path, resumeSessionId: prevSession, timeoutMs: 120_000, }) } catch (err) { // resume 失败(会话过期/被清理)→ 丢弃旧 id,按首次重开一个会话再试一次。 if (!prevSession) throw err await writeSummarySession(workspaceRoot, issueNumber, undefined) result = await spawnClaude({ prompt: buildSummaryPrompt(index, pr.body, filePath, patch, true), cwd: workspaceRoot, profilePath: deepseek.path, timeoutMs: 120_000, }) } // 模型偶发仍加 **①**/**②** → 轻量去标记;一句话约束主要靠 prompt const summary = result.resultText .trim() .replace(/\s+/g, ' ') .replace(/(?:\*{0,2}[①②]\*{0,2}\s*)+/g, '') .trim() await writeFileSummary(workspaceRoot, issueNumber, filePath, summary) await writeSummarySession(workspaceRoot, issueNumber, result.sessionId) panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, summary }) } catch (err) { panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: err instanceof Error ? err.message : String(err), }) } } const PR_DIFF_MODE_KEY = 'prDiffMode' /** 读 workspace 级 diff 模式(缺省 unified),回推 webview。 */ export function handleGetPrDiffMode(panel: KanbanWebviewPanel): void { const stored = panel.context.workspaceState.get(PR_DIFF_MODE_KEY) const mode = parsePrDiffMode(stored) panel.postMessage({ type: 'pr-diff-mode/show', mode }) } /** 写 workspace 级 diff 模式;非法值按 unified 落盘。 */ export async function handleSetPrDiffMode( panel: KanbanWebviewPanel, mode: unknown, ): Promise { const next: PrDiffMode = parsePrDiffMode(mode) await panel.context.workspaceState.update(PR_DIFF_MODE_KEY, next) }