374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
/**
|
||
* 「改动」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<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\`\`\``
|
||
}
|
||
|
||
/**
|
||
* 用 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<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
|
||
}
|
||
|
||
// 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<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)
|
||
}
|
||
|