plugin-git-manager 1.1.12 → 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 +0 -9
- package/dist/client/228.7588a0707cb3694a.js +0 -9
- package/dist/client/index.js +0 -9
- package/dist/externalVersion.js +5 -14
- package/dist/index.js +0 -9
- package/dist/server/actions/git-actions.js +0 -9
- package/dist/server/actions/gitlab-api.js +0 -9
- package/dist/server/actions/poller.js +0 -9
- package/dist/server/actions/review.js +302 -75
- package/dist/server/ai-tools.js +0 -9
- package/dist/server/collections/gitCodeReviews.js +0 -9
- package/dist/server/collections/gitRepositories.js +0 -9
- 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 +0 -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/server/actions/review.ts +332 -72
|
@@ -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
|
|
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
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
const
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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)
|
|
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>((
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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: {
|
|
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
|
-
|
|
1052
|
+
Authorization: `Bearer ${pat}`,
|
|
795
1053
|
'Content-Type': 'application/json',
|
|
796
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
923
|
-
//
|
|
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',
|