mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
feat(review): add incremental review with snapshot refs
Save baseSha + headSha as git refs (refs/reviewed/pr/{n}/base and
refs/reviewed/pr/{n}/head) after each successful PR review. On
subsequent reviews, compare saved baseSha with current baseSha to
decide incremental (two-dot diff) vs full (three-dot diff). Falls
back to full review only when PR base changes (rebase scenario).
Protects custom refs from fetch --prune via negative refspec.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
@@ -38,7 +38,8 @@ export class DiffExtractor {
|
||||
async buildContext(
|
||||
run: ReviewRun,
|
||||
mirrorPath: string,
|
||||
workspacePath: string
|
||||
workspacePath: string,
|
||||
lastReviewedHead?: string
|
||||
): Promise<ReviewContext> {
|
||||
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<string> {
|
||||
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<ChangedFile[]> {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user