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.
- package/dist/client/187.d5545b7cc8b90bfc.js +10 -0
- package/dist/client/components/RunReviewButton.d.ts +1 -1
- package/dist/client/context/GitManagerContext.d.ts +2 -0
- package/dist/client/index.js +1 -1
- package/dist/externalVersion.js +6 -4
- package/dist/locale/en-US.json +10 -1
- package/dist/locale/vi-VN.json +2 -0
- package/dist/server/actions/git-actions.js +15 -12
- package/dist/server/actions/gitlab-api.js +14 -13
- package/dist/server/actions/review.d.ts +5 -2
- package/dist/server/actions/review.js +184 -37
- package/dist/server/ai-tools.js +2 -0
- package/dist/server/collections/gitCodeReviews.js +1 -0
- package/dist/server/collections/gitRepositories.js +12 -0
- package/dist/server/migrations/20260508000000-add-auto-review-flow-id.d.ts +6 -0
- package/dist/server/migrations/20260508000000-add-auto-review-flow-id.js +57 -0
- package/dist/server/plugin.d.ts +4 -0
- package/dist/server/plugin.js +43 -6
- package/dist/server/poller.js +3 -1
- package/package.json +1 -1
- package/src/client/components/CommitHistory.tsx +21 -3
- package/src/client/components/FileExplorer.tsx +29 -24
- package/src/client/components/GitOperations.tsx +32 -16
- package/src/client/components/PollingStatus.tsx +27 -1
- package/src/client/components/RepositoryConfig.tsx +76 -3
- package/src/client/components/ReviewFlows.tsx +11 -1
- package/src/client/components/ReviewHistory.tsx +14 -1
- package/src/client/components/RunReviewButton.tsx +375 -278
- package/src/client/context/GitManagerContext.tsx +2 -0
- package/src/client/index.tsx +31 -31
- package/src/locale/en-US.json +10 -1
- package/src/locale/vi-VN.json +2 -0
- package/src/server/actions/git-actions.ts +15 -12
- package/src/server/actions/gitlab-api.ts +8 -4
- package/src/server/actions/review.ts +226 -41
- package/src/server/ai-tools.ts +1 -0
- package/src/server/collections/gitCodeReviews.ts +1 -0
- package/src/server/collections/gitRepositories.ts +12 -0
- package/src/server/migrations/20260508000000-add-auto-review-flow-id.ts +29 -0
- package/src/server/plugin.ts +205 -164
- package/src/server/poller.ts +11 -2
- package/dist/client/187.eec7be93247463d7.js +0 -10
package/src/client/index.tsx
CHANGED
|
@@ -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;
|
package/src/locale/en-US.json
CHANGED
|
@@ -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
|
}
|
package/src/locale/vi-VN.json
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 +
|
|
29
|
-
*
|
|
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
|
|
47
|
-
*
|
|
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 =
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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.
|
|
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
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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:
|
|
199
|
-
mrIid:
|
|
200
|
-
commitSha:
|
|
201
|
-
branch:
|
|
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:
|
|
205
|
-
userId:
|
|
206
|
-
})
|
|
207
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
432
|
-
let postStatus =
|
|
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
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
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
|
-
//
|
|
736
|
-
//
|
|
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:
|
|
926
|
+
status: 'running',
|
|
742
927
|
startedAt: { $lt: cutoff },
|
|
743
928
|
},
|
|
744
929
|
});
|
package/src/server/ai-tools.ts
CHANGED
|
@@ -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
|
],
|