mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-03-27 10:05:50 +00:00
- 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
409 lines
15 KiB
TypeScript
409 lines
15 KiB
TypeScript
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]);
|
||
}
|
||
}
|