feat(review): add workspace cleanup on PR close and scheduled stale cleanup

- Delete snapshot refs (refs/reviewed/pr/{n}/*) when PR is closed or merged
- Add daily 2:00 AM scheduled cleanup for mirrors/workspaces older than 3 days
- Expose deleteReviewedRefs, getMirrorPath, cleanStaleMirrors on LocalRepoManager
This commit is contained in:
jeffusion
2026-03-07 00:26:15 +08:00
committed by 路遥知码力
parent 272c832c43
commit 792ed7faa2
4 changed files with 233 additions and 0 deletions

View File

@@ -8,6 +8,8 @@ import { aiReviewService } from '../services/ai-review';
import { feishuService } from '../services/feishu'; import { feishuService } from '../services/feishu';
import { PullRequestDetails, PullRequestFile, giteaService } from '../services/gitea'; import { PullRequestDetails, PullRequestFile, giteaService } from '../services/gitea';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { LocalRepoManager } from '../review/context/local-repo-manager';
import { SandboxExec } from '../review/context/sandbox-exec';
// Gitea webhook事件类型 // Gitea webhook事件类型
@@ -95,6 +97,11 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
body.action !== 'edited' && body.action !== 'edited' &&
body.action !== 'review_requested' body.action !== 'review_requested'
) { ) {
// PR 关闭/合并事件 → 清理审查快照 refs
if (body.action === 'closed') {
return handlePullRequestClosed(c, body);
}
return c.json({ status: 'ignored', message: '无需处理的事件类型' }, 200); return c.json({ status: 'ignored', message: '无需处理的事件类型' }, 200);
} }
@@ -201,6 +208,55 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
return c.json({ status: 'accepted', message: '代码审查请求已接受' }, 202); return c.json({ status: 'accepted', message: '代码审查请求已接受' }, 202);
} }
/**
* 处理 PR 关闭/合并事件:清理审查快照 refs
*/
async function handlePullRequestClosed(c: Context, body: any): Promise<Response> {
const { pull_request: pullRequest, repository: repo } = body;
if (!pullRequest || !repo) {
return c.json({ status: 'ignored', message: 'PR close 无效数据' }, 200);
}
const prNumber = pullRequest.number;
const owner = repo.owner.login;
const repoName = repo.name;
logger.info('PR 已关闭,开始清理审查快照', { owner, repo: repoName, prNumber, merged: !!pullRequest.merged });
// 异步清理,不阻塞 webhook 响应
(async () => {
try {
const sandboxExec = new SandboxExec(config.review.allowedCommands);
const localRepoManager = new LocalRepoManager(
config.review.workdir,
sandboxExec,
config.review.commandTimeoutMs,
config.gitea.accessToken
);
const mirrorPath = localRepoManager.getMirrorPath(owner, repoName);
// 检查 mirror 是否存在(可能从未审查过该仓库)
const { access } = await import('node:fs/promises');
try {
await access(mirrorPath);
} catch {
return; // mirror 不存在,无需清理
}
await localRepoManager.deleteReviewedRefs(mirrorPath, prNumber);
} catch (error) {
logger.warn('PR 关闭清理失败(非致命)', {
owner,
repo: repoName,
prNumber,
error: error instanceof Error ? error.message : String(error),
});
}
})();
return c.json({ status: 'accepted', message: 'PR 关闭清理已触发' }, 200);
}
/** /**
* 处理提交状态更新事件 * 处理提交状态更新事件
*/ */

View File

@@ -12,6 +12,7 @@ import { initDatabase } from './db/database';
import { codexEngine } from './review/codex/codex-engine'; import { codexEngine } from './review/codex/codex-engine';
import { mcpRouter } from './review/codex/mcp-handler'; import { mcpRouter } from './review/codex/mcp-handler';
import { reviewEngine } from './review/engine'; import { reviewEngine } from './review/engine';
import { cleanupScheduler } from './review/cleanup-scheduler';
initMasterKey(); initMasterKey();
initDatabase(); initDatabase();
@@ -84,6 +85,9 @@ codexEngine.start().catch((error) => {
console.error('❌ 启动Codex Review Engine失败', error); console.error('❌ 启动Codex Review Engine失败', error);
}); });
// 启动清理调度器(定期清理过期 mirror/workspace 目录)
cleanupScheduler.start();
// 初始化反馈系统(总是初始化,记忆系统可选) // 初始化反馈系统(总是初始化,记忆系统可选)
const reviewStore = reviewEngine.getStore(); const reviewStore = reviewEngine.getStore();
initializeFeedbackSystem(reviewStore); initializeFeedbackSystem(reviewStore);

View File

@@ -0,0 +1,76 @@
import config from '../config';
import { logger } from '../utils/logger';
import { LocalRepoManager } from './context/local-repo-manager';
import { SandboxExec } from './context/sandbox-exec';
/** 过期 mirror 最大保留天数 */
const STALE_MIRROR_MAX_AGE_DAYS = 3;
/**
* 清理调度器:每天凌晨 2:00 清理过期的 mirror 和残留 workspace 目录
*/
class CleanupScheduler {
private timer: ReturnType<typeof setTimeout> | null = null;
private started = false;
start(): void {
if (this.started) return;
this.started = true;
// 启动后延迟 60s 执行一次(避免启动高峰冲突)
this.timer = setTimeout(() => {
this.runCleanup();
// 之后按每天凌晨 2:00 调度
this.scheduleNextRun();
}, 60_000);
logger.info('清理调度器已启动', { maxAgeDays: STALE_MIRROR_MAX_AGE_DAYS });
}
stop(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
this.started = false;
}
private scheduleNextRun(): void {
const now = new Date();
const next = new Date(now);
next.setHours(2, 0, 0, 0); // 凌晨 2:00
if (next.getTime() <= now.getTime()) {
next.setDate(next.getDate() + 1); // 已过今天 2:00推到明天
}
const delayMs = next.getTime() - now.getTime();
this.timer = setTimeout(() => {
this.runCleanup();
this.scheduleNextRun();
}, delayMs);
logger.debug('下次清理时间', { nextRun: next.toISOString(), delayMs });
}
private async runCleanup(): Promise<void> {
logger.info('开始执行定时清理任务');
try {
const sandboxExec = new SandboxExec(config.review.allowedCommands);
const localRepoManager = new LocalRepoManager(
config.review.workdir,
sandboxExec,
config.review.commandTimeoutMs,
config.gitea.accessToken
);
const cleaned = await localRepoManager.cleanStaleMirrors(STALE_MIRROR_MAX_AGE_DAYS);
logger.info('定时清理任务完成', { cleanedDirectories: cleaned });
} catch (error) {
logger.error('定时清理任务失败', {
error: error instanceof Error ? error.message : String(error),
});
}
}
}
export const cleanupScheduler = new CleanupScheduler();

View File

@@ -312,4 +312,101 @@ export class LocalRepoManager {
} }
} }
/**
* 删除指定 PR 的审查快照 refsPR 关闭时调用)
*/
async deleteReviewedRefs(mirrorPath: string, prNumber: number): Promise<void> {
const unlock = await this.acquireMirrorLock(mirrorPath);
try {
for (const suffix of ['head', 'base']) {
const refName = `refs/reviewed/pr/${prNumber}/${suffix}`;
try {
await this.sandboxExec.run(
'git',
['--git-dir', mirrorPath, 'update-ref', '-d', refName],
{
cwd: this.workDir,
timeoutMs: this.commandTimeoutMs,
}
);
} catch {
// ref 不存在时忽略
}
}
logger.info('已清理 PR 审查快照 refs', { mirrorPath, prNumber });
} finally {
unlock();
}
}
/**
* 根据 owner/repo 定位 mirror 路径
*/
getMirrorPath(owner: string, repo: string): string {
return path.join(this.workDir, 'repos', `${hashRepo(owner, repo)}.git`);
}
/**
* 清理超过指定天数未访问的 mirror 目录
* 通过检查 .git 目录的 atime/mtime 判断最后活动时间
*/
async cleanStaleMirrors(maxAgeDays: number): Promise<number> {
const { readdir, stat, rm } = await import('node:fs/promises');
const mirrorsRoot = path.join(this.workDir, 'repos');
let cleaned = 0;
let entries: string[];
try {
entries = await readdir(mirrorsRoot);
} catch {
return 0; // repos 目录不存在
}
const now = Date.now();
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
for (const entry of entries) {
if (!entry.endsWith('.git')) continue;
const mirrorPath = path.join(mirrorsRoot, entry);
try {
const info = await stat(mirrorPath);
// 使用 mtime最后修改时间fetch 会更新)判断活跃度
const lastActive = Math.max(info.mtimeMs, info.atimeMs);
if (now - lastActive > maxAgeMs) {
await rm(mirrorPath, { recursive: true, force: true });
cleaned++;
logger.info('已清理过期 mirror 目录', { mirrorPath, lastActiveDaysAgo: Math.floor((now - lastActive) / (24 * 60 * 60 * 1000)) });
}
} catch (error) {
logger.warn('检查/清理 mirror 目录失败', {
mirrorPath,
error: error instanceof Error ? error.message : String(error),
});
}
}
// 同时清理 workspaces 下的残留目录
const workspacesRoot = path.join(this.workDir, 'workspaces');
try {
const wsEntries = await readdir(workspacesRoot);
for (const entry of wsEntries) {
const wsPath = path.join(workspacesRoot, entry);
try {
const info = await stat(wsPath);
const lastActive = Math.max(info.mtimeMs, info.atimeMs);
if (now - lastActive > maxAgeMs) {
await rm(wsPath, { recursive: true, force: true });
cleaned++;
logger.info('已清理过期 workspace 目录', { wsPath });
}
} catch {
// 忽略单个目录清理失败
}
}
} catch {
// workspaces 目录不存在
}
return cleaned;
}
} }