plugin-git-manager 1.1.12 → 1.2.1

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.
@@ -13,6 +13,13 @@ const REVIEW_QUEUE_TIMEOUT_MS = Math.max(
13
13
  60_000,
14
14
  Number.parseInt(process.env.GIT_REVIEW_QUEUE_TIMEOUT_MS || '', 10) || 10 * 60 * 1000,
15
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';
16
23
 
17
24
  interface TriggerArgs {
18
25
  flowId?: number | null;
@@ -50,6 +57,19 @@ interface ReviewQueueMessage {
50
57
  flowSnapshot?: ReviewFlowSnapshot;
51
58
  }
52
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
+
53
73
  function getActionParams(ctx: Context) {
54
74
  return { ...ctx.action.params, ...ctx.action.params?.values, ...((ctx as any).request?.body || {}) };
55
75
  }
@@ -86,15 +106,7 @@ async function withTriggerLock<T>(app: Application, key: string, fn: () => Promi
86
106
  */
87
107
  export async function triggerReview(ctx: Context, next: () => Promise<void>) {
88
108
  const params = getActionParams(ctx);
89
- const {
90
- flowId,
91
- repositoryId,
92
- targetType,
93
- mrIid,
94
- commitSha,
95
- branch,
96
- extraInstructions,
97
- } = params;
109
+ const { flowId, repositoryId, targetType, mrIid, commitSha, branch, extraInstructions } = params;
98
110
 
99
111
  if (!repositoryId) ctx.throw(400, 'repositoryId is required');
100
112
  if (!targetType) ctx.throw(400, 'targetType is required');
@@ -197,12 +209,19 @@ async function triggerReviewInternalLocked(app: Application, args: TriggerArgs):
197
209
  triggeredBy: args.triggeredBy || 'manual',
198
210
  status: 'pending',
199
211
  // `startedAt` is stamped by the queue worker when execution actually
200
- // starts. Pending rows may be legitimately waiting in Redis.
212
+ // starts. Pending rows are durable queue items for worker polling.
201
213
  startedAt: null,
202
214
  finishedAt: null,
203
215
  durationMs: null,
204
216
  postStatus: getInitialPostStatus(flow, args.targetType),
205
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
+ },
206
225
  };
207
226
 
208
227
  let reviewId: number;
@@ -252,10 +271,15 @@ export function registerReviewQueue(app: Application) {
252
271
  await processQueuedReview(app, message);
253
272
  },
254
273
  });
274
+ if (!isGitReviewWorker(app)) {
275
+ app.on('afterStart', () => clearLocalReviewMemoryQueue(app));
276
+ }
277
+ startReviewQueueProcessor(app);
255
278
  }
256
279
 
257
280
  export function unregisterReviewQueue(app: Application) {
258
281
  app.eventQueue.unsubscribe(REVIEW_QUEUE_CHANNEL);
282
+ stopReviewQueueProcessor(app);
259
283
  }
260
284
 
261
285
  function createFlowSnapshot(flow: any): ReviewFlowSnapshot {
@@ -290,12 +314,224 @@ function isGitReviewWorker(app: Application): boolean {
290
314
  );
291
315
  }
292
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
+
293
514
  async function enqueueReview(app: Application, message: ReviewQueueMessage) {
294
515
  try {
295
- await app.eventQueue.publish(REVIEW_QUEUE_CHANNEL, message, {
296
- timeout: REVIEW_QUEUE_TIMEOUT_MS,
297
- maxRetries: 1,
298
- });
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
+ );
299
535
  } catch (err: any) {
300
536
  const safeMessage = redactPat(err?.message || String(err));
301
537
  await app.db.getRepository('gitCodeReviews').update({
@@ -323,49 +559,56 @@ async function failQueuedReview(app: Application, reviewId: number, err: any) {
323
559
  }
324
560
 
325
561
  async function processQueuedReview(app: Application, message: ReviewQueueMessage) {
326
- const db = app.db;
327
- const reviewsRepo = db.getRepository('gitCodeReviews');
328
- const review = await reviewsRepo.findOne({ filterByTk: message.reviewId });
329
- if (!review) {
330
- app.log?.warn?.(`git review queue: review ${message.reviewId} not found, skipping`);
331
- return;
332
- }
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
+ }
333
570
 
334
- if (review.get('status') !== 'pending') {
335
- app.log?.info?.(`git review queue: review ${message.reviewId} is ${review.get('status')}, skipping`);
336
- return;
337
- }
571
+ if (review.get('status') !== 'pending') {
572
+ app.log?.info?.(`git review queue: review ${message.reviewId} is ${review.get('status')}, skipping`);
573
+ return;
574
+ }
338
575
 
339
- try {
340
- const repo = await db.getRepository('gitRepositories').findOne({
341
- filterByTk: message.repositoryId || review.get('repositoryId'),
342
- });
343
- if (!repo) throw new Error('Repository not found');
576
+ const metadata = getQueuedReviewMetadata(review);
577
+ const targetType = (message.targetType || review.get('targetType')) as 'mr' | 'commit' | 'branch';
344
578
 
345
- const storedFlow = await db.getRepository('gitReviewFlows').findOne({
346
- filterByTk: message.flowSnapshot?.id || review.get('flowId'),
347
- });
348
- const flow = createFlowFromSnapshot(message.flowSnapshot, storedFlow);
349
- const aiEmployeeUsername = message.aiEmployeeUsername || (flow.get('aiEmployeeUsername') as string);
350
- if (!aiEmployeeUsername) throw new Error('Flow has no AI employee configured');
351
-
352
- await runReview(app, {
353
- reviewId: message.reviewId,
354
- flow,
355
- repo,
356
- targetType: message.targetType || review.get('targetType'),
357
- mrIid: message.targetType === 'mr' ? message.mrIid ?? Number(review.get('mrIid')) : null,
358
- commitSha: message.targetType === 'commit' ? message.commitSha || (review.get('commitSha') as string) : null,
359
- branch: message.branch || (review.get('branch') as string | undefined),
360
- headSha: message.headSha || (review.get('headSha') as string | null),
361
- aiEmployeeUsername,
362
- extraInstructions: message.extraInstructions,
363
- userId: message.userId ?? null,
364
- });
365
- } catch (err) {
366
- app.log?.error?.('git review queue: failed before review execution', err);
367
- await failQueuedReview(app, message.reviewId, err);
368
- }
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
+ });
369
612
  }
370
613
 
371
614
  /**
@@ -545,7 +788,8 @@ async function runReview(app: Application, args: RunReviewArgs) {
545
788
  // try next path
546
789
  }
547
790
  }
548
- 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');
549
793
 
550
794
  const llmService = args.flow.get('llmService') as string | null;
551
795
  const model = args.flow.get('model') as string | null;
@@ -573,7 +817,7 @@ async function runReview(app: Application, args: RunReviewArgs) {
573
817
  },
574
818
  ],
575
819
  }),
576
- new Promise<never>((_, reject) =>
820
+ new Promise<never>((_resolve, reject) =>
577
821
  setTimeout(() => reject(new Error('AI review timed out after 5 minutes')), REVIEW_TIMEOUT_MS),
578
822
  ),
579
823
  ]);
@@ -664,20 +908,34 @@ function buildReviewPrompt(args: RunReviewArgs): string {
664
908
 
665
909
  if (args.targetType === 'mr') {
666
910
  lines.push(`Target: Merge Request !${args.mrIid}.`);
667
- 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
+ );
668
916
  lines.push('Optionally call `git_get_merge_request_notes` to avoid duplicating prior comments.');
669
917
  } else if (args.targetType === 'commit') {
670
918
  lines.push(`Target: Commit ${args.commitSha}.`);
671
- 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
+ );
672
924
  } else {
673
925
  lines.push(`Target: Branch ${args.branch}.`);
674
- 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
+ );
675
931
  }
676
932
 
677
933
  lines.push('');
678
934
  lines.push('Produce a thorough but concise code review report in Markdown. Required sections:');
679
935
  lines.push('1. **Summary** — overall assessment.');
680
- 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
+ );
681
939
  lines.push('3. **Suggestions** — non-blocking improvements.');
682
940
  lines.push('4. **Verdict** — one of: `LGTM`, `Approve with comments`, `Request changes`, `Block`.');
683
941
  lines.push('');
@@ -755,12 +1013,12 @@ async function fetchMrHeadSha(repo: any, mrIid: number): Promise<string | null>
755
1013
  const pat = repo.get('pat') as string;
756
1014
  if (!pat) return null;
757
1015
  const isGitHub = repoUrl.includes('github.com');
758
-
1016
+
759
1017
  try {
760
1018
  if (isGitHub) {
761
1019
  const { projectPath } = parseGitLabProject(repoUrl);
762
1020
  const response = await fetch(`https://api.github.com/repos/${projectPath}/pulls/${mrIid}`, {
763
- headers: { 'Authorization': `Bearer ${pat}`, Accept: 'application/vnd.github.v3+json' },
1021
+ headers: { Authorization: `Bearer ${pat}`, Accept: 'application/vnd.github.v3+json' },
764
1022
  });
765
1023
  if (!response.ok) return null;
766
1024
  const data = await response.json();
@@ -783,21 +1041,21 @@ async function postNoteToGitLab(repo: any, mrIid: number, body: string): Promise
783
1041
  const repoUrl = repo.get('repoUrl') as string;
784
1042
  const pat = repo.get('pat') as string;
785
1043
  const isGitHub = repoUrl.includes('github.com');
786
-
1044
+
787
1045
  if (isGitHub) {
788
1046
  if (!pat) throw new Error('Repository has no PAT configured');
789
1047
  const { projectPath } = parseGitLabProject(repoUrl);
790
-
1048
+
791
1049
  const response = await fetch(`https://api.github.com/repos/${projectPath}/issues/${mrIid}/comments`, {
792
1050
  method: 'POST',
793
1051
  headers: {
794
- 'Authorization': `Bearer ${pat}`,
1052
+ Authorization: `Bearer ${pat}`,
795
1053
  'Content-Type': 'application/json',
796
- 'Accept': 'application/vnd.github.v3+json',
1054
+ Accept: 'application/vnd.github.v3+json',
797
1055
  },
798
1056
  body: JSON.stringify({ body }),
799
1057
  });
800
-
1058
+
801
1059
  if (!response.ok) {
802
1060
  const text = await response.text().catch(() => '');
803
1061
  throw new Error(`GitHub note post failed ${response.status}: ${text}`);
@@ -814,7 +1072,7 @@ async function postNoteToGitLab(repo: any, mrIid: number, body: string): Promise
814
1072
  headers: {
815
1073
  'PRIVATE-TOKEN': pat,
816
1074
  'Content-Type': 'application/json',
817
- 'Accept': 'application/json',
1075
+ Accept: 'application/json',
818
1076
  },
819
1077
  body: JSON.stringify({ body }),
820
1078
  });
@@ -870,7 +1128,9 @@ function warnInvalidBranchFilter(filter: string, reason: string) {
870
1128
  if (loggedBadFilters.has(filter)) return;
871
1129
  loggedBadFilters.add(filter);
872
1130
  console.warn(
873
- `[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.`,
874
1134
  );
875
1135
  }
876
1136
 
@@ -905,7 +1165,7 @@ function throwHttp(status: number, message: string): never {
905
1165
  }
906
1166
 
907
1167
  /**
908
- * Review execution is delegated to the distributed event queue. When a worker
1168
+ * Review execution is delegated to worker queue processing. When a worker
909
1169
  * restarts mid-run, the DB record can stay in `status='running'` indefinitely.
910
1170
  * On startup we sweep any `running` review whose `startedAt` is older than the
911
1171
  * in-process timeout (5 min) plus a safety margin and mark it as failed.
@@ -919,8 +1179,8 @@ export async function recoverStuckReviews(app: Application): Promise<number> {
919
1179
  try {
920
1180
  const reviewsRepo = app.db.getRepository('gitCodeReviews');
921
1181
  const cutoff = new Date(Date.now() - STUCK_REVIEW_CUTOFF_MS);
922
- // Pending rows may be legitimately waiting in Redis, so only sweep reviews
923
- // that were actually picked up by a worker.
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.
924
1184
  const stuck = await reviewsRepo.find({
925
1185
  filter: {
926
1186
  status: 'running',