From 792ed7faa2b89bb0f4c84dcf032e30a34286b77b Mon Sep 17 00:00:00 2001 From: jeffusion Date: Sat, 7 Mar 2026 00:26:15 +0800 Subject: [PATCH] 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 --- src/controllers/review.ts | 56 ++++++++++++++ src/index.ts | 4 + src/review/cleanup-scheduler.ts | 76 +++++++++++++++++++ src/review/context/local-repo-manager.ts | 97 ++++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 src/review/cleanup-scheduler.ts diff --git a/src/controllers/review.ts b/src/controllers/review.ts index ae1cf28..777617f 100644 --- a/src/controllers/review.ts +++ b/src/controllers/review.ts @@ -8,6 +8,8 @@ import { aiReviewService } from '../services/ai-review'; import { feishuService } from '../services/feishu'; import { PullRequestDetails, PullRequestFile, giteaService } from '../services/gitea'; import { logger } from '../utils/logger'; +import { LocalRepoManager } from '../review/context/local-repo-manager'; +import { SandboxExec } from '../review/context/sandbox-exec'; // Gitea webhook事件类型 @@ -95,6 +97,11 @@ async function handlePullRequestEvent(c: Context, body: any): Promise body.action !== 'edited' && body.action !== 'review_requested' ) { + // PR 关闭/合并事件 → 清理审查快照 refs + if (body.action === 'closed') { + return handlePullRequestClosed(c, body); + } + return c.json({ status: 'ignored', message: '无需处理的事件类型' }, 200); } @@ -201,6 +208,55 @@ async function handlePullRequestEvent(c: Context, body: any): Promise return c.json({ status: 'accepted', message: '代码审查请求已接受' }, 202); } +/** + * 处理 PR 关闭/合并事件:清理审查快照 refs + */ +async function handlePullRequestClosed(c: Context, body: any): Promise { + 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); +} + /** * 处理提交状态更新事件 */ diff --git a/src/index.ts b/src/index.ts index 8c66d56..b2885fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { initDatabase } from './db/database'; import { codexEngine } from './review/codex/codex-engine'; import { mcpRouter } from './review/codex/mcp-handler'; import { reviewEngine } from './review/engine'; +import { cleanupScheduler } from './review/cleanup-scheduler'; initMasterKey(); initDatabase(); @@ -84,6 +85,9 @@ codexEngine.start().catch((error) => { console.error('❌ 启动Codex Review Engine失败', error); }); +// 启动清理调度器(定期清理过期 mirror/workspace 目录) +cleanupScheduler.start(); + // 初始化反馈系统(总是初始化,记忆系统可选) const reviewStore = reviewEngine.getStore(); initializeFeedbackSystem(reviewStore); diff --git a/src/review/cleanup-scheduler.ts b/src/review/cleanup-scheduler.ts new file mode 100644 index 0000000..7204bbb --- /dev/null +++ b/src/review/cleanup-scheduler.ts @@ -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 | 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 { + 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(); diff --git a/src/review/context/local-repo-manager.ts b/src/review/context/local-repo-manager.ts index 912e405..be7a827 100644 --- a/src/review/context/local-repo-manager.ts +++ b/src/review/context/local-repo-manager.ts @@ -312,4 +312,101 @@ export class LocalRepoManager { } } + /** + * 删除指定 PR 的审查快照 refs(PR 关闭时调用) + */ + async deleteReviewedRefs(mirrorPath: string, prNumber: number): Promise { + 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 { + 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; + } }