Files
superwork/vscode/src/panel/handlers/prFiles.ts
T

391 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「改动」tab 的扩展侧 handler:实时从 Gitea 读整个 PR 的改动文件清单,并按需取
* 单个文件改动前后的原文,交给 webview 内联渲染(主从式 diff)。
*
* 数据全程实时拉取,只把「已查看」确认态落到 `.spx/pr-review-confirmed.json`
* (按文件变更指纹失效:作者推新提交后,仅真改动过的文件掉勾,没动的保留)。diff
* 左右两侧用 merge_base → head 的原文,与 Gitea 网页 diff 口径一致;getRawFile 对
* 404 兜底空串,故新增/删除/改名天然成立。
*/
import type { PrDiffMode } from '../../cc/prDiffMode'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { accessSync, constants } from 'node:fs'
import { createPatch } from 'diff'
import { workspace } from 'vscode'
import { getToken } from '../../auth/secrets'
import { resolveCommitProfileForRun } from '../../cc/commitProfile'
import { parsePrDiffMode } from '../../cc/prDiffMode'
import { resolveProfilePath } 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 { getEffectivePrDiffSummaryProfilePath } from '../../settings/store'
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<number | undefined> {
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<string, string> = {
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<void> {
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<string, string> = {}
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<string, string> = {}
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<void> {
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<string, string> },
): Promise<void> {
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\`\`\``
}
/**
* 给单个文件生成「一句话说明改了什么」的改动说明。增量复用同一会话:首个文件开新会话并把
* 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<void> {
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
}
const raw = await getEffectivePrDiffSummaryProfilePath(panel.context)
const resolution = resolveCommitProfileForRun(
raw,
resolveProfilePath,
(p) => {
try {
accessSync(p, constants.R_OK)
return true
}
catch {
return false
}
},
)
if (!resolution.ok) {
const message = resolution.reason === 'empty'
? '未配置改动说明 profile,请在设置中选择'
: `改动说明 profile 不可用:${resolution.profilePath}`
panel.postMessage({
type: 'toast/show',
id: makeNonce(),
level: 'error',
message,
dismissOnTimer: 8000,
})
panel.postMessage({ type: 'pr-file-summary/show', issueNumber, path: filePath, error: message })
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: resolution.profilePath,
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: resolution.profilePath,
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<unknown>(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<void> {
const next: PrDiffMode = parsePrDiffMode(mode)
await panel.context.workspaceState.update(PR_DIFF_MODE_KEY, next)
}