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.
Files changed (38) hide show
  1. package/dist/client/187.d5545b7cc8b90bfc.js +1 -0
  2. package/dist/client/228.7588a0707cb3694a.js +0 -9
  3. package/dist/client/index.js +1 -10
  4. package/dist/externalVersion.js +5 -14
  5. package/dist/index.js +0 -9
  6. package/dist/locale/en-US.json +2 -0
  7. package/dist/locale/vi-VN.json +2 -0
  8. package/dist/server/actions/git-actions.js +15 -21
  9. package/dist/server/actions/gitlab-api.js +0 -9
  10. package/dist/server/actions/poller.js +0 -9
  11. package/dist/server/actions/review.d.ts +5 -2
  12. package/dist/server/actions/review.js +430 -60
  13. package/dist/server/ai-tools.js +0 -9
  14. package/dist/server/collections/gitCodeReviews.d.ts +1 -1
  15. package/dist/server/collections/gitCodeReviews.js +1 -9
  16. package/dist/server/collections/gitRepositories.d.ts +1 -1
  17. package/dist/server/collections/gitRepositories.js +0 -9
  18. package/dist/server/collections/gitReviewFlows.d.ts +1 -1
  19. package/dist/server/collections/gitReviewFlows.js +0 -9
  20. package/dist/server/index.js +0 -9
  21. package/dist/server/migrations/20260508000000-add-auto-review-flow-id.js +0 -9
  22. package/dist/server/plugin.js +5 -9
  23. package/dist/server/poller.js +0 -9
  24. package/dist/server/utils/gitlab-url.js +0 -9
  25. package/dist/server/utils/redact.js +0 -9
  26. package/package.json +2 -2
  27. package/src/client/components/CommitHistory.tsx +3 -0
  28. package/src/client/components/FileExplorer.tsx +29 -24
  29. package/src/client/components/GitOperations.tsx +3 -0
  30. package/src/client/components/ReviewFlows.tsx +11 -1
  31. package/src/client/components/ReviewHistory.tsx +14 -1
  32. package/src/locale/en-US.json +2 -0
  33. package/src/locale/vi-VN.json +2 -0
  34. package/src/server/actions/git-actions.ts +15 -12
  35. package/src/server/actions/review.ts +504 -63
  36. package/src/server/collections/gitCodeReviews.ts +1 -0
  37. package/src/server/plugin.ts +25 -20
  38. 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 + setImmediate (a few ms), so a
33
- * 30s TTL is generous and auto-releases if the process crashes.
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 AIEmployee.invoke runs in
51
- * the background. The action returns immediately with the reviewId.
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
- // Stamp startedAt synchronously so `recoverStuckReviews` can sweep rows
166
- // that get stuck in `pending` (process died before runReview ran).
167
- // runReview's own update will refresh this on actual start.
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.get('postMode') === 'disabled' ? 'skipped' : 'pending_approval',
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
- // Run in background do not await
197
- setImmediate(() =>
198
- runReview(app, {
199
- reviewId,
200
- flow,
201
- repo,
202
- targetType: args.targetType,
203
- mrIid: args.targetType === 'mr' ? args.mrIid! : null,
204
- commitSha: args.targetType === 'commit' ? args.commitSha! : null,
205
- branch: args.branch || undefined,
206
- headSha,
207
- aiEmployeeUsername,
208
- extraInstructions: args.extraInstructions,
209
- userId: args.userId ?? null,
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) throw new Error('AIEmployee class not found — plugin-ai may not be installed or its exports changed');
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>((_, reject) =>
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.get('postMode') as string;
436
- let postStatus = 'pending_approval';
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(`Use the \`git_get_merge_request\` tool with repositoryId=${args.repo.get('id')} and mrIid=${args.mrIid} to fetch the diff and metadata.`);
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(`Use the \`git_get_commit\` tool with repositoryId=${args.repo.get('id')} and commitHash=${args.commitSha} to fetch the diff.`);
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(`Use \`git_list_commits\`, \`git_get_diff\`, and \`git_get_file_content\` (with repositoryId=${args.repo.get('id')}) to inspect recent changes on this branch.`);
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('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.');
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: { 'Authorization': `Bearer ${pat}`, Accept: 'application/vnd.github.v3+json' },
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
- 'Authorization': `Bearer ${pat}`,
1052
+ Authorization: `Bearer ${pat}`,
635
1053
  'Content-Type': 'application/json',
636
- 'Accept': 'application/vnd.github.v3+json',
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
- 'Accept': 'application/json',
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(filter)}. Flow will not match any branch.`,
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
- * Reviews are launched via `setImmediate` and tracked entirely in process
725
- * memory. When the app restarts mid-run, the in-memory promise dies but the
726
- * DB record stays in `status='running'` indefinitely. On startup we sweep
727
- * any `running` review whose `startedAt` is older than the in-process
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
- // Sweep both `running` (interrupted mid-execution) and `pending`
740
- // (interrupted between record creation and runReview's first update).
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: { $in: ['running', 'pending'] },
1186
+ status: 'running',
746
1187
  startedAt: { $lt: cutoff },
747
1188
  },
748
1189
  });