diff --git a/src/review/context/diff-extractor.ts b/src/review/context/diff-extractor.ts index 0a4683a..305c79a 100644 --- a/src/review/context/diff-extractor.ts +++ b/src/review/context/diff-extractor.ts @@ -38,7 +38,8 @@ export class DiffExtractor { async buildContext( run: ReviewRun, mirrorPath: string, - workspacePath: string + workspacePath: string, + lastReviewedHead?: string ): Promise { const targetSha = run.headSha || run.commitSha; if (!targetSha) { @@ -46,6 +47,11 @@ export class DiffExtractor { } let baseSha = run.baseSha; + // 增量审查:如果提供了 lastReviewedHead,用它替换 baseSha + const incremental = !!lastReviewedHead; + if (lastReviewedHead) { + baseSha = lastReviewedHead; + } if (!baseSha) { baseSha = (await this.localRepoManager.resolveCommitParent(workspacePath, targetSha)) || undefined; @@ -55,11 +61,11 @@ export class DiffExtractor { const isRootCommit = !baseSha; const diff = isRootCommit ? await this.getRootCommitDiff(workspacePath, targetSha) - : await this.getDiff(workspacePath, run.eventType, baseSha!, targetSha); + : await this.getDiff(workspacePath, run.eventType, baseSha!, targetSha, incremental); const changedFiles = isRootCommit ? await this.getRootCommitChangedFiles(workspacePath, targetSha) - : await this.getChangedFiles(workspacePath, baseSha!, targetSha); + : await this.getChangedFiles(workspacePath, baseSha!, targetSha, incremental); // 构建允许的文件路径集合,确保parsedDiff也受REVIEW_MAX_FILES_PER_RUN限制 const allowedPaths = new Set(changedFiles.map((f) => f.path)); @@ -103,12 +109,13 @@ export class DiffExtractor { workspacePath: string, eventType: ReviewRun['eventType'], baseSha: string, - targetSha: string + targetSha: string, + incremental = false ): Promise { if (eventType === 'pull_request') { const response = await this.sandboxExec.run( 'git', - ['diff', '--unified=3', `${baseSha}...${targetSha}`], + ['diff', '--unified=3', `${baseSha}${incremental ? '..' : '...'}${targetSha}`], { cwd: workspacePath, timeoutMs: this.commandTimeoutMs, @@ -197,11 +204,12 @@ export class DiffExtractor { private async getChangedFiles( workspacePath: string, baseSha: string, - targetSha: string + targetSha: string, + incremental = false ): Promise { const statusResult = await this.sandboxExec.run( 'git', - ['diff', '--name-status', `${baseSha}...${targetSha}`], + ['diff', '--name-status', `${baseSha}${incremental ? '..' : '...'}${targetSha}`], { cwd: workspacePath, timeoutMs: this.commandTimeoutMs, @@ -210,7 +218,7 @@ export class DiffExtractor { const numStatResult = await this.sandboxExec.run( 'git', - ['diff', '--numstat', `${baseSha}...${targetSha}`], + ['diff', '--numstat', `${baseSha}${incremental ? '..' : '...'}${targetSha}`], { cwd: workspacePath, timeoutMs: this.commandTimeoutMs, diff --git a/src/review/context/local-repo-manager.ts b/src/review/context/local-repo-manager.ts index 912725d..912e405 100644 --- a/src/review/context/local-repo-manager.ts +++ b/src/review/context/local-repo-manager.ts @@ -112,7 +112,7 @@ export class LocalRepoManager { // fetch使用认证参数 await this.sandboxExec.run( 'git', - [...authArgs, '--git-dir', mirrorPath, 'fetch', '--prune', 'origin', '+refs/*:refs/*'], + [...authArgs, '--git-dir', mirrorPath, 'fetch', '--prune', 'origin', '+refs/*:refs/*', '^refs/reviewed/*'], { cwd: this.workDir, timeoutMs: this.commandTimeoutMs, @@ -247,4 +247,69 @@ export class LocalRepoManager { return false; } } + + /** + * 保存审查快照 ref,记录 PR 最后一次成功审查的 baseSha 和 headSha + * 存储在 mirror 的 refs/reviewed/pr/{prNumber}/head 和 refs/reviewed/pr/{prNumber}/base + */ + async saveReviewedRef(mirrorPath: string, prNumber: number, baseSha: string, headSha: string): Promise { + const unlock = await this.acquireMirrorLock(mirrorPath); + try { + const headRef = `refs/reviewed/pr/${prNumber}/head`; + const baseRef = `refs/reviewed/pr/${prNumber}/base`; + await this.sandboxExec.run( + 'git', + ['--git-dir', mirrorPath, 'update-ref', headRef, headSha], + { + cwd: this.workDir, + timeoutMs: this.commandTimeoutMs, + } + ); + await this.sandboxExec.run( + 'git', + ['--git-dir', mirrorPath, 'update-ref', baseRef, baseSha], + { + cwd: this.workDir, + timeoutMs: this.commandTimeoutMs, + } + ); + logger.info('已保存审查快照 ref', { mirrorPath, prNumber, baseSha, headSha }); + } finally { + unlock(); + } + } + + /** + * 解析上次审查的快照(baseSha + headSha) + * 如果任一 ref 不存在,返回 null + */ + async resolveReviewedRef(mirrorPath: string, prNumber: number): Promise<{ baseSha: string; headSha: string } | null> { + try { + const headRef = `refs/reviewed/pr/${prNumber}/head`; + const baseRef = `refs/reviewed/pr/${prNumber}/base`; + const headResult = await this.sandboxExec.run( + 'git', + ['--git-dir', mirrorPath, 'rev-parse', '--verify', headRef], + { + cwd: this.workDir, + timeoutMs: this.commandTimeoutMs, + } + ); + const baseResult = await this.sandboxExec.run( + 'git', + ['--git-dir', mirrorPath, 'rev-parse', '--verify', baseRef], + { + cwd: this.workDir, + timeoutMs: this.commandTimeoutMs, + } + ); + const headSha = headResult.stdout.trim(); + const baseSha = baseResult.stdout.trim(); + if (!headSha || !baseSha) return null; + return { baseSha, headSha }; + } catch { + return null; + } + } + } diff --git a/src/review/orchestrator.ts b/src/review/orchestrator.ts index c72525d..7298122 100644 --- a/src/review/orchestrator.ts +++ b/src/review/orchestrator.ts @@ -171,6 +171,34 @@ export class ReviewOrchestrator { latencyMs: Date.now() - workspaceStepStart, }); + // ── 增量审查基线解析 ───────────────────────────────────────────── + let lastReviewedHead: string | undefined; + if (run.eventType === 'pull_request' && run.prNumber) { + const snapshot = await this.localRepoManager.resolveReviewedRef( + repoPaths.mirrorPath, + run.prNumber + ); + if (snapshot && targetSha) { + if (snapshot.baseSha === run.baseSha) { + // base 未变(追加 commit 或 force-push 修改 commit)→ 增量审查 + lastReviewedHead = snapshot.headSha; + logger.info('增量审查模式:使用上次审查快照', { + runId: run.id, + lastReviewedHead: snapshot.headSha, + currentHead: targetSha, + baseSha: run.baseSha, + }); + } else { + // base 变了(PR 分支做了 rebase)→ 全量审查 + logger.info('PR base 已变更(可能 rebase),回退全量审查', { + runId: run.id, + savedBaseSha: snapshot.baseSha, + currentBaseSha: run.baseSha, + }); + } + } + } + const contextStart = Date.now(); await this.store.addStep({ runId: run.id, @@ -182,7 +210,8 @@ export class ReviewOrchestrator { const context = await this.diffExtractor.buildContext( run, repoPaths.mirrorPath, - repoPaths.workspacePath + repoPaths.workspacePath, + lastReviewedHead ); await this.store.addStep({ @@ -477,6 +506,23 @@ export class ReviewOrchestrator { gated: policyResult.gated.length, dropped: policyResult.dropped.length, }); + + // ── 审查成功:保存审查快照 ref ────────────────────────────────── + if (run.eventType === 'pull_request' && run.prNumber && targetSha) { + try { + await this.localRepoManager.saveReviewedRef( + repoPaths!.mirrorPath, + run.prNumber, + run.baseSha!, + targetSha + ); + } catch (refError) { + logger.warn('保存审查快照 ref 失败(非致命)', { + runId: run.id, + error: refError instanceof Error ? refError.message : String(refError), + }); + } + } } catch (error) { await this.store.addStep({ runId: run.id,