fix: make all config consumers read dynamically instead of caching at module load

After migrating config to DB, values changed via Web UI were not picked
up by consumers that cached config at module load time.

- gitea.ts: replace static axios.create() with request interceptors that
  read config.gitea.apiUrl and accessToken on every request
- feishu.ts: remove constructor caching of webhookUrl/webhookSecret,
  read from config.feishu.* on each sendMessage() call
- engine.ts: create SandboxExec/LocalRepoManager/DiffExtractor/Orchestrator
  per review run instead of once at class init, so workdir/token/limits
  always reflect current config. FileReviewStore stays singleton (has state).
- index.ts: wrap JWT middleware in per-request handler so config.admin.jwtSecret
  is read dynamically instead of captured once at startup
This commit is contained in:
jeffusion
2026-03-05 16:08:50 +08:00
committed by 路遥知码力
parent e3b8365ea2
commit 9a356a228f
4 changed files with 78 additions and 62 deletions

View File

@@ -49,7 +49,10 @@ app.route('/admin/api', adminController.publicRoutes);
// 受保护的路由 // 受保护的路由
const adminProtected = new Hono(); const adminProtected = new Hono();
adminProtected.use('/*', jwt({ secret: config.admin.jwtSecret, alg: 'HS256' })); adminProtected.use('/*', (c, next) => {
const jwtMiddleware = jwt({ secret: config.admin.jwtSecret, alg: 'HS256' });
return jwtMiddleware(c, next);
});
adminProtected.route('/', adminController.protectedRoutes); adminProtected.route('/', adminController.protectedRoutes);
adminProtected.route('/feedback', feedbackRouter); adminProtected.route('/feedback', feedbackRouter);
adminProtected.route('/config', configRouter); adminProtected.route('/config', configRouter);

View File

@@ -8,32 +8,56 @@ import { FileReviewStore } from './store/file-review-store';
import { CommitReviewPayload, PullRequestReviewPayload, ReviewRun } from './types'; import { CommitReviewPayload, PullRequestReviewPayload, ReviewRun } from './types';
class ReviewEngine { class ReviewEngine {
private readonly store = new FileReviewStore(config.review.workdir); // Sub-objects are created lazily per config snapshot.
private readonly sandboxExec = new SandboxExec(config.review.allowedCommands); // store holds state (runs, steps) so we keep ONE instance but update workdir.
private readonly localRepoManager = new LocalRepoManager( private _store: FileReviewStore | null = null;
config.review.workdir,
this.sandboxExec,
config.review.commandTimeoutMs,
config.gitea.accessToken
);
private readonly diffExtractor = new DiffExtractor(
this.sandboxExec,
this.localRepoManager,
config.review.commandTimeoutMs,
config.review.maxFilesPerRun,
config.review.maxFileContentChars
);
private readonly orchestrator = new ReviewOrchestrator(
this.store,
this.localRepoManager,
this.diffExtractor
);
private started = false; private started = false;
private activeRunsCount = 0; private activeRunsCount = 0;
private timer: ReturnType<typeof setInterval> | null = null; private timer: ReturnType<typeof setInterval> | null = null;
private tickInProgress = false; private tickInProgress = false;
/** Lazily-created store — stable singleton (holds review state). */
private get store(): FileReviewStore {
if (!this._store) {
this._store = new FileReviewStore(config.review.workdir);
}
return this._store;
}
/** Fresh SandboxExec that always reflects current allowed-commands config. */
private createSandboxExec(): SandboxExec {
return new SandboxExec(config.review.allowedCommands);
}
/** Fresh LocalRepoManager that reads current config values. */
private createLocalRepoManager(sandboxExec: SandboxExec): LocalRepoManager {
return new LocalRepoManager(
config.review.workdir,
sandboxExec,
config.review.commandTimeoutMs,
config.gitea.accessToken
);
}
/** Fresh DiffExtractor that reads current config values. */
private createDiffExtractor(sandboxExec: SandboxExec, localRepoManager: LocalRepoManager): DiffExtractor {
return new DiffExtractor(
sandboxExec,
localRepoManager,
config.review.commandTimeoutMs,
config.review.maxFilesPerRun,
config.review.maxFileContentChars
);
}
/** Create a fresh orchestrator with current config for each run. */
private createOrchestrator(): ReviewOrchestrator {
const sandboxExec = this.createSandboxExec();
const localRepoManager = this.createLocalRepoManager(sandboxExec);
const diffExtractor = this.createDiffExtractor(sandboxExec, localRepoManager);
return new ReviewOrchestrator(this.store, localRepoManager, diffExtractor);
}
async start(): Promise<void> { async start(): Promise<void> {
if (this.started || config.review.engine !== 'agent') { if (this.started || config.review.engine !== 'agent') {
return; return;
@@ -92,27 +116,23 @@ class ReviewEngine {
} }
private async tick(): Promise<void> { private async tick(): Promise<void> {
// 防止重入如果上一次tick还在执行跳过本次调度
if (this.tickInProgress) { if (this.tickInProgress) {
return; return;
} }
this.tickInProgress = true; this.tickInProgress = true;
try { try {
// 检查是否达到并行限制
const maxParallel = config.review.maxParallelRuns; const maxParallel = config.review.maxParallelRuns;
if (this.activeRunsCount >= maxParallel) { if (this.activeRunsCount >= maxParallel) {
return; return;
} }
// 尝试获取并启动新任务,直到达到并行上限
while (this.activeRunsCount < maxParallel) { while (this.activeRunsCount < maxParallel) {
const run = await this.store.acquireNextQueuedRun(); const run = await this.store.acquireNextQueuedRun();
if (!run) { if (!run) {
break; // 队列为空 break;
} }
// 启动异步任务,不等待完成
this.activeRunsCount++; this.activeRunsCount++;
this.processRun(run).finally(() => { this.processRun(run).finally(() => {
this.activeRunsCount--; this.activeRunsCount--;
@@ -132,10 +152,12 @@ class ReviewEngine {
activeRuns: this.activeRunsCount, activeRuns: this.activeRunsCount,
}); });
try { // Create a fresh orchestrator per run so it picks up latest config values
await this.orchestrator.execute(run); const orchestrator = this.createOrchestrator();
try {
await orchestrator.execute(run);
// 检查run状态防止将ignored状态覆盖为succeeded
const runDetails = await this.store.getRunDetails(run.id); const runDetails = await this.store.getRunDetails(run.id);
if (runDetails && runDetails.run.status !== 'ignored') { if (runDetails && runDetails.run.status !== 'ignored') {
await this.store.markRunSucceeded(run.id); await this.store.markRunSucceeded(run.id);

View File

@@ -3,27 +3,11 @@ import config from '../config';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
export class FeishuService { export class FeishuService {
private webhookUrl?: string;
private webhookSecret?: string;
constructor() {
this.webhookUrl = config.feishu.webhookUrl;
this.webhookSecret = config.feishu.webhookSecret;
if (!this.webhookUrl) {
logger.info('飞书webhook URL未配置飞书通知已禁用');
}
if (this.webhookUrl && !this.webhookSecret) {
logger.warn('飞书webhook密钥未配置签名验证将被禁用');
}
}
/** /**
* 判断飞书通知是否已启用 * 判断飞书通知是否已启用
*/ */
isEnabled(): boolean { isEnabled(): boolean {
return !!this.webhookUrl; return !!config.feishu.webhookUrl;
} }
/** /**
@@ -43,7 +27,10 @@ export class FeishuService {
* @param usernames 需要@的用户名列表 * @param usernames 需要@的用户名列表
*/ */
async sendMessage(content: string, usernames: string[] = []): Promise<void> { async sendMessage(content: string, usernames: string[] = []): Promise<void> {
if (!this.webhookUrl) { const webhookUrl = config.feishu.webhookUrl;
const webhookSecret = config.feishu.webhookSecret;
if (!webhookUrl) {
logger.debug('飞书通知已跳过: webhook URL未配置'); logger.debug('飞书通知已跳过: webhook URL未配置');
return; return;
} }
@@ -66,12 +53,12 @@ export class FeishuService {
} }
// 如果配置了密钥,添加签名 // 如果配置了密钥,添加签名
if (this.webhookSecret) { if (webhookSecret) {
message.timestamp = timestamp; message.timestamp = timestamp;
message.sign = this.generateSign(timestamp, this.webhookSecret); message.sign = this.generateSign(timestamp, webhookSecret);
} }
const response = await fetch(this.webhookUrl, { const response = await fetch(webhookUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@@ -8,24 +8,28 @@ export interface LineComment {
comment: string; comment: string;
} }
// 创建API客户端 // API客户端 — 使用 interceptor 确保每次请求都读取最新的 config
const giteaClient = axios.create({ const giteaClient = axios.create({
baseURL: config.gitea.apiUrl, headers: { 'Content-Type': 'application/json' },
headers: { });
Authorization: `token ${config.gitea.accessToken}`, giteaClient.interceptors.request.use((req) => {
'Content-Type': 'application/json', req.baseURL = config.gitea.apiUrl;
}, req.headers.Authorization = `token ${config.gitea.accessToken}`;
return req;
}); });
// 创建用于管理操作的API客户端 // 管理操作的API客户端 — 同样动态读取 config
const giteaAdminClient = axios.create({ const giteaAdminClient = axios.create({
baseURL: config.gitea.apiUrl,
headers: { headers: {
Authorization: `token ${config.admin.giteaAdminToken || config.gitea.accessToken}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'User-Agent': 'curl/7.81.0', // 伪装成 curl 'User-Agent': 'curl/7.81.0',
}, },
proxy: false, // 禁用所有代理 proxy: false,
});
giteaAdminClient.interceptors.request.use((req) => {
req.baseURL = config.gitea.apiUrl;
req.headers.Authorization = `token ${config.admin.giteaAdminToken || config.gitea.accessToken}`;
return req;
}); });
// Gitea服务接口定义 // Gitea服务接口定义