plugin-git-manager 1.1.10 → 1.1.12

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.
@@ -3,6 +3,17 @@ 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
+
6
17
  interface TriggerArgs {
7
18
  flowId?: number | null;
8
19
  repositoryId: number;
@@ -16,6 +27,29 @@ interface TriggerArgs {
16
27
  userId?: number | string | null;
17
28
  }
18
29
 
30
+ interface ReviewFlowSnapshot {
31
+ id: number;
32
+ name?: string;
33
+ postMode?: string;
34
+ llmService?: string | null;
35
+ model?: string | null;
36
+ instructions?: string | null;
37
+ }
38
+
39
+ interface ReviewQueueMessage {
40
+ reviewId: number;
41
+ repositoryId: number;
42
+ targetType: 'mr' | 'commit' | 'branch';
43
+ mrIid?: number | null;
44
+ commitSha?: string | null;
45
+ branch?: string | null;
46
+ headSha?: string | null;
47
+ aiEmployeeUsername: string;
48
+ extraInstructions?: string;
49
+ userId?: number | string | null;
50
+ flowSnapshot?: ReviewFlowSnapshot;
51
+ }
52
+
19
53
  function getActionParams(ctx: Context) {
20
54
  return { ...ctx.action.params, ...ctx.action.params?.values, ...((ctx as any).request?.body || {}) };
21
55
  }
@@ -29,8 +63,8 @@ function getActionParams(ctx: Context) {
29
63
  * Uses `app.lockManager` so the same code path covers both single-node
30
64
  * (in-memory `async-mutex`) and HA cluster (Redis-backed Redlock when
31
65
  * `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.
66
+ * The locked region only does an upsert + queue publish (a few ms), so a 30s
67
+ * TTL is generous and auto-releases if the process crashes.
34
68
  */
35
69
  function targetKey(args: TriggerArgs): string {
36
70
  if (args.targetType === 'mr') return `${args.repositoryId}:mr:${args.mrIid}`;
@@ -47,8 +81,8 @@ async function withTriggerLock<T>(app: Application, key: string, fn: () => Promi
47
81
 
48
82
  /**
49
83
  * 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.
84
+ * The review record is upserted synchronously, then queued for an available
85
+ * git-review worker. The action returns immediately with the reviewId.
52
86
  */
53
87
  export async function triggerReview(ctx: Context, next: () => Promise<void>) {
54
88
  const params = getActionParams(ctx);
@@ -162,13 +196,12 @@ async function triggerReviewInternalLocked(app: Application, args: TriggerArgs):
162
196
  latestSha: headSha || existingLatestSha || null,
163
197
  triggeredBy: args.triggeredBy || 'manual',
164
198
  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(),
199
+ // `startedAt` is stamped by the queue worker when execution actually
200
+ // starts. Pending rows may be legitimately waiting in Redis.
201
+ startedAt: null,
169
202
  finishedAt: null,
170
203
  durationMs: null,
171
- postStatus: flow.get('postMode') === 'disabled' ? 'skipped' : 'pending_approval',
204
+ postStatus: getInitialPostStatus(flow, args.targetType),
172
205
  error: null,
173
206
  };
174
207
 
@@ -193,26 +226,146 @@ async function triggerReviewInternalLocked(app: Application, args: TriggerArgs):
193
226
  reviewId = review.get('id') as number;
194
227
  }
195
228
 
196
- // Run in background do not await
197
- setImmediate(() =>
198
- runReview(app, {
199
- reviewId,
229
+ // Queue for background workers; the action returns as soon as the message is published.
230
+ await enqueueReview(app, {
231
+ reviewId,
232
+ repositoryId: args.repositoryId,
233
+ targetType: args.targetType,
234
+ mrIid: args.targetType === 'mr' ? args.mrIid : null,
235
+ commitSha: args.targetType === 'commit' ? args.commitSha : null,
236
+ branch: args.branch || undefined,
237
+ headSha,
238
+ aiEmployeeUsername,
239
+ extraInstructions: args.extraInstructions,
240
+ userId: args.userId ?? null,
241
+ flowSnapshot: createFlowSnapshot(flow),
242
+ });
243
+
244
+ return reviewId;
245
+ }
246
+
247
+ export function registerReviewQueue(app: Application) {
248
+ app.eventQueue.subscribe(REVIEW_QUEUE_CHANNEL, {
249
+ concurrency: REVIEW_QUEUE_CONCURRENCY,
250
+ idle: () => isGitReviewWorker(app),
251
+ process: async (message: ReviewQueueMessage) => {
252
+ await processQueuedReview(app, message);
253
+ },
254
+ });
255
+ }
256
+
257
+ export function unregisterReviewQueue(app: Application) {
258
+ app.eventQueue.unsubscribe(REVIEW_QUEUE_CHANNEL);
259
+ }
260
+
261
+ function createFlowSnapshot(flow: any): ReviewFlowSnapshot {
262
+ return {
263
+ id: Number(flow.get('id')),
264
+ name: flow.get('name') as string,
265
+ postMode: flow.get('postMode') as string,
266
+ llmService: flow.get('llmService') as string | null,
267
+ model: flow.get('model') as string | null,
268
+ instructions: flow.get('instructions') as string | null,
269
+ };
270
+ }
271
+
272
+ function createFlowFromSnapshot(snapshot: ReviewFlowSnapshot | undefined, fallback?: any) {
273
+ return {
274
+ get(name: string) {
275
+ if (snapshot && Object.prototype.hasOwnProperty.call(snapshot, name)) {
276
+ return (snapshot as any)[name];
277
+ }
278
+ return fallback?.get?.(name);
279
+ },
280
+ };
281
+ }
282
+
283
+ function isGitReviewWorker(app: Application): boolean {
284
+ const workerMode = process.env.WORKER_MODE || '';
285
+ return (
286
+ app.serving(WORKER_JOB_GIT_REVIEW_PROCESS) ||
287
+ workerMode === 'worker' ||
288
+ workerMode === 'task' ||
289
+ process.env.APP_ROLE === 'worker'
290
+ );
291
+ }
292
+
293
+ async function enqueueReview(app: Application, message: ReviewQueueMessage) {
294
+ try {
295
+ await app.eventQueue.publish(REVIEW_QUEUE_CHANNEL, message, {
296
+ timeout: REVIEW_QUEUE_TIMEOUT_MS,
297
+ maxRetries: 1,
298
+ });
299
+ } catch (err: any) {
300
+ const safeMessage = redactPat(err?.message || String(err));
301
+ await app.db.getRepository('gitCodeReviews').update({
302
+ filterByTk: message.reviewId,
303
+ values: {
304
+ status: 'failed',
305
+ error: `Failed to enqueue review: ${safeMessage}`,
306
+ finishedAt: new Date(),
307
+ },
308
+ });
309
+ throw err;
310
+ }
311
+ }
312
+
313
+ async function failQueuedReview(app: Application, reviewId: number, err: any) {
314
+ const safeMessage = redactPat(err?.message || String(err));
315
+ await app.db.getRepository('gitCodeReviews').update({
316
+ filterByTk: reviewId,
317
+ values: {
318
+ status: 'failed',
319
+ error: safeMessage,
320
+ finishedAt: new Date(),
321
+ },
322
+ });
323
+ }
324
+
325
+ 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
+ }
333
+
334
+ if (review.get('status') !== 'pending') {
335
+ app.log?.info?.(`git review queue: review ${message.reviewId} is ${review.get('status')}, skipping`);
336
+ return;
337
+ }
338
+
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');
344
+
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,
200
354
  flow,
201
355
  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,
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),
207
361
  aiEmployeeUsername,
208
- extraInstructions: args.extraInstructions,
209
- userId: args.userId ?? null,
210
- }).catch((err) => {
211
- app.log?.error?.('runReview background error', err);
212
- }),
213
- );
214
-
215
- return reviewId;
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
+ }
216
369
  }
217
370
 
218
371
  /**
@@ -248,6 +401,7 @@ export async function reviewApprovePost(ctx: Context, next: () => Promise<void>)
248
401
  postedNoteId: String(noteId),
249
402
  approvedBy: userId ? String(userId) : null,
250
403
  approvedAt: new Date(),
404
+ error: null,
251
405
  },
252
406
  });
253
407
 
@@ -432,9 +586,10 @@ async function runReview(app: Application, args: RunReviewArgs) {
432
586
  const finishedAt = new Date();
433
587
  const durationMs = finishedAt.getTime() - startedAt.getTime();
434
588
 
435
- const postMode = args.flow.get('postMode') as string;
436
- let postStatus = 'pending_approval';
589
+ const postMode = getFlowPostMode(args.flow);
590
+ let postStatus = getInitialPostStatus(args.flow, args.targetType);
437
591
  let postedNoteId: string | null = null;
592
+ let autoPostError: string | null = null;
438
593
 
439
594
  if (postMode === 'disabled') {
440
595
  postStatus = 'skipped';
@@ -443,6 +598,8 @@ async function runReview(app: Application, args: RunReviewArgs) {
443
598
  postedNoteId = String(await postNoteToGitLab(args.repo, args.mrIid, content));
444
599
  postStatus = 'posted';
445
600
  } catch (err: any) {
601
+ autoPostError = redactPat(err?.message || String(err));
602
+ postStatus = 'post_failed';
446
603
  app.log?.error?.('Auto-post review note failed', err);
447
604
  }
448
605
  }
@@ -461,11 +618,14 @@ async function runReview(app: Application, args: RunReviewArgs) {
461
618
  finishedAt,
462
619
  postStatus,
463
620
  postedNoteId,
621
+ error: autoPostError ? `Auto-post failed: ${autoPostError}` : null,
464
622
  metadata: {
465
623
  flowName: args.flow.get('name'),
466
624
  aiEmployeeUsername: args.aiEmployeeUsername,
467
625
  llmService,
468
626
  model,
627
+ postMode,
628
+ autoPostError,
469
629
  },
470
630
  },
471
631
  });
@@ -682,6 +842,30 @@ async function postNoteToGitLab(repo: any, mrIid: number, body: string): Promise
682
842
  const MAX_BRANCH_FILTER_LENGTH = 200;
683
843
  const loggedBadFilters = new Set<string>();
684
844
 
845
+ type ReviewPostMode = 'auto' | 'manual' | 'disabled';
846
+
847
+ function getFlowPostMode(flow: any): ReviewPostMode {
848
+ const rawValue = flow?.get?.('postMode');
849
+ const value = rawValue && typeof rawValue === 'object' && 'value' in rawValue ? rawValue.value : rawValue;
850
+ const normalized = String(value || 'manual')
851
+ .trim()
852
+ .toLowerCase()
853
+ .replace(/[\s-]+/g, '_');
854
+
855
+ if (['auto', 'auto_post', 'autopost', 'auto_post_to_mr'].includes(normalized)) return 'auto';
856
+ if (['disabled', 'disable', 'do_not_post', 'dont_post', 'none', 'skip', 'skipped'].includes(normalized)) {
857
+ return 'disabled';
858
+ }
859
+ return 'manual';
860
+ }
861
+
862
+ function getInitialPostStatus(flow: any, targetType: string): string {
863
+ const postMode = getFlowPostMode(flow);
864
+ if (postMode === 'disabled') return 'skipped';
865
+ if (postMode === 'auto' && targetType !== 'mr') return 'skipped';
866
+ return 'pending_approval';
867
+ }
868
+
685
869
  function warnInvalidBranchFilter(filter: string, reason: string) {
686
870
  if (loggedBadFilters.has(filter)) return;
687
871
  loggedBadFilters.add(filter);
@@ -721,11 +905,10 @@ function throwHttp(status: number, message: string): never {
721
905
  }
722
906
 
723
907
  /**
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.
908
+ * Review execution is delegated to the distributed event queue. When a worker
909
+ * restarts mid-run, the DB record can stay in `status='running'` indefinitely.
910
+ * On startup we sweep any `running` review whose `startedAt` is older than the
911
+ * in-process timeout (5 min) plus a safety margin and mark it as failed.
729
912
  *
730
913
  * The cutoff is intentionally larger than the runtime timeout so concurrent
731
914
  * reviews running on a *different* node in an HA cluster aren't clobbered.
@@ -736,13 +919,11 @@ export async function recoverStuckReviews(app: Application): Promise<number> {
736
919
  try {
737
920
  const reviewsRepo = app.db.getRepository('gitCodeReviews');
738
921
  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.
922
+ // Pending rows may be legitimately waiting in Redis, so only sweep reviews
923
+ // that were actually picked up by a worker.
743
924
  const stuck = await reviewsRepo.find({
744
925
  filter: {
745
- status: { $in: ['running', 'pending'] },
926
+ status: 'running',
746
927
  startedAt: { $lt: cutoff },
747
928
  },
748
929
  });
@@ -136,6 +136,7 @@ export default defineCollection({
136
136
  { value: 'pending_approval', label: 'Pending Approval' },
137
137
  { value: 'approved', label: 'Approved' },
138
138
  { value: 'posted', label: 'Posted' },
139
+ { value: 'post_failed', label: 'Post Failed' },
139
140
  { value: 'skipped', label: 'Skipped' },
140
141
  { value: 'rejected', label: 'Rejected' },
141
142
  ],
@@ -3,9 +3,9 @@ import { resolve } from 'path';
3
3
  import { DataTypes } from 'sequelize';
4
4
  import * as gitActions from './actions/git-actions';
5
5
  import * as gitlabApi from './actions/gitlab-api';
6
- import * as reviewActions from './actions/review';
7
- import * as pollerActions from './actions/poller';
8
- import { recoverStuckReviews } from './actions/review';
6
+ import * as reviewActions from './actions/review';
7
+ import * as pollerActions from './actions/poller';
8
+ import { recoverStuckReviews, registerReviewQueue, unregisterReviewQueue } from './actions/review';
9
9
  import { registerGitReviewAiTools } from './ai-tools';
10
10
  import { startPoller, stopPoller } from './poller';
11
11
 
@@ -77,9 +77,10 @@ export class PluginGitManagerServer extends Plugin {
77
77
  };
78
78
  }
79
79
  return next();
80
- });
81
-
82
- registerGitReviewAiTools((this as any).app);
80
+ });
81
+
82
+ registerReviewQueue((this as any).app);
83
+ registerGitReviewAiTools((this as any).app);
83
84
 
84
85
  (this as any).app.on('afterStart', async () => {
85
86
  await ensureAutoReviewFlowSchema((this as any).app).catch(
@@ -91,12 +92,14 @@ export class PluginGitManagerServer extends Plugin {
91
92
  );
92
93
  startPoller((this as any).app);
93
94
  });
94
- (this as any).app.on('beforeStop', () => {
95
- stopPoller();
96
- });
97
- (this as any).app.on('beforeDestroy', () => {
98
- stopPoller();
99
- });
95
+ (this as any).app.on('beforeStop', () => {
96
+ unregisterReviewQueue((this as any).app);
97
+ stopPoller();
98
+ });
99
+ (this as any).app.on('beforeDestroy', () => {
100
+ unregisterReviewQueue((this as any).app);
101
+ stopPoller();
102
+ });
100
103
 
101
104
  // Read-only operations available to all plugin users
102
105
  (this as any).app.acl.registerSnippet({
@@ -188,14 +191,16 @@ export class PluginGitManagerServer extends Plugin {
188
191
  await (this as any).app.db.getCollection('gitRepositories')?.sync();
189
192
  }
190
193
 
191
- async beforeDisable() {
192
- stopPoller();
193
- }
194
-
195
- async beforeUnload() {
196
- stopPoller();
197
- }
198
- }
194
+ async beforeDisable() {
195
+ unregisterReviewQueue((this as any).app);
196
+ stopPoller();
197
+ }
198
+
199
+ async beforeUnload() {
200
+ unregisterReviewQueue((this as any).app);
201
+ stopPoller();
202
+ }
203
+ }
199
204
 
200
205
  export async function ensureAutoReviewFlowSchema(app: any) {
201
206
  const sequelize = app.db?.sequelize;