Files
archived-gitea-ai-assistant/src/review/codex/codex-runner.ts
jeffusion d5deb75231 feat(repo): add project-level review prompt with UI redesign
- Add database migration and repository for project review prompts
- Add API endpoint for setting project-level prompts
- Integrate project prompts into Agent and Codex review flows
- Redesign repository management UI with dialog-based prompt editor
- Replace flat buttons with Switch for webhook toggle and dedicated prompt button
- Add Dialog and DropdownMenu UI components from Radix UI
- Add comprehensive tests for wiring and interactions
2026-03-26 13:35:05 +08:00

409 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import config from '../../config';
import { logger } from '../../utils/logger';
import type { LocalRepoManager, LocalRepoPaths } from '../context/local-repo-manager';
import { resolveProjectReviewPrompt } from '../project-review-prompt';
import type { FileReviewStore } from '../store/file-review-store';
import type { ReviewRun } from '../types';
import { type ReviewRunContext, mcpToolExecutor } from './mcp-tools';
// ---------------------------------------------------------------------------
// 默认审查提示词
// ---------------------------------------------------------------------------
const SYSTEM_INSTRUCTIONS = `你是一个专业的代码审查助手。请审查当前仓库中两个分支之间的代码变更。
## 审查步骤
1. **获取审查上下文**:调用 \`get_pr_info\` 工具获取 PR 信息owner、repo、PR number、base SHA、head SHA
2. **获取代码差异**:在终端中执行 \`git diff <baseSha>...<headSha>\` 命令,查看两个分支之间的完整差异。如果差异内容过多,可以先执行 \`git diff --stat <baseSha>...<headSha>\` 了解变更概况,再针对重要文件逐个查看。
3. **分析代码问题**:仔细审查差异中的代码,根据审查原则进行分析。
4. **发布审查结果**
- 调用 \`add_review_summary\` 发布整体审查总结
- 对发现的具体问题,调用 \`add_line_comment\` 在对应代码行添加评论
## 执行规则
- 这是自动化流程,不是对话。不要问候、不要寒暄、不要解释你将要做什么。
- 直接调用工具,不要在调用前描述你的计划或在调用后总结结果。
- 如果没有值得标记的问题,调用 add_review_summary 说明即可,不要额外输出。
- 所有审查工作完成后,回复"DONE"。不要输出其他内容。`;
const DEFAULT_REVIEW_GUIDELINES = `- 关注逻辑错误、潜在 bug、安全漏洞、性能问题、错误处理缺失
- 仅关注本次变更引入的问题,不要评论已有代码
- 只标记作者如果知道了一定会修复的真正问题
- 不要标记代码风格、格式化等非实质性问题
- 评论要简洁明了,直接说明问题和影响
- 如果没有发现值得标记的问题,在总结中说明代码变更看起来没有问题即可
- 行评论中的文件路径使用相对于仓库根目录的路径`;
/**
* 单次 Codex 审查执行器
*
* 负责:
* 1. 准备工作空间(复用 LocalRepoManager
* 2. 生成 .codex/config.toml含 MCP server 配置)
* 3. 注册 MCP 审查上下文
* 4. 启动 codex exec 子进程(自定义 prompt + MCP 工具)
* 5. 等待 Codex 通过 MCP 工具发布审查评论
* 6. 清理工作空间
*/
export class CodexRunner {
constructor(
private readonly store: FileReviewStore,
private readonly localRepoManager: LocalRepoManager
) {}
async execute(run: ReviewRun): Promise<void> {
const targetSha = run.headSha || run.commitSha;
if (!targetSha) {
await this.store.markRunIgnored(run.id, '缺少目标 sha');
return;
}
let repoPaths: LocalRepoPaths | null = null;
try {
// ── Step 1: 准备工作空间 ──────────────────────────────────
const workspaceStepStart = Date.now();
await this.store.addStep({
runId: run.id,
stepName: 'codex_prepare_workspace',
status: 'started',
startedAt: new Date(workspaceStepStart).toISOString(),
});
repoPaths = await this.localRepoManager.prepareWorkspace(
run.owner,
run.repo,
run.cloneUrl,
targetSha,
run.id,
run.headCloneUrl
);
await this.store.addStep({
runId: run.id,
stepName: 'codex_prepare_workspace',
status: 'succeeded',
startedAt: new Date(workspaceStepStart).toISOString(),
finishedAt: new Date().toISOString(),
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('Codex 增量审查模式:使用上次审查快照', {
runId: run.id,
lastReviewedHead: snapshot.headSha,
currentHead: targetSha,
baseSha: run.baseSha,
});
} else {
// base 变了PR 分支做了 rebase→ 全量审查
logger.info('Codex PR base 已变更(可能 rebase回退全量审查', {
runId: run.id,
savedBaseSha: snapshot.baseSha,
currentBaseSha: run.baseSha,
});
}
}
}
// ── Step 2: 生成 .codex 配置 ───────────────────────────────
await this.generateCodexWorkspaceConfig(repoPaths.workspacePath, run.id);
// ── Step 3: 注册 MCP 上下文 ──────────────────────────────
const mcpContext: ReviewRunContext = {
runId: run.id,
owner: run.owner,
repo: run.repo,
prNumber: run.prNumber,
relatedPrNumber: run.relatedPrNumber,
commitSha: run.commitSha,
baseSha: run.baseSha,
headSha: run.headSha,
lastReviewedHead,
};
mcpToolExecutor.registerContext(mcpContext);
// ── Step 4: 执行 Codex CLI ────────────────────────────────
const codexStepStart = Date.now();
await this.store.addStep({
runId: run.id,
stepName: 'codex_review',
status: 'started',
startedAt: new Date(codexStepStart).toISOString(),
});
await this.runCodexProcess(repoPaths.workspacePath, run, lastReviewedHead);
await this.store.addStep({
runId: run.id,
stepName: 'codex_review',
status: 'succeeded',
startedAt: new Date(codexStepStart).toISOString(),
finishedAt: new Date().toISOString(),
latencyMs: Date.now() - codexStepStart,
});
logger.info('Codex 审查流程完成', {
runId: run.id,
owner: run.owner,
repo: run.repo,
});
// ── 审查成功:保存审查快照 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('Codex 保存审查快照 ref 失败(非致命)', {
runId: run.id,
error: refError instanceof Error ? refError.message : String(refError),
});
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await this.store.addStep({
runId: run.id,
stepName: 'codex_review',
status: 'failed',
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
error: message,
});
throw error;
} finally {
// 清理 MCP 上下文
mcpToolExecutor.unregisterContext(run.id);
// 清理工作空间
if (repoPaths) {
await this.localRepoManager.cleanupWorkspace(repoPaths);
}
}
}
private async generateCodexWorkspaceConfig(workspacePath: string, runId: string): Promise<void> {
const codexConfigDir = path.join(workspacePath, '.codex');
await mkdir(codexConfigDir, { recursive: true });
const port = config.app.port;
const model = config.review.codexModel;
const apiUrl = this.normalizeApiBaseUrl(config.review.codexApiUrl);
const apiKey = config.review.codexApiKey;
// 生成 TOML 配置(含 MCP server
const tomlLines: string[] = [
`model = "${model}"`,
`model_verbosity = "low"`,
'',
'[model_providers.openai]',
`name = "OpenAI"`,
`base_url = "${apiUrl}"`,
`env_key = "OPENAI_API_KEY"`,
'requires_openai_auth = false',
'',
'[mcp_servers.gitea-review]',
`url = "http://127.0.0.1:${port}/mcp/gitea-review"`,
`http_headers = { "X-Review-Run-Id" = "${runId}" }`,
'required = true',
'',
];
await writeFile(path.join(codexConfigDir, 'config.toml'), tomlLines.join('\n'), 'utf-8');
if (apiKey?.trim()) {
const authJson = {
auth_mode: 'api_key',
OPENAI_API_KEY: apiKey,
};
await writeFile(path.join(codexConfigDir, 'auth.json'), JSON.stringify(authJson), 'utf-8');
}
logger.debug('已生成 Codex 配置文件', {
runId,
configPath: path.join(codexConfigDir, 'config.toml'),
model,
apiUrl,
authMode: apiKey?.trim() ? 'env+auth.json' : 'missing_api_key',
});
}
private normalizeApiBaseUrl(rawUrl: string): string {
const trimmed = rawUrl.trim().replace(/\/+$/, '');
if (!trimmed) {
return 'https://api.openai.com/v1';
}
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`;
}
/**
* 构建审查提示词
*/
private buildReviewPrompt(run: ReviewRun, lastReviewedHead?: string): string {
const customPrompt = config.review.codexReviewPrompt?.trim();
const globalPrompt = config.review.globalPrompt?.trim();
const projectPrompt = this.resolveProjectPrompt(run);
const sections: string[] = [];
sections.push(SYSTEM_INSTRUCTIONS);
sections.push(`## 审查原则\n\n${customPrompt || DEFAULT_REVIEW_GUIDELINES}`);
if (globalPrompt) {
sections.push(`## 全局审查要求\n\n${globalPrompt}`);
}
if (projectPrompt) {
sections.push(`## 项目级审查要求\n\n${projectPrompt}`);
}
sections.push('当要求冲突时,优先级为:项目级审查要求 > 全局审查要求 > 审查原则。');
const contextLines: string[] = ['## 当前审查目标'];
if (run.eventType === 'pull_request') {
contextLines.push(' 类型Pull Request');
if (run.prNumber) contextLines.push(`- PR 编号:#${run.prNumber}`);
if (run.baseSha) contextLines.push(`- Base SHA${run.baseSha}`);
if (run.headSha) contextLines.push(`- Head SHA${run.headSha}`);
if (lastReviewedHead) {
contextLines.push('- 增量审查模式:仅审查上次审查后的新变更');
contextLines.push(`- 上次审查 SHA${lastReviewedHead}`);
contextLines.push(`- 请使用 \`git diff ${lastReviewedHead}..${run.headSha}\` 获取增量差异`);
} else {
contextLines.push(`- 请使用 \`git diff ${run.baseSha}...${run.headSha}\` 获取差异`);
}
} else if (run.eventType === 'commit_status') {
contextLines.push('- 类型Commit');
if (run.commitSha) {
contextLines.push(`- Commit SHA${run.commitSha}`);
contextLines.push(`- 请使用 \`git diff ${run.commitSha}~1...${run.commitSha}\` 获取差异`);
}
if (run.commitMessage) contextLines.push(`- Commit 信息:${run.commitMessage}`);
}
sections.push(contextLines.join('\n'));
return sections.join('\n\n');
}
private resolveProjectPrompt(_run: ReviewRun): string | undefined {
return resolveProjectReviewPrompt(_run.owner, _run.repo);
}
/**
* 执行 codex exec 子进程(自定义 prompt + MCP 工具)
*/
private async runCodexProcess(
workspacePath: string,
run: ReviewRun,
lastReviewedHead?: string
): Promise<void> {
const timeoutMs = config.review.codexTimeoutMs;
const codexHome = path.join(workspacePath, '.codex');
// 构建审查提示词
const prompt = this.buildReviewPrompt(run, lastReviewedHead);
// 构建命令参数codex exec --full-auto --ephemeral [PROMPT]
const args: string[] = ['exec', '--full-auto', '--ephemeral', prompt];
logger.info('启动 Codex CLI', {
runId: run.id,
args: ['codex', 'exec', '--full-auto', '--ephemeral', '<prompt>'].join(' '),
promptLength: prompt.length,
cwd: workspacePath,
timeoutMs,
});
const proc = Bun.spawn(['codex', ...args], {
cwd: workspacePath,
stdin: 'ignore',
stdout: 'pipe',
stderr: 'pipe',
env: {
...process.env,
CODEX_API_KEY: config.review.codexApiKey || '',
OPENAI_API_KEY: config.review.codexApiKey || '',
OPENAI_BASE_URL: this.normalizeApiBaseUrl(config.review.codexApiUrl),
CODEX_HOME: codexHome,
},
});
// 超时控制
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
proc.kill();
reject(new Error(`Codex 审查超时(${timeoutMs}ms`));
}, timeoutMs);
});
// 等待进程完成
const processPromise = (async () => {
let fullStderr = '';
// 并行读取 stdout 和 stderr
const readStderr = (async () => {
try {
const text = await new Response(proc.stderr).text();
fullStderr = text;
// 按行输出到日志
for (const line of text.split('\n')) {
if (line.trim()) {
logger.debug('Codex 输出', { runId: run.id, line: line.substring(0, 500) });
}
}
} catch {
// stream 读取错误不是致命的
}
})();
const readStdout = (async () => {
try {
const text = await new Response(proc.stdout).text();
for (const line of text.split('\n')) {
if (line.trim()) {
logger.debug('Codex 结果', { runId: run.id, line: line.substring(0, 500) });
}
}
} catch {
// stream 读取错误不是致命的
}
})();
// 等待读取完成 + 进程退出
await Promise.all([readStderr, readStdout]);
const exitCode = await proc.exited;
if (exitCode !== 0) {
throw new Error(
`Codex 进程退出码 ${exitCode}${fullStderr ? `\nstderr: ${fullStderr.substring(0, 2000)}` : ''}`
);
}
logger.info('Codex 进程结束', { runId: run.id, exitCode });
})();
await Promise.race([processPromise, timeoutPromise]);
}
}