plugin-git-manager 1.1.9 → 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.
Files changed (42) hide show
  1. package/dist/client/187.d5545b7cc8b90bfc.js +10 -0
  2. package/dist/client/components/RunReviewButton.d.ts +1 -1
  3. package/dist/client/context/GitManagerContext.d.ts +2 -0
  4. package/dist/client/index.js +1 -1
  5. package/dist/externalVersion.js +6 -4
  6. package/dist/locale/en-US.json +10 -1
  7. package/dist/locale/vi-VN.json +2 -0
  8. package/dist/server/actions/git-actions.js +15 -12
  9. package/dist/server/actions/gitlab-api.js +14 -13
  10. package/dist/server/actions/review.d.ts +5 -2
  11. package/dist/server/actions/review.js +184 -37
  12. package/dist/server/ai-tools.js +2 -0
  13. package/dist/server/collections/gitCodeReviews.js +1 -0
  14. package/dist/server/collections/gitRepositories.js +12 -0
  15. package/dist/server/migrations/20260508000000-add-auto-review-flow-id.d.ts +6 -0
  16. package/dist/server/migrations/20260508000000-add-auto-review-flow-id.js +57 -0
  17. package/dist/server/plugin.d.ts +4 -0
  18. package/dist/server/plugin.js +43 -6
  19. package/dist/server/poller.js +3 -1
  20. package/package.json +1 -1
  21. package/src/client/components/CommitHistory.tsx +21 -3
  22. package/src/client/components/FileExplorer.tsx +29 -24
  23. package/src/client/components/GitOperations.tsx +32 -16
  24. package/src/client/components/PollingStatus.tsx +27 -1
  25. package/src/client/components/RepositoryConfig.tsx +76 -3
  26. package/src/client/components/ReviewFlows.tsx +11 -1
  27. package/src/client/components/ReviewHistory.tsx +14 -1
  28. package/src/client/components/RunReviewButton.tsx +375 -278
  29. package/src/client/context/GitManagerContext.tsx +2 -0
  30. package/src/client/index.tsx +31 -31
  31. package/src/locale/en-US.json +10 -1
  32. package/src/locale/vi-VN.json +2 -0
  33. package/src/server/actions/git-actions.ts +15 -12
  34. package/src/server/actions/gitlab-api.ts +8 -4
  35. package/src/server/actions/review.ts +226 -41
  36. package/src/server/ai-tools.ts +1 -0
  37. package/src/server/collections/gitCodeReviews.ts +1 -0
  38. package/src/server/collections/gitRepositories.ts +12 -0
  39. package/src/server/migrations/20260508000000-add-auto-review-flow-id.ts +29 -0
  40. package/src/server/plugin.ts +205 -164
  41. package/src/server/poller.ts +11 -2
  42. package/dist/client/187.eec7be93247463d7.js +0 -10
@@ -8,6 +8,8 @@ interface Repository {
8
8
  localPath: string;
9
9
  defaultBranch: string;
10
10
  status: string;
11
+ autoReview?: boolean;
12
+ autoReviewFlowId?: number | null;
11
13
  }
12
14
 
13
15
  interface GitManagerContextType {
@@ -1,31 +1,31 @@
1
- import { Plugin } from '@nocobase/client';
2
- import React from 'react';
3
- import {
4
- GitRepositoryWorkContext,
5
- GitMergeRequestWorkContext,
6
- GitCommitWorkContext,
7
- } from './ai-context';
8
-
9
- const GitManagerSettings = React.lazy(() =>
10
- import('./components/GitManagerSettings').then((m) => ({ default: m.GitManagerSettings })),
11
- );
12
-
13
- export class PluginGitManagerClient extends Plugin {
14
- async load() {
15
- this.app.pluginSettingsManager.add('git-manager', {
16
- title: this.t('Git Manager'),
17
- icon: 'BranchesOutlined',
18
- Component: GitManagerSettings,
19
- aclSnippet: 'pm.plugin-git-manager',
20
- });
21
-
22
- const aiManager = (this.app as any).aiManager;
23
- if (aiManager?.registerWorkContext) {
24
- aiManager.registerWorkContext('git-repository', GitRepositoryWorkContext);
25
- aiManager.registerWorkContext('git-merge-request', GitMergeRequestWorkContext);
26
- aiManager.registerWorkContext('git-commit', GitCommitWorkContext);
27
- }
28
- }
29
- }
30
-
31
- export default PluginGitManagerClient;
1
+ import { Plugin } from '@nocobase/client';
2
+ import React from 'react';
3
+ import {
4
+ GitRepositoryWorkContext,
5
+ GitMergeRequestWorkContext,
6
+ GitCommitWorkContext,
7
+ } from './ai-context';
8
+
9
+ const GitManagerSettings = React.lazy(() =>
10
+ import('./components/GitManagerSettings').then((m) => ({ default: m.GitManagerSettings })),
11
+ );
12
+
13
+ export class PluginGitManagerClient extends Plugin {
14
+ async load() {
15
+ (this as any).app.pluginSettingsManager.add('git-manager', {
16
+ title: (this as any).t('Git Manager'),
17
+ icon: 'BranchesOutlined',
18
+ Component: GitManagerSettings,
19
+ aclSnippet: 'pm.plugin-git-manager',
20
+ });
21
+
22
+ const aiManager = ((this as any).app as any).aiManager;
23
+ if (aiManager?.registerWorkContext) {
24
+ aiManager.registerWorkContext('git-repository', GitRepositoryWorkContext);
25
+ aiManager.registerWorkContext('git-merge-request', GitMergeRequestWorkContext);
26
+ aiManager.registerWorkContext('git-commit', GitCommitWorkContext);
27
+ }
28
+ }
29
+ }
30
+
31
+ export default PluginGitManagerClient;
@@ -125,6 +125,8 @@
125
125
  "Posted": "Posted",
126
126
  "Pending Approval": "Pending Approval",
127
127
  "Approved": "Approved",
128
+ "Post Failed": "Post Failed",
129
+ "post_failed": "Post failed",
128
130
  "Rejected": "Rejected",
129
131
  "Skipped": "Skipped",
130
132
  "Running": "Running",
@@ -177,5 +179,12 @@
177
179
  "new": "new",
178
180
  "Reviewed SHA": "Reviewed SHA",
179
181
  "Latest SHA": "Latest SHA",
180
- "All triggers": "All triggers"
182
+ "All triggers": "All triggers",
183
+ "Search commit title or message": "Search commit title or message",
184
+ "Primary Auto Flow": "Primary Auto Flow",
185
+ "Primary Auto Review Flow": "Primary Auto Review Flow",
186
+ "Only flows with automatic trigger modes are shown": "Only flows with automatic trigger modes are shown",
187
+ "Fallback": "Fallback",
188
+ "AI employee not found": "AI employee not found",
189
+ "Failed to open AI chat": "Failed to open AI chat"
181
190
  }
@@ -125,6 +125,8 @@
125
125
  "Posted": "Đã đăng",
126
126
  "Pending Approval": "Chờ duyệt",
127
127
  "Approved": "Đã duyệt",
128
+ "Post Failed": "Đăng thất bại",
129
+ "post_failed": "Đăng thất bại",
128
130
  "Rejected": "Đã từ chối",
129
131
  "Skipped": "Đã bỏ qua",
130
132
  "Running": "Đang chạy",
@@ -91,7 +91,10 @@ function getAuthUrl(repoUrl: string, pat: string, username?: string): string {
91
91
  return url.toString();
92
92
  }
93
93
 
94
- function getGit(localPath: string): SimpleGit {
94
+ function getGit(ctx: Context, localPath: string): SimpleGit {
95
+ if (!fs.existsSync(localPath)) {
96
+ ctx.throw(400, 'Repository directory does not exist. Please clone the repository first.');
97
+ }
95
98
  return simpleGit(localPath);
96
99
  }
97
100
 
@@ -170,7 +173,7 @@ export async function pull(ctx: Context, next: () => Promise<void>) {
170
173
  const repoUrl = (repo.get('repoUrl') as string || '').trim();
171
174
  const username = (repo.get('username') as string || '').trim();
172
175
 
173
- const git = getGit(localPath);
176
+ const git = getGit(ctx, localPath);
174
177
  const result = await withAuth(git, localPath, repoUrl, pat, () => git.pull(), username);
175
178
 
176
179
  ctx.body = { success: true, data: result };
@@ -184,7 +187,7 @@ export async function push(ctx: Context, next: () => Promise<void>) {
184
187
  const repoUrl = (repo.get('repoUrl') as string || '').trim();
185
188
  const username = (repo.get('username') as string || '').trim();
186
189
 
187
- const git = getGit(localPath);
190
+ const git = getGit(ctx, localPath);
188
191
  const result = await withAuth(git, localPath, repoUrl, pat, () => git.push(), username);
189
192
 
190
193
  ctx.body = { success: true, data: result };
@@ -198,7 +201,7 @@ export async function fetch(ctx: Context, next: () => Promise<void>) {
198
201
  const repoUrl = (repo.get('repoUrl') as string || '').trim();
199
202
  const username = (repo.get('username') as string || '').trim();
200
203
 
201
- const git = getGit(localPath);
204
+ const git = getGit(ctx, localPath);
202
205
  const result = await withAuth(git, localPath, repoUrl, pat, () => git.fetch(), username);
203
206
 
204
207
  ctx.body = { success: true, data: result };
@@ -210,7 +213,7 @@ export async function diff(ctx: Context, next: () => Promise<void>) {
210
213
  const localPath = validateLocalPath(repo.get('localPath'));
211
214
  const { file, commitHash, compareHash } = ctx.action.params;
212
215
 
213
- const git = getGit(localPath);
216
+ const git = getGit(ctx, localPath);
214
217
  const args: string[] = [];
215
218
  if (commitHash && compareHash) {
216
219
  args.push(validateRef(commitHash), validateRef(compareHash));
@@ -230,7 +233,7 @@ export async function diff(ctx: Context, next: () => Promise<void>) {
230
233
  export async function status(ctx: Context, next: () => Promise<void>) {
231
234
  const repo = await getRepo(ctx);
232
235
  const localPath = validateLocalPath(repo.get('localPath'));
233
- const result = await getGit(localPath).status();
236
+ const result = await getGit(ctx, localPath).status();
234
237
  ctx.body = { success: true, data: result };
235
238
  await next();
236
239
  }
@@ -247,7 +250,7 @@ export async function log(ctx: Context, next: () => Promise<void>) {
247
250
  options.file = file;
248
251
  }
249
252
 
250
- const result = await getGit(localPath).log(options);
253
+ const result = await getGit(ctx, localPath).log(options);
251
254
  ctx.body = { success: true, data: result };
252
255
  await next();
253
256
  }
@@ -255,7 +258,7 @@ export async function log(ctx: Context, next: () => Promise<void>) {
255
258
  export async function branches(ctx: Context, next: () => Promise<void>) {
256
259
  const repo = await getRepo(ctx);
257
260
  const localPath = validateLocalPath(repo.get('localPath'));
258
- const result = await getGit(localPath).branch();
261
+ const result = await getGit(ctx, localPath).branch();
259
262
  ctx.body = { success: true, data: result };
260
263
  await next();
261
264
  }
@@ -265,7 +268,7 @@ export async function checkout(ctx: Context, next: () => Promise<void>) {
265
268
  const localPath = validateLocalPath(repo.get('localPath'));
266
269
  const { branch } = ctx.action.params;
267
270
  validateBranch(branch);
268
- await getGit(localPath).checkout(branch);
271
+ await getGit(ctx, localPath).checkout(branch);
269
272
  ctx.body = { success: true, message: `Switched to branch ${branch}` };
270
273
  await next();
271
274
  }
@@ -275,7 +278,7 @@ export async function fileTree(ctx: Context, next: () => Promise<void>) {
275
278
  const localPath = validateLocalPath(repo.get('localPath'));
276
279
  const { ref = 'HEAD', treePath = '' } = ctx.action.params;
277
280
 
278
- const git = getGit(localPath);
281
+ const git = getGit(ctx, localPath);
279
282
  validateRef(ref);
280
283
  if (treePath && treePath.includes('..')) {
281
284
  ctx.throw(400, 'Invalid tree path');
@@ -324,7 +327,7 @@ export async function fileContent(ctx: Context, next: () => Promise<void>) {
324
327
  }
325
328
 
326
329
  validateRef(ref);
327
- const git = getGit(localPath);
330
+ const git = getGit(ctx, localPath);
328
331
  const content = await git.show([`${ref}:${filePath}`]);
329
332
  ctx.body = { success: true, data: { content, filePath, ref } };
330
333
  await next();
@@ -339,7 +342,7 @@ export async function commitDetail(ctx: Context, next: () => Promise<void>) {
339
342
  ctx.throw(400, 'commitHash is required');
340
343
  }
341
344
 
342
- const git = getGit(localPath);
345
+ const git = getGit(ctx, localPath);
343
346
  validateRef(commitHash);
344
347
 
345
348
  // Use %x00 in format string to tell git to output null bytes, avoiding null bytes in args
@@ -1,6 +1,10 @@
1
1
  import { Context } from '@nocobase/actions';
2
2
  import { parseGitLabProject } from '../utils/gitlab-url';
3
3
 
4
+ function getActionParams(ctx: Context) {
5
+ return { ...ctx.action.params, ...ctx.action.params?.values, ...((ctx as any).request?.body || {}) };
6
+ }
7
+
4
8
  async function gitlabFetch(apiBase: string, endpoint: string, pat: string, params?: Record<string, any>) {
5
9
  const url = new URL(`${apiBase}${endpoint}`);
6
10
  if (params) {
@@ -33,7 +37,7 @@ async function gitlabFetch(apiBase: string, endpoint: string, pat: string, param
33
37
 
34
38
  async function getRepoApiContext(ctx: Context) {
35
39
  // Fix for POST requests where data might be in ctx.request.body
36
- const params = { ...ctx.action.params, ...ctx.action.params?.values, ...( (ctx.request.body as any) || {} ) };
40
+ const params = getActionParams(ctx);
37
41
  const { repositoryId } = params;
38
42
 
39
43
  const repo = await ctx.db.getRepository('gitRepositories').findOne({
@@ -90,7 +94,7 @@ async function githubFetch(endpoint: string, pat: string, params?: Record<string
90
94
  export async function mergeRequests(ctx: Context, next: () => Promise<void>) {
91
95
  const { pat, apiBase, encodedProject, projectPath, isGitHub } = await getRepoApiContext(ctx);
92
96
  // Merge params from query and body
93
- const params = { ...ctx.action.params, ...ctx.action.params?.values, ...( (ctx.request.body as any) || {} ) };
97
+ const params = getActionParams(ctx);
94
98
  const {
95
99
  state = 'opened',
96
100
  search,
@@ -207,7 +211,7 @@ export async function mergeRequests(ctx: Context, next: () => Promise<void>) {
207
211
 
208
212
  export async function mergeRequestDetail(ctx: Context, next: () => Promise<void>) {
209
213
  const { pat, apiBase, encodedProject, projectPath, isGitHub } = await getRepoApiContext(ctx);
210
- const params = { ...ctx.action.params, ...ctx.action.params?.values, ...( (ctx.request.body as any) || {} ) };
214
+ const params = getActionParams(ctx);
211
215
  const { mrIid } = params;
212
216
 
213
217
  if (!mrIid) {
@@ -321,7 +325,7 @@ export async function mergeRequestDetail(ctx: Context, next: () => Promise<void>
321
325
 
322
326
  export async function mergeRequestNotes(ctx: Context, next: () => Promise<void>) {
323
327
  const { pat, apiBase, encodedProject, projectPath, isGitHub } = await getRepoApiContext(ctx);
324
- const params = { ...ctx.action.params, ...ctx.action.params?.values, ...( (ctx.request.body as any) || {} ) };
328
+ const params = getActionParams(ctx);
325
329
  const { mrIid, page = 1, perPage = 50 } = params;
326
330
 
327
331
  if (!mrIid) {
@@ -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,33 @@ 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
+
53
+ function getActionParams(ctx: Context) {
54
+ return { ...ctx.action.params, ...ctx.action.params?.values, ...((ctx as any).request?.body || {}) };
55
+ }
56
+
19
57
  /**
20
58
  * Per-target mutex to prevent two concurrent calls to
21
59
  * `triggerReviewInternal` for the same MR / commit / branch from racing
@@ -25,8 +63,8 @@ interface TriggerArgs {
25
63
  * Uses `app.lockManager` so the same code path covers both single-node
26
64
  * (in-memory `async-mutex`) and HA cluster (Redis-backed Redlock when
27
65
  * `plugin-cluster-manager` is active and `LOCK_ADAPTER_DEFAULT=redis`).
28
- * The locked region only does an upsert + setImmediate (a few ms), so a
29
- * 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.
30
68
  */
31
69
  function targetKey(args: TriggerArgs): string {
32
70
  if (args.targetType === 'mr') return `${args.repositoryId}:mr:${args.mrIid}`;
@@ -43,11 +81,11 @@ async function withTriggerLock<T>(app: Application, key: string, fn: () => Promi
43
81
 
44
82
  /**
45
83
  * Trigger an AI-driven code review for an MR / commit / branch.
46
- * The review record is upserted synchronously, then AIEmployee.invoke runs in
47
- * 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.
48
86
  */
49
87
  export async function triggerReview(ctx: Context, next: () => Promise<void>) {
50
- const params = { ...ctx.action.params, ...ctx.action.params?.values, ...( (ctx.request.body as any) || {} ) };
88
+ const params = getActionParams(ctx);
51
89
  const {
52
90
  flowId,
53
91
  repositoryId,
@@ -158,13 +196,12 @@ async function triggerReviewInternalLocked(app: Application, args: TriggerArgs):
158
196
  latestSha: headSha || existingLatestSha || null,
159
197
  triggeredBy: args.triggeredBy || 'manual',
160
198
  status: 'pending',
161
- // Stamp startedAt synchronously so `recoverStuckReviews` can sweep rows
162
- // that get stuck in `pending` (process died before runReview ran).
163
- // runReview's own update will refresh this on actual start.
164
- 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,
165
202
  finishedAt: null,
166
203
  durationMs: null,
167
- postStatus: flow.get('postMode') === 'disabled' ? 'skipped' : 'pending_approval',
204
+ postStatus: getInitialPostStatus(flow, args.targetType),
168
205
  error: null,
169
206
  };
170
207
 
@@ -189,33 +226,153 @@ async function triggerReviewInternalLocked(app: Application, args: TriggerArgs):
189
226
  reviewId = review.get('id') as number;
190
227
  }
191
228
 
192
- // Run in background do not await
193
- setImmediate(() =>
194
- runReview(app, {
195
- 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,
196
354
  flow,
197
355
  repo,
198
- targetType: args.targetType,
199
- mrIid: args.targetType === 'mr' ? args.mrIid! : null,
200
- commitSha: args.targetType === 'commit' ? args.commitSha! : null,
201
- branch: args.branch || undefined,
202
- 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),
203
361
  aiEmployeeUsername,
204
- extraInstructions: args.extraInstructions,
205
- userId: args.userId ?? null,
206
- }).catch((err) => {
207
- app.log?.error?.('runReview background error', err);
208
- }),
209
- );
210
-
211
- 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
+ }
212
369
  }
213
370
 
214
371
  /**
215
372
  * Mark a review as approved and post its content to GitLab as an MR note.
216
373
  */
217
374
  export async function reviewApprovePost(ctx: Context, next: () => Promise<void>) {
218
- const params = { ...ctx.action.params, ...ctx.action.params?.values, ...( (ctx.request.body as any) || {} ) };
375
+ const params = getActionParams(ctx);
219
376
  const { reviewId, editedMarkdown } = params;
220
377
  if (!reviewId) ctx.throw(400, 'reviewId is required');
221
378
 
@@ -244,6 +401,7 @@ export async function reviewApprovePost(ctx: Context, next: () => Promise<void>)
244
401
  postedNoteId: String(noteId),
245
402
  approvedBy: userId ? String(userId) : null,
246
403
  approvedAt: new Date(),
404
+ error: null,
247
405
  },
248
406
  });
249
407
 
@@ -255,7 +413,7 @@ export async function reviewApprovePost(ctx: Context, next: () => Promise<void>)
255
413
  * Reject a pending review (do not post to GitLab).
256
414
  */
257
415
  export async function reviewReject(ctx: Context, next: () => Promise<void>) {
258
- const params = { ...ctx.action.params, ...ctx.action.params?.values, ...( (ctx.request.body as any) || {} ) };
416
+ const params = getActionParams(ctx);
259
417
  const { reviewId, reason } = params;
260
418
  if (!reviewId) ctx.throw(400, 'reviewId is required');
261
419
 
@@ -428,9 +586,10 @@ async function runReview(app: Application, args: RunReviewArgs) {
428
586
  const finishedAt = new Date();
429
587
  const durationMs = finishedAt.getTime() - startedAt.getTime();
430
588
 
431
- const postMode = args.flow.get('postMode') as string;
432
- let postStatus = 'pending_approval';
589
+ const postMode = getFlowPostMode(args.flow);
590
+ let postStatus = getInitialPostStatus(args.flow, args.targetType);
433
591
  let postedNoteId: string | null = null;
592
+ let autoPostError: string | null = null;
434
593
 
435
594
  if (postMode === 'disabled') {
436
595
  postStatus = 'skipped';
@@ -439,6 +598,8 @@ async function runReview(app: Application, args: RunReviewArgs) {
439
598
  postedNoteId = String(await postNoteToGitLab(args.repo, args.mrIid, content));
440
599
  postStatus = 'posted';
441
600
  } catch (err: any) {
601
+ autoPostError = redactPat(err?.message || String(err));
602
+ postStatus = 'post_failed';
442
603
  app.log?.error?.('Auto-post review note failed', err);
443
604
  }
444
605
  }
@@ -457,11 +618,14 @@ async function runReview(app: Application, args: RunReviewArgs) {
457
618
  finishedAt,
458
619
  postStatus,
459
620
  postedNoteId,
621
+ error: autoPostError ? `Auto-post failed: ${autoPostError}` : null,
460
622
  metadata: {
461
623
  flowName: args.flow.get('name'),
462
624
  aiEmployeeUsername: args.aiEmployeeUsername,
463
625
  llmService,
464
626
  model,
627
+ postMode,
628
+ autoPostError,
465
629
  },
466
630
  },
467
631
  });
@@ -678,6 +842,30 @@ async function postNoteToGitLab(repo: any, mrIid: number, body: string): Promise
678
842
  const MAX_BRANCH_FILTER_LENGTH = 200;
679
843
  const loggedBadFilters = new Set<string>();
680
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
+
681
869
  function warnInvalidBranchFilter(filter: string, reason: string) {
682
870
  if (loggedBadFilters.has(filter)) return;
683
871
  loggedBadFilters.add(filter);
@@ -717,11 +905,10 @@ function throwHttp(status: number, message: string): never {
717
905
  }
718
906
 
719
907
  /**
720
- * Reviews are launched via `setImmediate` and tracked entirely in process
721
- * memory. When the app restarts mid-run, the in-memory promise dies but the
722
- * DB record stays in `status='running'` indefinitely. On startup we sweep
723
- * any `running` review whose `startedAt` is older than the in-process
724
- * 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.
725
912
  *
726
913
  * The cutoff is intentionally larger than the runtime timeout so concurrent
727
914
  * reviews running on a *different* node in an HA cluster aren't clobbered.
@@ -732,13 +919,11 @@ export async function recoverStuckReviews(app: Application): Promise<number> {
732
919
  try {
733
920
  const reviewsRepo = app.db.getRepository('gitCodeReviews');
734
921
  const cutoff = new Date(Date.now() - STUCK_REVIEW_CUTOFF_MS);
735
- // Sweep both `running` (interrupted mid-execution) and `pending`
736
- // (interrupted between record creation and runReview's first update).
737
- // Both have `startedAt` stamped at trigger time so the cutoff applies
738
- // consistently.
922
+ // Pending rows may be legitimately waiting in Redis, so only sweep reviews
923
+ // that were actually picked up by a worker.
739
924
  const stuck = await reviewsRepo.find({
740
925
  filter: {
741
- status: { $in: ['running', 'pending'] },
926
+ status: 'running',
742
927
  startedAt: { $lt: cutoff },
743
928
  },
744
929
  });
@@ -90,6 +90,7 @@ export function registerGitReviewAiTools(app: Application) {
90
90
  app: ctx.app,
91
91
  db: ctx.db,
92
92
  action: { params },
93
+ request: { ...(ctx.request || {}), body: ctx.request?.body || {} },
93
94
  throw: (status: number, message: string) => {
94
95
  const err: any = new Error(message);
95
96
  err.status = status;
@@ -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
  ],