mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
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:
@@ -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<Response>
|
||||
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<Response>
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理提交状态更新事件
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
76
src/review/cleanup-scheduler.ts
Normal file
76
src/review/cleanup-scheduler.ts
Normal 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();
|
||||
@@ -312,4 +312,101 @@ export class LocalRepoManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定 PR 的审查快照 refs(PR 关闭时调用)
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user