plugin-git-manager 1.1.10 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/187.d5545b7cc8b90bfc.js +1 -0
- package/dist/client/228.7588a0707cb3694a.js +0 -9
- package/dist/client/index.js +1 -10
- package/dist/externalVersion.js +5 -14
- package/dist/index.js +0 -9
- package/dist/locale/en-US.json +2 -0
- package/dist/locale/vi-VN.json +2 -0
- package/dist/server/actions/git-actions.js +15 -21
- package/dist/server/actions/gitlab-api.js +0 -9
- package/dist/server/actions/poller.js +0 -9
- package/dist/server/actions/review.d.ts +5 -2
- package/dist/server/actions/review.js +430 -60
- package/dist/server/ai-tools.js +0 -9
- package/dist/server/collections/gitCodeReviews.d.ts +1 -1
- package/dist/server/collections/gitCodeReviews.js +1 -9
- package/dist/server/collections/gitRepositories.d.ts +1 -1
- package/dist/server/collections/gitRepositories.js +0 -9
- package/dist/server/collections/gitReviewFlows.d.ts +1 -1
- package/dist/server/collections/gitReviewFlows.js +0 -9
- package/dist/server/index.js +0 -9
- package/dist/server/migrations/20260508000000-add-auto-review-flow-id.js +0 -9
- package/dist/server/plugin.js +5 -9
- package/dist/server/poller.js +0 -9
- package/dist/server/utils/gitlab-url.js +0 -9
- package/dist/server/utils/redact.js +0 -9
- package/package.json +2 -2
- package/src/client/components/CommitHistory.tsx +3 -0
- package/src/client/components/FileExplorer.tsx +29 -24
- package/src/client/components/GitOperations.tsx +3 -0
- package/src/client/components/ReviewFlows.tsx +11 -1
- package/src/client/components/ReviewHistory.tsx +14 -1
- package/src/locale/en-US.json +2 -0
- package/src/locale/vi-VN.json +2 -0
- package/src/server/actions/git-actions.ts +15 -12
- package/src/server/actions/review.ts +504 -63
- package/src/server/collections/gitCodeReviews.ts +1 -0
- package/src/server/plugin.ts +25 -20
- package/dist/client/187.08dd0bf4d0f68036.js +0 -10
|
@@ -3,6 +3,24 @@ import type { Application } from '@nocobase/server';
|
|
|
3
3
|
import { parseGitLabProject } from '../utils/gitlab-url';
|
|
4
4
|
import { redactPat } from '../utils/redact';
|
|
5
5
|
|
|
6
|
+
export const WORKER_JOB_GIT_REVIEW_PROCESS = 'git-review:process';
|
|
7
|
+
const REVIEW_QUEUE_CHANNEL = 'plugin-git-manager.review';
|
|
8
|
+
const REVIEW_QUEUE_CONCURRENCY = Math.max(
|
|
9
|
+
1,
|
|
10
|
+
Number.parseInt(process.env.GIT_REVIEW_QUEUE_CONCURRENCY || process.env.GIT_REVIEW_MAX_CONCURRENCY || '3', 10) || 3,
|
|
11
|
+
);
|
|
12
|
+
const REVIEW_QUEUE_TIMEOUT_MS = Math.max(
|
|
13
|
+
60_000,
|
|
14
|
+
Number.parseInt(process.env.GIT_REVIEW_QUEUE_TIMEOUT_MS || '', 10) || 10 * 60 * 1000,
|
|
15
|
+
);
|
|
16
|
+
const REVIEW_QUEUE_POLL_INTERVAL_MS = Math.max(
|
|
17
|
+
1000,
|
|
18
|
+
Number.parseInt(process.env.GIT_REVIEW_QUEUE_POLL_INTERVAL_MS || '', 10) || 5000,
|
|
19
|
+
);
|
|
20
|
+
const REVIEW_PROCESS_LOCK_TTL_MS = Math.max(REVIEW_QUEUE_TIMEOUT_MS + 60_000, 11 * 60 * 1000);
|
|
21
|
+
const REVIEW_QUEUE_WAKE_CHANNEL = 'plugin-git-manager.review.wake';
|
|
22
|
+
const REVIEW_QUEUE_REDIS_CONNECTION = 'plugin-git-manager.review.queue';
|
|
23
|
+
|
|
6
24
|
interface TriggerArgs {
|
|
7
25
|
flowId?: number | null;
|
|
8
26
|
repositoryId: number;
|
|
@@ -16,6 +34,42 @@ interface TriggerArgs {
|
|
|
16
34
|
userId?: number | string | null;
|
|
17
35
|
}
|
|
18
36
|
|
|
37
|
+
interface ReviewFlowSnapshot {
|
|
38
|
+
id: number;
|
|
39
|
+
name?: string;
|
|
40
|
+
postMode?: string;
|
|
41
|
+
llmService?: string | null;
|
|
42
|
+
model?: string | null;
|
|
43
|
+
instructions?: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ReviewQueueMessage {
|
|
47
|
+
reviewId: number;
|
|
48
|
+
repositoryId: number;
|
|
49
|
+
targetType: 'mr' | 'commit' | 'branch';
|
|
50
|
+
mrIid?: number | null;
|
|
51
|
+
commitSha?: string | null;
|
|
52
|
+
branch?: string | null;
|
|
53
|
+
headSha?: string | null;
|
|
54
|
+
aiEmployeeUsername: string;
|
|
55
|
+
extraInstructions?: string;
|
|
56
|
+
userId?: number | string | null;
|
|
57
|
+
flowSnapshot?: ReviewFlowSnapshot;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface QueuedReviewMetadata {
|
|
61
|
+
queuedAt?: string;
|
|
62
|
+
aiEmployeeUsername?: string;
|
|
63
|
+
extraInstructions?: string | null;
|
|
64
|
+
userId?: number | string | null;
|
|
65
|
+
flowSnapshot?: ReviewFlowSnapshot;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let reviewQueueTimer: NodeJS.Timeout | null = null;
|
|
69
|
+
let reviewQueueKickTimer: NodeJS.Timeout | null = null;
|
|
70
|
+
let reviewQueueProcessing = false;
|
|
71
|
+
let reviewWakeHandler: ((message?: any) => Promise<void>) | null = null;
|
|
72
|
+
|
|
19
73
|
function getActionParams(ctx: Context) {
|
|
20
74
|
return { ...ctx.action.params, ...ctx.action.params?.values, ...((ctx as any).request?.body || {}) };
|
|
21
75
|
}
|
|
@@ -29,8 +83,8 @@ function getActionParams(ctx: Context) {
|
|
|
29
83
|
* Uses `app.lockManager` so the same code path covers both single-node
|
|
30
84
|
* (in-memory `async-mutex`) and HA cluster (Redis-backed Redlock when
|
|
31
85
|
* `plugin-cluster-manager` is active and `LOCK_ADAPTER_DEFAULT=redis`).
|
|
32
|
-
* The locked region only does an upsert +
|
|
33
|
-
*
|
|
86
|
+
* The locked region only does an upsert + queue publish (a few ms), so a 30s
|
|
87
|
+
* TTL is generous and auto-releases if the process crashes.
|
|
34
88
|
*/
|
|
35
89
|
function targetKey(args: TriggerArgs): string {
|
|
36
90
|
if (args.targetType === 'mr') return `${args.repositoryId}:mr:${args.mrIid}`;
|
|
@@ -47,20 +101,12 @@ async function withTriggerLock<T>(app: Application, key: string, fn: () => Promi
|
|
|
47
101
|
|
|
48
102
|
/**
|
|
49
103
|
* Trigger an AI-driven code review for an MR / commit / branch.
|
|
50
|
-
* The review record is upserted synchronously, then
|
|
51
|
-
*
|
|
104
|
+
* The review record is upserted synchronously, then queued for an available
|
|
105
|
+
* git-review worker. The action returns immediately with the reviewId.
|
|
52
106
|
*/
|
|
53
107
|
export async function triggerReview(ctx: Context, next: () => Promise<void>) {
|
|
54
108
|
const params = getActionParams(ctx);
|
|
55
|
-
const {
|
|
56
|
-
flowId,
|
|
57
|
-
repositoryId,
|
|
58
|
-
targetType,
|
|
59
|
-
mrIid,
|
|
60
|
-
commitSha,
|
|
61
|
-
branch,
|
|
62
|
-
extraInstructions,
|
|
63
|
-
} = params;
|
|
109
|
+
const { flowId, repositoryId, targetType, mrIid, commitSha, branch, extraInstructions } = params;
|
|
64
110
|
|
|
65
111
|
if (!repositoryId) ctx.throw(400, 'repositoryId is required');
|
|
66
112
|
if (!targetType) ctx.throw(400, 'targetType is required');
|
|
@@ -162,14 +208,20 @@ async function triggerReviewInternalLocked(app: Application, args: TriggerArgs):
|
|
|
162
208
|
latestSha: headSha || existingLatestSha || null,
|
|
163
209
|
triggeredBy: args.triggeredBy || 'manual',
|
|
164
210
|
status: 'pending',
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
startedAt: new Date(),
|
|
211
|
+
// `startedAt` is stamped by the queue worker when execution actually
|
|
212
|
+
// starts. Pending rows are durable queue items for worker polling.
|
|
213
|
+
startedAt: null,
|
|
169
214
|
finishedAt: null,
|
|
170
215
|
durationMs: null,
|
|
171
|
-
postStatus: flow.
|
|
216
|
+
postStatus: getInitialPostStatus(flow, args.targetType),
|
|
172
217
|
error: null,
|
|
218
|
+
metadata: {
|
|
219
|
+
queuedAt: new Date().toISOString(),
|
|
220
|
+
aiEmployeeUsername,
|
|
221
|
+
extraInstructions: args.extraInstructions || null,
|
|
222
|
+
userId: args.userId ?? null,
|
|
223
|
+
flowSnapshot: createFlowSnapshot(flow),
|
|
224
|
+
},
|
|
173
225
|
};
|
|
174
226
|
|
|
175
227
|
let reviewId: number;
|
|
@@ -193,28 +245,372 @@ async function triggerReviewInternalLocked(app: Application, args: TriggerArgs):
|
|
|
193
245
|
reviewId = review.get('id') as number;
|
|
194
246
|
}
|
|
195
247
|
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}).catch((err) => {
|
|
211
|
-
app.log?.error?.('runReview background error', err);
|
|
212
|
-
}),
|
|
213
|
-
);
|
|
248
|
+
// Queue for background workers; the action returns as soon as the message is published.
|
|
249
|
+
await enqueueReview(app, {
|
|
250
|
+
reviewId,
|
|
251
|
+
repositoryId: args.repositoryId,
|
|
252
|
+
targetType: args.targetType,
|
|
253
|
+
mrIid: args.targetType === 'mr' ? args.mrIid : null,
|
|
254
|
+
commitSha: args.targetType === 'commit' ? args.commitSha : null,
|
|
255
|
+
branch: args.branch || undefined,
|
|
256
|
+
headSha,
|
|
257
|
+
aiEmployeeUsername,
|
|
258
|
+
extraInstructions: args.extraInstructions,
|
|
259
|
+
userId: args.userId ?? null,
|
|
260
|
+
flowSnapshot: createFlowSnapshot(flow),
|
|
261
|
+
});
|
|
214
262
|
|
|
215
263
|
return reviewId;
|
|
216
264
|
}
|
|
217
265
|
|
|
266
|
+
export function registerReviewQueue(app: Application) {
|
|
267
|
+
app.eventQueue.subscribe(REVIEW_QUEUE_CHANNEL, {
|
|
268
|
+
concurrency: REVIEW_QUEUE_CONCURRENCY,
|
|
269
|
+
idle: () => isGitReviewWorker(app),
|
|
270
|
+
process: async (message: ReviewQueueMessage) => {
|
|
271
|
+
await processQueuedReview(app, message);
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
if (!isGitReviewWorker(app)) {
|
|
275
|
+
app.on('afterStart', () => clearLocalReviewMemoryQueue(app));
|
|
276
|
+
}
|
|
277
|
+
startReviewQueueProcessor(app);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function unregisterReviewQueue(app: Application) {
|
|
281
|
+
app.eventQueue.unsubscribe(REVIEW_QUEUE_CHANNEL);
|
|
282
|
+
stopReviewQueueProcessor(app);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function createFlowSnapshot(flow: any): ReviewFlowSnapshot {
|
|
286
|
+
return {
|
|
287
|
+
id: Number(flow.get('id')),
|
|
288
|
+
name: flow.get('name') as string,
|
|
289
|
+
postMode: flow.get('postMode') as string,
|
|
290
|
+
llmService: flow.get('llmService') as string | null,
|
|
291
|
+
model: flow.get('model') as string | null,
|
|
292
|
+
instructions: flow.get('instructions') as string | null,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function createFlowFromSnapshot(snapshot: ReviewFlowSnapshot | undefined, fallback?: any) {
|
|
297
|
+
return {
|
|
298
|
+
get(name: string) {
|
|
299
|
+
if (snapshot && Object.prototype.hasOwnProperty.call(snapshot, name)) {
|
|
300
|
+
return (snapshot as any)[name];
|
|
301
|
+
}
|
|
302
|
+
return fallback?.get?.(name);
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function isGitReviewWorker(app: Application): boolean {
|
|
308
|
+
const workerMode = process.env.WORKER_MODE || '';
|
|
309
|
+
return (
|
|
310
|
+
app.serving(WORKER_JOB_GIT_REVIEW_PROCESS) ||
|
|
311
|
+
workerMode === 'worker' ||
|
|
312
|
+
workerMode === 'task' ||
|
|
313
|
+
process.env.APP_ROLE === 'worker'
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function clearLocalReviewMemoryQueue(app: Application) {
|
|
318
|
+
const eventQueue = (app as any).eventQueue;
|
|
319
|
+
const adapter = eventQueue?.adapter;
|
|
320
|
+
const fullChannel = eventQueue?.getFullChannel?.(REVIEW_QUEUE_CHANNEL);
|
|
321
|
+
const queue = fullChannel ? adapter?.queues?.get?.(fullChannel) : null;
|
|
322
|
+
if (!queue?.length) return;
|
|
323
|
+
|
|
324
|
+
adapter.queues.set(fullChannel, []);
|
|
325
|
+
app.log?.warn?.(
|
|
326
|
+
`git review queue: cleared ${queue.length} stale local memory message(s) on non-worker node; pending DB rows will be picked up by workers`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function getReviewQueueRedisKey(app: Application): string {
|
|
331
|
+
const appName = (app as any).name || process.env.APP_NAME || 'main';
|
|
332
|
+
return `${appName}:plugin-git-manager:review:queue`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function getReviewQueueRedis(app: Application): Promise<any | null> {
|
|
336
|
+
const manager = (app as any).redisConnectionManager;
|
|
337
|
+
if (!manager?.getConnectionSync) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
try {
|
|
342
|
+
const connectionString = process.env.QUEUE_ADAPTER_REDIS_URL || process.env.REDIS_URL;
|
|
343
|
+
return await manager.getConnectionSync(
|
|
344
|
+
REVIEW_QUEUE_REDIS_CONNECTION,
|
|
345
|
+
connectionString ? { connectionString } : undefined,
|
|
346
|
+
);
|
|
347
|
+
} catch (err: any) {
|
|
348
|
+
app.log?.debug?.(`git review queue: Redis queue unavailable, falling back to DB polling: ${err?.message || err}`);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function enqueueReviewToRedis(app: Application, message: ReviewQueueMessage): Promise<boolean> {
|
|
354
|
+
const redis = await getReviewQueueRedis(app);
|
|
355
|
+
if (!redis) return false;
|
|
356
|
+
|
|
357
|
+
await redis.sendCommand(['RPUSH', getReviewQueueRedisKey(app), JSON.stringify(message)]);
|
|
358
|
+
app.log?.debug?.(`git review queue: enqueued review ${message.reviewId} to Redis`);
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function publishReviewQueueWake(app: Application, reviewId?: number) {
|
|
363
|
+
try {
|
|
364
|
+
await (app as any).pubSubManager?.publish?.(
|
|
365
|
+
REVIEW_QUEUE_WAKE_CHANNEL,
|
|
366
|
+
{ reviewId },
|
|
367
|
+
{ skipSelf: !isGitReviewWorker(app) },
|
|
368
|
+
);
|
|
369
|
+
} catch (err: any) {
|
|
370
|
+
app.log?.debug?.(`git review queue: wake publish skipped: ${err?.message || err}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function startReviewQueueProcessor(app: Application) {
|
|
375
|
+
if (!isGitReviewWorker(app)) {
|
|
376
|
+
app.log?.debug?.('plugin-git-manager: review queue processor disabled on non-worker node');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
if (reviewQueueTimer) return;
|
|
380
|
+
|
|
381
|
+
reviewWakeHandler = async () => {
|
|
382
|
+
scheduleReviewQueueTick(app, 0);
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
const subscribe = (app as any).pubSubManager?.subscribe?.(REVIEW_QUEUE_WAKE_CHANNEL, reviewWakeHandler);
|
|
386
|
+
if (subscribe?.catch) {
|
|
387
|
+
subscribe.catch((err: any) => app.log?.debug?.(`git review queue: wake subscribe skipped: ${err?.message || err}`));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
reviewQueueTimer = setInterval(() => scheduleReviewQueueTick(app, 0), REVIEW_QUEUE_POLL_INTERVAL_MS);
|
|
391
|
+
(reviewQueueTimer as any).unref?.();
|
|
392
|
+
scheduleReviewQueueTick(app, 1000);
|
|
393
|
+
app.log?.info?.(`plugin-git-manager: review queue processor started (interval ${REVIEW_QUEUE_POLL_INTERVAL_MS}ms)`);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function stopReviewQueueProcessor(app: Application) {
|
|
397
|
+
if (reviewQueueTimer) {
|
|
398
|
+
clearInterval(reviewQueueTimer);
|
|
399
|
+
reviewQueueTimer = null;
|
|
400
|
+
}
|
|
401
|
+
if (reviewQueueKickTimer) {
|
|
402
|
+
clearTimeout(reviewQueueKickTimer);
|
|
403
|
+
reviewQueueKickTimer = null;
|
|
404
|
+
}
|
|
405
|
+
if (reviewWakeHandler) {
|
|
406
|
+
const unsubscribe = (app as any).pubSubManager?.unsubscribe?.(REVIEW_QUEUE_WAKE_CHANNEL, reviewWakeHandler);
|
|
407
|
+
if (unsubscribe?.catch) {
|
|
408
|
+
unsubscribe.catch(() => undefined);
|
|
409
|
+
}
|
|
410
|
+
reviewWakeHandler = null;
|
|
411
|
+
}
|
|
412
|
+
reviewQueueProcessing = false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function scheduleReviewQueueTick(app: Application, delayMs: number) {
|
|
416
|
+
if (reviewQueueKickTimer) return;
|
|
417
|
+
reviewQueueKickTimer = setTimeout(() => {
|
|
418
|
+
reviewQueueKickTimer = null;
|
|
419
|
+
runReviewQueueTick(app).catch((err) => app.log?.error?.('git review queue: processor tick failed', err));
|
|
420
|
+
}, delayMs);
|
|
421
|
+
(reviewQueueKickTimer as any).unref?.();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function runReviewQueueTick(app: Application) {
|
|
425
|
+
if (reviewQueueProcessing || !isGitReviewWorker(app)) return;
|
|
426
|
+
|
|
427
|
+
reviewQueueProcessing = true;
|
|
428
|
+
try {
|
|
429
|
+
const redisMessages = await drainRedisReviewQueue(app, REVIEW_QUEUE_CONCURRENCY);
|
|
430
|
+
await processReviewQueueMessages(app, redisMessages);
|
|
431
|
+
|
|
432
|
+
const remaining = Math.max(1, REVIEW_QUEUE_CONCURRENCY - redisMessages.length);
|
|
433
|
+
await processPendingReviews(app, remaining);
|
|
434
|
+
} finally {
|
|
435
|
+
reviewQueueProcessing = false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function drainRedisReviewQueue(app: Application, count: number): Promise<ReviewQueueMessage[]> {
|
|
440
|
+
const redis = await getReviewQueueRedis(app);
|
|
441
|
+
if (!redis) return [];
|
|
442
|
+
|
|
443
|
+
const key = getReviewQueueRedisKey(app);
|
|
444
|
+
const messages: ReviewQueueMessage[] = [];
|
|
445
|
+
for (let i = 0; i < count; i += 1) {
|
|
446
|
+
const raw = await redis.sendCommand(['LPOP', key]);
|
|
447
|
+
if (!raw) break;
|
|
448
|
+
try {
|
|
449
|
+
messages.push(JSON.parse(String(raw)));
|
|
450
|
+
} catch (err: any) {
|
|
451
|
+
app.log?.warn?.(`git review queue: dropped invalid Redis message: ${err?.message || err}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return messages;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function getQueuedReviewMetadata(review: any): QueuedReviewMetadata {
|
|
458
|
+
const raw = review?.get?.('metadata');
|
|
459
|
+
if (!raw) return {};
|
|
460
|
+
if (typeof raw === 'string') {
|
|
461
|
+
try {
|
|
462
|
+
return JSON.parse(raw) || {};
|
|
463
|
+
} catch {
|
|
464
|
+
return {};
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return typeof raw === 'object' ? raw : {};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function toNullableNumber(value: any): number | null {
|
|
471
|
+
if (value === null || value === undefined || value === '') return null;
|
|
472
|
+
const parsed = Number(value);
|
|
473
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function createReviewQueueMessageFromReview(review: any): ReviewQueueMessage {
|
|
477
|
+
const metadata = getQueuedReviewMetadata(review);
|
|
478
|
+
const targetType = review.get('targetType') as 'mr' | 'commit' | 'branch';
|
|
479
|
+
return {
|
|
480
|
+
reviewId: Number(review.get('id')),
|
|
481
|
+
repositoryId: Number(review.get('repositoryId')),
|
|
482
|
+
targetType,
|
|
483
|
+
mrIid: targetType === 'mr' ? toNullableNumber(review.get('mrIid')) : null,
|
|
484
|
+
commitSha: targetType === 'commit' ? (review.get('commitSha') as string | null) : null,
|
|
485
|
+
branch: targetType === 'branch' ? (review.get('branch') as string | null) : null,
|
|
486
|
+
headSha: review.get('headSha') as string | null,
|
|
487
|
+
aiEmployeeUsername: metadata.aiEmployeeUsername || '',
|
|
488
|
+
extraInstructions: metadata.extraInstructions || undefined,
|
|
489
|
+
userId: metadata.userId ?? null,
|
|
490
|
+
flowSnapshot: metadata.flowSnapshot,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function processPendingReviews(app: Application, count: number) {
|
|
495
|
+
const pending = await app.db.getRepository('gitCodeReviews').find({
|
|
496
|
+
filter: { status: 'pending' },
|
|
497
|
+
sort: ['createdAt'],
|
|
498
|
+
limit: count,
|
|
499
|
+
});
|
|
500
|
+
if (!pending?.length) return;
|
|
501
|
+
await processReviewQueueMessages(app, pending.map(createReviewQueueMessageFromReview));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function processReviewQueueMessages(app: Application, messages: ReviewQueueMessage[]) {
|
|
505
|
+
if (!messages.length) return;
|
|
506
|
+
await Promise.all(messages.map((message) => processQueuedReview(app, message)));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
async function withReviewProcessLock<T>(app: Application, reviewId: number, fn: () => Promise<T>): Promise<T> {
|
|
510
|
+
const lockKey = `git-review:process:${reviewId}`;
|
|
511
|
+
return app.lockManager.runExclusive(lockKey, fn, REVIEW_PROCESS_LOCK_TTL_MS);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function enqueueReview(app: Application, message: ReviewQueueMessage) {
|
|
515
|
+
try {
|
|
516
|
+
const queuedInRedis = await enqueueReviewToRedis(app, message);
|
|
517
|
+
if (queuedInRedis) {
|
|
518
|
+
await publishReviewQueueWake(app, message.reviewId);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
await publishReviewQueueWake(app, message.reviewId);
|
|
523
|
+
|
|
524
|
+
if (isGitReviewWorker(app)) {
|
|
525
|
+
await app.eventQueue.publish(REVIEW_QUEUE_CHANNEL, message, {
|
|
526
|
+
timeout: REVIEW_QUEUE_TIMEOUT_MS,
|
|
527
|
+
maxRetries: 1,
|
|
528
|
+
});
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
app.log?.warn?.(
|
|
533
|
+
`git review queue: Redis queue is unavailable; review ${message.reviewId} will remain pending until a worker DB poller picks it up`,
|
|
534
|
+
);
|
|
535
|
+
} catch (err: any) {
|
|
536
|
+
const safeMessage = redactPat(err?.message || String(err));
|
|
537
|
+
await app.db.getRepository('gitCodeReviews').update({
|
|
538
|
+
filterByTk: message.reviewId,
|
|
539
|
+
values: {
|
|
540
|
+
status: 'failed',
|
|
541
|
+
error: `Failed to enqueue review: ${safeMessage}`,
|
|
542
|
+
finishedAt: new Date(),
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
throw err;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function failQueuedReview(app: Application, reviewId: number, err: any) {
|
|
550
|
+
const safeMessage = redactPat(err?.message || String(err));
|
|
551
|
+
await app.db.getRepository('gitCodeReviews').update({
|
|
552
|
+
filterByTk: reviewId,
|
|
553
|
+
values: {
|
|
554
|
+
status: 'failed',
|
|
555
|
+
error: safeMessage,
|
|
556
|
+
finishedAt: new Date(),
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function processQueuedReview(app: Application, message: ReviewQueueMessage) {
|
|
562
|
+
await withReviewProcessLock(app, message.reviewId, async () => {
|
|
563
|
+
const db = app.db;
|
|
564
|
+
const reviewsRepo = db.getRepository('gitCodeReviews');
|
|
565
|
+
const review = await reviewsRepo.findOne({ filterByTk: message.reviewId });
|
|
566
|
+
if (!review) {
|
|
567
|
+
app.log?.warn?.(`git review queue: review ${message.reviewId} not found, skipping`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (review.get('status') !== 'pending') {
|
|
572
|
+
app.log?.info?.(`git review queue: review ${message.reviewId} is ${review.get('status')}, skipping`);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const metadata = getQueuedReviewMetadata(review);
|
|
577
|
+
const targetType = (message.targetType || review.get('targetType')) as 'mr' | 'commit' | 'branch';
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
const repo = await db.getRepository('gitRepositories').findOne({
|
|
581
|
+
filterByTk: message.repositoryId || review.get('repositoryId'),
|
|
582
|
+
});
|
|
583
|
+
if (!repo) throw new Error('Repository not found');
|
|
584
|
+
|
|
585
|
+
const flowSnapshot = message.flowSnapshot || metadata.flowSnapshot;
|
|
586
|
+
const storedFlow = await db.getRepository('gitReviewFlows').findOne({
|
|
587
|
+
filterByTk: flowSnapshot?.id || review.get('flowId'),
|
|
588
|
+
});
|
|
589
|
+
const flow = createFlowFromSnapshot(flowSnapshot, storedFlow);
|
|
590
|
+
const aiEmployeeUsername =
|
|
591
|
+
message.aiEmployeeUsername || metadata.aiEmployeeUsername || (flow.get('aiEmployeeUsername') as string);
|
|
592
|
+
if (!aiEmployeeUsername) throw new Error('Flow has no AI employee configured');
|
|
593
|
+
|
|
594
|
+
await runReview(app, {
|
|
595
|
+
reviewId: message.reviewId,
|
|
596
|
+
flow,
|
|
597
|
+
repo,
|
|
598
|
+
targetType,
|
|
599
|
+
mrIid: targetType === 'mr' ? message.mrIid ?? toNullableNumber(review.get('mrIid')) : null,
|
|
600
|
+
commitSha: targetType === 'commit' ? message.commitSha || (review.get('commitSha') as string) : null,
|
|
601
|
+
branch: message.branch || (review.get('branch') as string | undefined),
|
|
602
|
+
headSha: message.headSha || (review.get('headSha') as string | null),
|
|
603
|
+
aiEmployeeUsername,
|
|
604
|
+
extraInstructions: message.extraInstructions ?? metadata.extraInstructions ?? undefined,
|
|
605
|
+
userId: message.userId ?? metadata.userId ?? null,
|
|
606
|
+
});
|
|
607
|
+
} catch (err) {
|
|
608
|
+
app.log?.error?.('git review queue: failed before review execution', err);
|
|
609
|
+
await failQueuedReview(app, message.reviewId, err);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
218
614
|
/**
|
|
219
615
|
* Mark a review as approved and post its content to GitLab as an MR note.
|
|
220
616
|
*/
|
|
@@ -248,6 +644,7 @@ export async function reviewApprovePost(ctx: Context, next: () => Promise<void>)
|
|
|
248
644
|
postedNoteId: String(noteId),
|
|
249
645
|
approvedBy: userId ? String(userId) : null,
|
|
250
646
|
approvedAt: new Date(),
|
|
647
|
+
error: null,
|
|
251
648
|
},
|
|
252
649
|
});
|
|
253
650
|
|
|
@@ -391,7 +788,8 @@ async function runReview(app: Application, args: RunReviewArgs) {
|
|
|
391
788
|
// try next path
|
|
392
789
|
}
|
|
393
790
|
}
|
|
394
|
-
if (!AIEmployee)
|
|
791
|
+
if (!AIEmployee)
|
|
792
|
+
throw new Error('AIEmployee class not found — plugin-ai may not be installed or its exports changed');
|
|
395
793
|
|
|
396
794
|
const llmService = args.flow.get('llmService') as string | null;
|
|
397
795
|
const model = args.flow.get('model') as string | null;
|
|
@@ -419,7 +817,7 @@ async function runReview(app: Application, args: RunReviewArgs) {
|
|
|
419
817
|
},
|
|
420
818
|
],
|
|
421
819
|
}),
|
|
422
|
-
new Promise<never>((
|
|
820
|
+
new Promise<never>((_resolve, reject) =>
|
|
423
821
|
setTimeout(() => reject(new Error('AI review timed out after 5 minutes')), REVIEW_TIMEOUT_MS),
|
|
424
822
|
),
|
|
425
823
|
]);
|
|
@@ -432,9 +830,10 @@ async function runReview(app: Application, args: RunReviewArgs) {
|
|
|
432
830
|
const finishedAt = new Date();
|
|
433
831
|
const durationMs = finishedAt.getTime() - startedAt.getTime();
|
|
434
832
|
|
|
435
|
-
const postMode = args.flow
|
|
436
|
-
let postStatus =
|
|
833
|
+
const postMode = getFlowPostMode(args.flow);
|
|
834
|
+
let postStatus = getInitialPostStatus(args.flow, args.targetType);
|
|
437
835
|
let postedNoteId: string | null = null;
|
|
836
|
+
let autoPostError: string | null = null;
|
|
438
837
|
|
|
439
838
|
if (postMode === 'disabled') {
|
|
440
839
|
postStatus = 'skipped';
|
|
@@ -443,6 +842,8 @@ async function runReview(app: Application, args: RunReviewArgs) {
|
|
|
443
842
|
postedNoteId = String(await postNoteToGitLab(args.repo, args.mrIid, content));
|
|
444
843
|
postStatus = 'posted';
|
|
445
844
|
} catch (err: any) {
|
|
845
|
+
autoPostError = redactPat(err?.message || String(err));
|
|
846
|
+
postStatus = 'post_failed';
|
|
446
847
|
app.log?.error?.('Auto-post review note failed', err);
|
|
447
848
|
}
|
|
448
849
|
}
|
|
@@ -461,11 +862,14 @@ async function runReview(app: Application, args: RunReviewArgs) {
|
|
|
461
862
|
finishedAt,
|
|
462
863
|
postStatus,
|
|
463
864
|
postedNoteId,
|
|
865
|
+
error: autoPostError ? `Auto-post failed: ${autoPostError}` : null,
|
|
464
866
|
metadata: {
|
|
465
867
|
flowName: args.flow.get('name'),
|
|
466
868
|
aiEmployeeUsername: args.aiEmployeeUsername,
|
|
467
869
|
llmService,
|
|
468
870
|
model,
|
|
871
|
+
postMode,
|
|
872
|
+
autoPostError,
|
|
469
873
|
},
|
|
470
874
|
},
|
|
471
875
|
});
|
|
@@ -504,20 +908,34 @@ function buildReviewPrompt(args: RunReviewArgs): string {
|
|
|
504
908
|
|
|
505
909
|
if (args.targetType === 'mr') {
|
|
506
910
|
lines.push(`Target: Merge Request !${args.mrIid}.`);
|
|
507
|
-
lines.push(
|
|
911
|
+
lines.push(
|
|
912
|
+
`Use the \`git_get_merge_request\` tool with repositoryId=${args.repo.get('id')} and mrIid=${
|
|
913
|
+
args.mrIid
|
|
914
|
+
} to fetch the diff and metadata.`,
|
|
915
|
+
);
|
|
508
916
|
lines.push('Optionally call `git_get_merge_request_notes` to avoid duplicating prior comments.');
|
|
509
917
|
} else if (args.targetType === 'commit') {
|
|
510
918
|
lines.push(`Target: Commit ${args.commitSha}.`);
|
|
511
|
-
lines.push(
|
|
919
|
+
lines.push(
|
|
920
|
+
`Use the \`git_get_commit\` tool with repositoryId=${args.repo.get('id')} and commitHash=${
|
|
921
|
+
args.commitSha
|
|
922
|
+
} to fetch the diff.`,
|
|
923
|
+
);
|
|
512
924
|
} else {
|
|
513
925
|
lines.push(`Target: Branch ${args.branch}.`);
|
|
514
|
-
lines.push(
|
|
926
|
+
lines.push(
|
|
927
|
+
`Use \`git_list_commits\`, \`git_get_diff\`, and \`git_get_file_content\` (with repositoryId=${args.repo.get(
|
|
928
|
+
'id',
|
|
929
|
+
)}) to inspect recent changes on this branch.`,
|
|
930
|
+
);
|
|
515
931
|
}
|
|
516
932
|
|
|
517
933
|
lines.push('');
|
|
518
934
|
lines.push('Produce a thorough but concise code review report in Markdown. Required sections:');
|
|
519
935
|
lines.push('1. **Summary** — overall assessment.');
|
|
520
|
-
lines.push(
|
|
936
|
+
lines.push(
|
|
937
|
+
'2. **Findings** — issues grouped by severity (`Critical`, `High`, `Medium`, `Low`, `Info`). For each finding include the file path, line/range when possible, the problem, and a suggested fix.',
|
|
938
|
+
);
|
|
521
939
|
lines.push('3. **Suggestions** — non-blocking improvements.');
|
|
522
940
|
lines.push('4. **Verdict** — one of: `LGTM`, `Approve with comments`, `Request changes`, `Block`.');
|
|
523
941
|
lines.push('');
|
|
@@ -595,12 +1013,12 @@ async function fetchMrHeadSha(repo: any, mrIid: number): Promise<string | null>
|
|
|
595
1013
|
const pat = repo.get('pat') as string;
|
|
596
1014
|
if (!pat) return null;
|
|
597
1015
|
const isGitHub = repoUrl.includes('github.com');
|
|
598
|
-
|
|
1016
|
+
|
|
599
1017
|
try {
|
|
600
1018
|
if (isGitHub) {
|
|
601
1019
|
const { projectPath } = parseGitLabProject(repoUrl);
|
|
602
1020
|
const response = await fetch(`https://api.github.com/repos/${projectPath}/pulls/${mrIid}`, {
|
|
603
|
-
headers: {
|
|
1021
|
+
headers: { Authorization: `Bearer ${pat}`, Accept: 'application/vnd.github.v3+json' },
|
|
604
1022
|
});
|
|
605
1023
|
if (!response.ok) return null;
|
|
606
1024
|
const data = await response.json();
|
|
@@ -623,21 +1041,21 @@ async function postNoteToGitLab(repo: any, mrIid: number, body: string): Promise
|
|
|
623
1041
|
const repoUrl = repo.get('repoUrl') as string;
|
|
624
1042
|
const pat = repo.get('pat') as string;
|
|
625
1043
|
const isGitHub = repoUrl.includes('github.com');
|
|
626
|
-
|
|
1044
|
+
|
|
627
1045
|
if (isGitHub) {
|
|
628
1046
|
if (!pat) throw new Error('Repository has no PAT configured');
|
|
629
1047
|
const { projectPath } = parseGitLabProject(repoUrl);
|
|
630
|
-
|
|
1048
|
+
|
|
631
1049
|
const response = await fetch(`https://api.github.com/repos/${projectPath}/issues/${mrIid}/comments`, {
|
|
632
1050
|
method: 'POST',
|
|
633
1051
|
headers: {
|
|
634
|
-
|
|
1052
|
+
Authorization: `Bearer ${pat}`,
|
|
635
1053
|
'Content-Type': 'application/json',
|
|
636
|
-
|
|
1054
|
+
Accept: 'application/vnd.github.v3+json',
|
|
637
1055
|
},
|
|
638
1056
|
body: JSON.stringify({ body }),
|
|
639
1057
|
});
|
|
640
|
-
|
|
1058
|
+
|
|
641
1059
|
if (!response.ok) {
|
|
642
1060
|
const text = await response.text().catch(() => '');
|
|
643
1061
|
throw new Error(`GitHub note post failed ${response.status}: ${text}`);
|
|
@@ -654,7 +1072,7 @@ async function postNoteToGitLab(repo: any, mrIid: number, body: string): Promise
|
|
|
654
1072
|
headers: {
|
|
655
1073
|
'PRIVATE-TOKEN': pat,
|
|
656
1074
|
'Content-Type': 'application/json',
|
|
657
|
-
|
|
1075
|
+
Accept: 'application/json',
|
|
658
1076
|
},
|
|
659
1077
|
body: JSON.stringify({ body }),
|
|
660
1078
|
});
|
|
@@ -682,11 +1100,37 @@ async function postNoteToGitLab(repo: any, mrIid: number, body: string): Promise
|
|
|
682
1100
|
const MAX_BRANCH_FILTER_LENGTH = 200;
|
|
683
1101
|
const loggedBadFilters = new Set<string>();
|
|
684
1102
|
|
|
1103
|
+
type ReviewPostMode = 'auto' | 'manual' | 'disabled';
|
|
1104
|
+
|
|
1105
|
+
function getFlowPostMode(flow: any): ReviewPostMode {
|
|
1106
|
+
const rawValue = flow?.get?.('postMode');
|
|
1107
|
+
const value = rawValue && typeof rawValue === 'object' && 'value' in rawValue ? rawValue.value : rawValue;
|
|
1108
|
+
const normalized = String(value || 'manual')
|
|
1109
|
+
.trim()
|
|
1110
|
+
.toLowerCase()
|
|
1111
|
+
.replace(/[\s-]+/g, '_');
|
|
1112
|
+
|
|
1113
|
+
if (['auto', 'auto_post', 'autopost', 'auto_post_to_mr'].includes(normalized)) return 'auto';
|
|
1114
|
+
if (['disabled', 'disable', 'do_not_post', 'dont_post', 'none', 'skip', 'skipped'].includes(normalized)) {
|
|
1115
|
+
return 'disabled';
|
|
1116
|
+
}
|
|
1117
|
+
return 'manual';
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function getInitialPostStatus(flow: any, targetType: string): string {
|
|
1121
|
+
const postMode = getFlowPostMode(flow);
|
|
1122
|
+
if (postMode === 'disabled') return 'skipped';
|
|
1123
|
+
if (postMode === 'auto' && targetType !== 'mr') return 'skipped';
|
|
1124
|
+
return 'pending_approval';
|
|
1125
|
+
}
|
|
1126
|
+
|
|
685
1127
|
function warnInvalidBranchFilter(filter: string, reason: string) {
|
|
686
1128
|
if (loggedBadFilters.has(filter)) return;
|
|
687
1129
|
loggedBadFilters.add(filter);
|
|
688
1130
|
console.warn(
|
|
689
|
-
`[plugin-git-manager] branchFilter rejected (${reason}): ${JSON.stringify(
|
|
1131
|
+
`[plugin-git-manager] branchFilter rejected (${reason}): ${JSON.stringify(
|
|
1132
|
+
filter,
|
|
1133
|
+
)}. Flow will not match any branch.`,
|
|
690
1134
|
);
|
|
691
1135
|
}
|
|
692
1136
|
|
|
@@ -721,11 +1165,10 @@ function throwHttp(status: number, message: string): never {
|
|
|
721
1165
|
}
|
|
722
1166
|
|
|
723
1167
|
/**
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
728
|
-
* timeout (5 min) plus a safety margin and mark it as failed.
|
|
1168
|
+
* Review execution is delegated to worker queue processing. When a worker
|
|
1169
|
+
* restarts mid-run, the DB record can stay in `status='running'` indefinitely.
|
|
1170
|
+
* On startup we sweep any `running` review whose `startedAt` is older than the
|
|
1171
|
+
* in-process timeout (5 min) plus a safety margin and mark it as failed.
|
|
729
1172
|
*
|
|
730
1173
|
* The cutoff is intentionally larger than the runtime timeout so concurrent
|
|
731
1174
|
* reviews running on a *different* node in an HA cluster aren't clobbered.
|
|
@@ -736,13 +1179,11 @@ export async function recoverStuckReviews(app: Application): Promise<number> {
|
|
|
736
1179
|
try {
|
|
737
1180
|
const reviewsRepo = app.db.getRepository('gitCodeReviews');
|
|
738
1181
|
const cutoff = new Date(Date.now() - STUCK_REVIEW_CUTOFF_MS);
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
// Both have `startedAt` stamped at trigger time so the cutoff applies
|
|
742
|
-
// consistently.
|
|
1182
|
+
// Pending rows are the durable queue source of truth and are picked up by
|
|
1183
|
+
// worker DB polling, so only sweep reviews that were actually claimed.
|
|
743
1184
|
const stuck = await reviewsRepo.find({
|
|
744
1185
|
filter: {
|
|
745
|
-
status:
|
|
1186
|
+
status: 'running',
|
|
746
1187
|
startedAt: { $lt: cutoff },
|
|
747
1188
|
},
|
|
748
1189
|
});
|