plugin-git-manager 1.0.10 → 1.0.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/externalVersion.js +4 -4
- package/dist/server/actions/git-actions.js +30 -25
- package/dist/server/actions/gitlab-api.js +23 -10
- package/dist/server/actions/review.d.ts +1 -0
- package/dist/server/actions/review.js +106 -14
- package/dist/server/ai-tools.js +57 -9
- package/dist/server/collections/gitRepositories.js +1 -1
- package/dist/server/plugin.js +19 -0
- package/dist/server/poller.js +103 -27
- package/dist/server/utils/redact.d.ts +13 -0
- package/dist/server/utils/redact.js +49 -0
- package/package.json +1 -1
- package/src/server/actions/git-actions.ts +39 -25
- package/src/server/actions/gitlab-api.ts +34 -11
- package/src/server/actions/review.ts +162 -15
- package/src/server/ai-tools.ts +67 -8
- package/src/server/collections/gitRepositories.ts +1 -1
- package/src/server/plugin.ts +24 -0
- package/src/server/poller.ts +121 -35
- package/src/server/utils/redact.ts +24 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Context } from '@nocobase/actions';
|
|
2
2
|
import type { Application } from '@nocobase/server';
|
|
3
3
|
import { parseGitLabProject } from '../utils/gitlab-url';
|
|
4
|
+
import { redactPat } from '../utils/redact';
|
|
4
5
|
|
|
5
6
|
interface TriggerArgs {
|
|
6
7
|
flowId?: number | null;
|
|
@@ -15,6 +16,31 @@ interface TriggerArgs {
|
|
|
15
16
|
userId?: number | string | null;
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Per-target mutex to prevent two concurrent calls to
|
|
21
|
+
* `triggerReviewInternal` for the same MR / commit / branch from racing
|
|
22
|
+
* `findOne` → `create` and producing two duplicate review rows or
|
|
23
|
+
* scheduling two `runReview`s.
|
|
24
|
+
*
|
|
25
|
+
* Uses `app.lockManager` so the same code path covers both single-node
|
|
26
|
+
* (in-memory `async-mutex`) and HA cluster (Redis-backed Redlock when
|
|
27
|
+
* `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.
|
|
30
|
+
*/
|
|
31
|
+
function targetKey(args: TriggerArgs): string {
|
|
32
|
+
if (args.targetType === 'mr') return `${args.repositoryId}:mr:${args.mrIid}`;
|
|
33
|
+
if (args.targetType === 'commit') return `${args.repositoryId}:commit:${args.commitSha}`;
|
|
34
|
+
return `${args.repositoryId}:branch:${args.branch}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const TRIGGER_LOCK_TTL_MS = 30_000;
|
|
38
|
+
|
|
39
|
+
async function withTriggerLock<T>(app: Application, key: string, fn: () => Promise<T>): Promise<T> {
|
|
40
|
+
const lockKey = `git-review:trigger:${key}`;
|
|
41
|
+
return app.lockManager.runExclusive(lockKey, fn, TRIGGER_LOCK_TTL_MS);
|
|
42
|
+
}
|
|
43
|
+
|
|
18
44
|
/**
|
|
19
45
|
* Trigger an AI-driven code review for an MR / commit / branch.
|
|
20
46
|
* The review record is upserted synchronously, then AIEmployee.invoke runs in
|
|
@@ -64,6 +90,10 @@ export async function triggerReview(ctx: Context, next: () => Promise<void>) {
|
|
|
64
90
|
* Returns the reviewId of the upserted record.
|
|
65
91
|
*/
|
|
66
92
|
export async function triggerReviewInternal(app: Application, args: TriggerArgs): Promise<number> {
|
|
93
|
+
return withTriggerLock(app, targetKey(args), () => triggerReviewInternalLocked(app, args));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function triggerReviewInternalLocked(app: Application, args: TriggerArgs): Promise<number> {
|
|
67
97
|
const db = app.db;
|
|
68
98
|
const flowsRepo = db.getRepository('gitReviewFlows');
|
|
69
99
|
|
|
@@ -128,14 +158,25 @@ export async function triggerReviewInternal(app: Application, args: TriggerArgs)
|
|
|
128
158
|
latestSha: headSha || existingLatestSha || null,
|
|
129
159
|
triggeredBy: args.triggeredBy || 'manual',
|
|
130
160
|
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(),
|
|
165
|
+
finishedAt: null,
|
|
166
|
+
durationMs: null,
|
|
131
167
|
postStatus: flow.get('postMode') === 'disabled' ? 'skipped' : 'pending_approval',
|
|
132
168
|
error: null,
|
|
133
169
|
};
|
|
134
170
|
|
|
135
171
|
let reviewId: number;
|
|
136
172
|
if (existing) {
|
|
137
|
-
|
|
138
|
-
|
|
173
|
+
const st = existing.get('status');
|
|
174
|
+
// Treat `pending` the same as `running`: between this fn returning and
|
|
175
|
+
// the scheduled `runReview` flipping the row to `running`, a second
|
|
176
|
+
// caller would otherwise slip past and schedule a duplicate runReview.
|
|
177
|
+
// Stuck `pending` rows (process died before runReview ran) are swept
|
|
178
|
+
// by `recoverStuckReviews` on next startup.
|
|
179
|
+
if (st === 'running' || st === 'pending') {
|
|
139
180
|
return existing.get('id') as number;
|
|
140
181
|
}
|
|
141
182
|
await reviewsRepo.update({
|
|
@@ -300,6 +341,7 @@ async function runReview(app: Application, args: RunReviewArgs) {
|
|
|
300
341
|
db,
|
|
301
342
|
state: { currentUser: args.userId ? { id: args.userId } : null },
|
|
302
343
|
req: { headers: { 'x-timezone': 'UTC', 'x-locale': 'en-US' } },
|
|
344
|
+
log: app.logger || console,
|
|
303
345
|
get(name: string) {
|
|
304
346
|
return this.req.headers[String(name).toLowerCase()];
|
|
305
347
|
},
|
|
@@ -417,11 +459,14 @@ async function runReview(app: Application, args: RunReviewArgs) {
|
|
|
417
459
|
}
|
|
418
460
|
} catch (err: any) {
|
|
419
461
|
const finishedAt = new Date();
|
|
462
|
+
// Redact PAT before persisting the error — simple-git / fetch errors
|
|
463
|
+
// can echo back the authenticated remote URL in their messages.
|
|
464
|
+
const safeMessage = redactPat(err?.message || String(err));
|
|
420
465
|
await reviewsRepo.update({
|
|
421
466
|
filterByTk: args.reviewId,
|
|
422
467
|
values: {
|
|
423
468
|
status: 'failed',
|
|
424
|
-
error:
|
|
469
|
+
error: safeMessage,
|
|
425
470
|
finishedAt,
|
|
426
471
|
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
427
472
|
},
|
|
@@ -477,18 +522,52 @@ function buildReviewPrompt(args: RunReviewArgs): string {
|
|
|
477
522
|
return lines.join('\n');
|
|
478
523
|
}
|
|
479
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Pick the AI's final reply out of a LangChain-style message list.
|
|
527
|
+
*
|
|
528
|
+
* The previous implementation relied solely on `constructor.name`, which is
|
|
529
|
+
* unsafe under bundling/minification (class names can be mangled to single
|
|
530
|
+
* letters) and after JSON serialisation (instances become plain objects).
|
|
531
|
+
* The new logic checks several signals in order: LangChain's `_getType()`,
|
|
532
|
+
* an explicit `role` field, then the constructor name as a last resort.
|
|
533
|
+
*/
|
|
480
534
|
function extractLastAiMessageContent(result: any): string {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
if (
|
|
535
|
+
const messages = result?.messages;
|
|
536
|
+
if (!Array.isArray(messages)) return '';
|
|
537
|
+
|
|
538
|
+
const isAiMessage = (msg: any): boolean => {
|
|
539
|
+
if (!msg) return false;
|
|
540
|
+
if (typeof msg._getType === 'function') {
|
|
541
|
+
try {
|
|
542
|
+
return msg._getType() === 'ai';
|
|
543
|
+
} catch {
|
|
544
|
+
// fall through to other signals
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (typeof msg.role === 'string') {
|
|
548
|
+
const r = msg.role.toLowerCase();
|
|
549
|
+
return r === 'assistant' || r === 'ai';
|
|
550
|
+
}
|
|
551
|
+
if (typeof msg.type === 'string' && msg.type.toLowerCase() === 'ai') return true;
|
|
552
|
+
const name = msg?.constructor?.name;
|
|
553
|
+
if (name === 'AIMessage' || name === 'AIMessageChunk') return true;
|
|
554
|
+
return false;
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const getContent = (msg: any): string => {
|
|
487
558
|
if (typeof msg.content === 'string') return msg.content;
|
|
488
559
|
if (Array.isArray(msg.content)) {
|
|
489
|
-
const textBlock = msg.content.find((c: any) => c
|
|
490
|
-
if (textBlock?.text) return textBlock.text;
|
|
560
|
+
const textBlock = msg.content.find((c: any) => c?.type === 'text');
|
|
561
|
+
if (typeof textBlock?.text === 'string') return textBlock.text;
|
|
491
562
|
}
|
|
563
|
+
return '';
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
567
|
+
const msg = messages[i];
|
|
568
|
+
if (!isAiMessage(msg)) continue;
|
|
569
|
+
const content = getContent(msg);
|
|
570
|
+
if (content) return content;
|
|
492
571
|
}
|
|
493
572
|
return '';
|
|
494
573
|
}
|
|
@@ -574,18 +653,37 @@ async function postNoteToGitLab(repo: any, mrIid: number, body: string): Promise
|
|
|
574
653
|
|
|
575
654
|
/**
|
|
576
655
|
* H-1 fix: guard against ReDoS by limiting regex length and wrapping
|
|
577
|
-
* execution in a try-catch.
|
|
656
|
+
* execution in a try-catch.
|
|
657
|
+
*
|
|
658
|
+
* Fail-closed: an invalid or oversized pattern means "no branch matches".
|
|
659
|
+
* Reviewing all branches when a user explicitly configured a filter is more
|
|
660
|
+
* dangerous (auto-posts to GitLab, consumes LLM credits) than skipping a
|
|
661
|
+
* malformed flow. The poller logs once per process so misconfiguration is
|
|
662
|
+
* still observable.
|
|
578
663
|
*/
|
|
579
664
|
const MAX_BRANCH_FILTER_LENGTH = 200;
|
|
665
|
+
const loggedBadFilters = new Set<string>();
|
|
666
|
+
|
|
667
|
+
function warnInvalidBranchFilter(filter: string, reason: string) {
|
|
668
|
+
if (loggedBadFilters.has(filter)) return;
|
|
669
|
+
loggedBadFilters.add(filter);
|
|
670
|
+
console.warn(
|
|
671
|
+
`[plugin-git-manager] branchFilter rejected (${reason}): ${JSON.stringify(filter)}. Flow will not match any branch.`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
580
674
|
|
|
581
675
|
export function branchMatches(flow: any, branch: string): boolean {
|
|
582
676
|
const filter = flow.get('branchFilter') as string | null;
|
|
583
677
|
if (!filter) return true;
|
|
584
|
-
if (filter.length > MAX_BRANCH_FILTER_LENGTH)
|
|
678
|
+
if (filter.length > MAX_BRANCH_FILTER_LENGTH) {
|
|
679
|
+
warnInvalidBranchFilter(filter, `too long (>${MAX_BRANCH_FILTER_LENGTH} chars)`);
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
585
682
|
try {
|
|
586
683
|
return new RegExp(filter).test(branch);
|
|
587
|
-
} catch {
|
|
588
|
-
|
|
684
|
+
} catch (err: any) {
|
|
685
|
+
warnInvalidBranchFilter(filter, `invalid regex: ${err?.message || err}`);
|
|
686
|
+
return false;
|
|
589
687
|
}
|
|
590
688
|
}
|
|
591
689
|
|
|
@@ -603,3 +701,52 @@ function throwHttp(status: number, message: string): never {
|
|
|
603
701
|
err.status = status;
|
|
604
702
|
throw err;
|
|
605
703
|
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Reviews are launched via `setImmediate` and tracked entirely in process
|
|
707
|
+
* memory. When the app restarts mid-run, the in-memory promise dies but the
|
|
708
|
+
* DB record stays in `status='running'` indefinitely. On startup we sweep
|
|
709
|
+
* any `running` review whose `startedAt` is older than the in-process
|
|
710
|
+
* timeout (5 min) plus a safety margin and mark it as failed.
|
|
711
|
+
*
|
|
712
|
+
* The cutoff is intentionally larger than the runtime timeout so concurrent
|
|
713
|
+
* reviews running on a *different* node in an HA cluster aren't clobbered.
|
|
714
|
+
*/
|
|
715
|
+
const STUCK_REVIEW_CUTOFF_MS = 10 * 60 * 1000; // 10 minutes
|
|
716
|
+
|
|
717
|
+
export async function recoverStuckReviews(app: Application): Promise<number> {
|
|
718
|
+
try {
|
|
719
|
+
const reviewsRepo = app.db.getRepository('gitCodeReviews');
|
|
720
|
+
const cutoff = new Date(Date.now() - STUCK_REVIEW_CUTOFF_MS);
|
|
721
|
+
// Sweep both `running` (interrupted mid-execution) and `pending`
|
|
722
|
+
// (interrupted between record creation and runReview's first update).
|
|
723
|
+
// Both have `startedAt` stamped at trigger time so the cutoff applies
|
|
724
|
+
// consistently.
|
|
725
|
+
const stuck = await reviewsRepo.find({
|
|
726
|
+
filter: {
|
|
727
|
+
status: { $in: ['running', 'pending'] },
|
|
728
|
+
startedAt: { $lt: cutoff },
|
|
729
|
+
},
|
|
730
|
+
});
|
|
731
|
+
if (!stuck?.length) return 0;
|
|
732
|
+
const finishedAt = new Date();
|
|
733
|
+
for (const review of stuck) {
|
|
734
|
+
const startedAt = review.get('startedAt') as Date | null;
|
|
735
|
+
const durationMs = startedAt ? finishedAt.getTime() - new Date(startedAt).getTime() : null;
|
|
736
|
+
await reviewsRepo.update({
|
|
737
|
+
filterByTk: review.get('id'),
|
|
738
|
+
values: {
|
|
739
|
+
status: 'failed',
|
|
740
|
+
error: 'Review interrupted by application restart',
|
|
741
|
+
finishedAt,
|
|
742
|
+
durationMs,
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
app.log?.info?.(`plugin-git-manager: marked ${stuck.length} stuck review(s) as failed after restart`);
|
|
747
|
+
return stuck.length;
|
|
748
|
+
} catch (err: any) {
|
|
749
|
+
app.log?.error?.(`plugin-git-manager: recoverStuckReviews failed: ${err?.message}`);
|
|
750
|
+
return 0;
|
|
751
|
+
}
|
|
752
|
+
}
|
package/src/server/ai-tools.ts
CHANGED
|
@@ -18,11 +18,46 @@ export function registerGitReviewAiTools(app: Application) {
|
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Enforce that the AI session has an authenticated user and that this user
|
|
23
|
+
* is permitted to invoke the underlying resource:action. Without this check,
|
|
24
|
+
* a prompt-injected AI could pass an arbitrary `repositoryId` to the tools
|
|
25
|
+
* and read any repository's MRs / commits / file content regardless of the
|
|
26
|
+
* caller's ACL.
|
|
27
|
+
*/
|
|
28
|
+
const enforceAcl = (ctx: any, resource: string, action: string) => {
|
|
29
|
+
const user = ctx?.state?.currentUser;
|
|
30
|
+
if (!user?.id) {
|
|
31
|
+
const err: any = new Error('AI tool requires an authenticated user context');
|
|
32
|
+
err.status = 401;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
const acl = ctx.app?.acl;
|
|
36
|
+
if (!acl?.can) return; // ACL not available — fail-open is acceptable for legacy contexts
|
|
37
|
+
const role =
|
|
38
|
+
ctx?.state?.currentRole ||
|
|
39
|
+
(Array.isArray(user.roles) && user.roles[0]?.name) ||
|
|
40
|
+
null;
|
|
41
|
+
if (!role) {
|
|
42
|
+
const err: any = new Error('AI tool requires a resolvable role on the current user');
|
|
43
|
+
err.status = 403;
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
const allowed = acl.can({ role, resource, action });
|
|
47
|
+
if (!allowed) {
|
|
48
|
+
const err: any = new Error(`Permission denied: ${resource}:${action}`);
|
|
49
|
+
err.status = 403;
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
21
54
|
const runResourceAction = async (
|
|
22
55
|
ctx: any,
|
|
23
56
|
handler: (ctx: any, next: () => Promise<void>) => Promise<void>,
|
|
24
57
|
params: Record<string, any>,
|
|
58
|
+
gate: { resource: string; action: string },
|
|
25
59
|
) => {
|
|
60
|
+
enforceAcl(ctx, gate.resource, gate.action);
|
|
26
61
|
const synthCtx: any = {
|
|
27
62
|
...ctx,
|
|
28
63
|
app: ctx.app,
|
|
@@ -55,7 +90,10 @@ export function registerGitReviewAiTools(app: Application) {
|
|
|
55
90
|
}),
|
|
56
91
|
},
|
|
57
92
|
invoke: async (ctx: any, args: any) => {
|
|
58
|
-
const body = await runResourceAction(ctx, gitlabApi.mergeRequestDetail, args
|
|
93
|
+
const body = await runResourceAction(ctx, gitlabApi.mergeRequestDetail, args, {
|
|
94
|
+
resource: 'gitManager',
|
|
95
|
+
action: 'mergeRequestDetail',
|
|
96
|
+
});
|
|
59
97
|
return body?.data ?? body;
|
|
60
98
|
},
|
|
61
99
|
},
|
|
@@ -77,7 +115,10 @@ export function registerGitReviewAiTools(app: Application) {
|
|
|
77
115
|
}),
|
|
78
116
|
},
|
|
79
117
|
invoke: async (ctx: any, args: any) => {
|
|
80
|
-
const body = await runResourceAction(ctx, gitlabApi.mergeRequests, args
|
|
118
|
+
const body = await runResourceAction(ctx, gitlabApi.mergeRequests, args, {
|
|
119
|
+
resource: 'gitManager',
|
|
120
|
+
action: 'mergeRequests',
|
|
121
|
+
});
|
|
81
122
|
return body?.data ?? body;
|
|
82
123
|
},
|
|
83
124
|
},
|
|
@@ -96,7 +137,10 @@ export function registerGitReviewAiTools(app: Application) {
|
|
|
96
137
|
}),
|
|
97
138
|
},
|
|
98
139
|
invoke: async (ctx: any, args: any) => {
|
|
99
|
-
const body = await runResourceAction(ctx, gitlabApi.mergeRequestNotes, args
|
|
140
|
+
const body = await runResourceAction(ctx, gitlabApi.mergeRequestNotes, args, {
|
|
141
|
+
resource: 'gitManager',
|
|
142
|
+
action: 'mergeRequestNotes',
|
|
143
|
+
});
|
|
100
144
|
return body?.data ?? body;
|
|
101
145
|
},
|
|
102
146
|
},
|
|
@@ -115,7 +159,10 @@ export function registerGitReviewAiTools(app: Application) {
|
|
|
115
159
|
}),
|
|
116
160
|
},
|
|
117
161
|
invoke: async (ctx: any, args: any) => {
|
|
118
|
-
const body = await runResourceAction(ctx, gitActions.commitDetail, args
|
|
162
|
+
const body = await runResourceAction(ctx, gitActions.commitDetail, args, {
|
|
163
|
+
resource: 'gitManager',
|
|
164
|
+
action: 'commitDetail',
|
|
165
|
+
});
|
|
119
166
|
return body?.data ?? body;
|
|
120
167
|
},
|
|
121
168
|
},
|
|
@@ -137,7 +184,10 @@ export function registerGitReviewAiTools(app: Application) {
|
|
|
137
184
|
}),
|
|
138
185
|
},
|
|
139
186
|
invoke: async (ctx: any, args: any) => {
|
|
140
|
-
const body = await runResourceAction(ctx, gitActions.diff, args
|
|
187
|
+
const body = await runResourceAction(ctx, gitActions.diff, args, {
|
|
188
|
+
resource: 'gitManager',
|
|
189
|
+
action: 'diff',
|
|
190
|
+
});
|
|
141
191
|
return body?.data ?? body;
|
|
142
192
|
},
|
|
143
193
|
},
|
|
@@ -158,7 +208,10 @@ export function registerGitReviewAiTools(app: Application) {
|
|
|
158
208
|
}),
|
|
159
209
|
},
|
|
160
210
|
invoke: async (ctx: any, args: any) => {
|
|
161
|
-
const body = await runResourceAction(ctx, gitActions.fileContent, args
|
|
211
|
+
const body = await runResourceAction(ctx, gitActions.fileContent, args, {
|
|
212
|
+
resource: 'gitManager',
|
|
213
|
+
action: 'fileContent',
|
|
214
|
+
});
|
|
162
215
|
return body?.data ?? body;
|
|
163
216
|
},
|
|
164
217
|
},
|
|
@@ -174,7 +227,10 @@ export function registerGitReviewAiTools(app: Application) {
|
|
|
174
227
|
schema: z.object({ repositoryId: z.number() }),
|
|
175
228
|
},
|
|
176
229
|
invoke: async (ctx: any, args: any) => {
|
|
177
|
-
const body = await runResourceAction(ctx, gitActions.branches, args
|
|
230
|
+
const body = await runResourceAction(ctx, gitActions.branches, args, {
|
|
231
|
+
resource: 'gitManager',
|
|
232
|
+
action: 'branches',
|
|
233
|
+
});
|
|
178
234
|
return body?.data ?? body;
|
|
179
235
|
},
|
|
180
236
|
},
|
|
@@ -194,7 +250,10 @@ export function registerGitReviewAiTools(app: Application) {
|
|
|
194
250
|
}),
|
|
195
251
|
},
|
|
196
252
|
invoke: async (ctx: any, args: any) => {
|
|
197
|
-
const body = await runResourceAction(ctx, gitActions.log, args
|
|
253
|
+
const body = await runResourceAction(ctx, gitActions.log, args, {
|
|
254
|
+
resource: 'gitManager',
|
|
255
|
+
action: 'log',
|
|
256
|
+
});
|
|
198
257
|
return body?.data ?? body;
|
|
199
258
|
},
|
|
200
259
|
},
|
|
@@ -32,7 +32,7 @@ export default defineCollection({
|
|
|
32
32
|
uiSchema: { title: 'Local Path', type: 'string', 'x-component': 'Input' },
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
|
-
type: '
|
|
35
|
+
type: 'string',
|
|
36
36
|
name: 'pat',
|
|
37
37
|
interface: 'password',
|
|
38
38
|
uiSchema: { title: 'Personal Access Token', type: 'string', 'x-component': 'Password' },
|
package/src/server/plugin.ts
CHANGED
|
@@ -4,6 +4,7 @@ import * as gitActions from './actions/git-actions';
|
|
|
4
4
|
import * as gitlabApi from './actions/gitlab-api';
|
|
5
5
|
import * as reviewActions from './actions/review';
|
|
6
6
|
import * as pollerActions from './actions/poller';
|
|
7
|
+
import { recoverStuckReviews } from './actions/review';
|
|
7
8
|
import { registerGitReviewAiTools } from './ai-tools';
|
|
8
9
|
import { startPoller, stopPoller } from './poller';
|
|
9
10
|
|
|
@@ -39,9 +40,32 @@ export class PluginGitManagerServer extends Plugin {
|
|
|
39
40
|
},
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
// Suppress noisy workflow pre-action/post-action warnings for custom resources
|
|
44
|
+
this.app.use(async (ctx, next) => {
|
|
45
|
+
if (ctx.logger && ctx.logger.warn) {
|
|
46
|
+
const originalWarn = ctx.logger.warn.bind(ctx.logger);
|
|
47
|
+
ctx.logger.warn = (message: any, ...args: any[]) => {
|
|
48
|
+
if (
|
|
49
|
+
typeof message === 'string' &&
|
|
50
|
+
message.includes('[Workflow') &&
|
|
51
|
+
message.includes('collection') &&
|
|
52
|
+
message.includes('not found')
|
|
53
|
+
) {
|
|
54
|
+
return ctx.logger;
|
|
55
|
+
}
|
|
56
|
+
return originalWarn(message, ...args);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return next();
|
|
60
|
+
});
|
|
61
|
+
|
|
42
62
|
registerGitReviewAiTools(this.app);
|
|
43
63
|
|
|
44
64
|
this.app.on('afterStart', () => {
|
|
65
|
+
// Sweep any review left in `running` state from a previous process.
|
|
66
|
+
recoverStuckReviews(this.app).catch((err) =>
|
|
67
|
+
this.app.log?.error?.('plugin-git-manager: recoverStuckReviews error', err),
|
|
68
|
+
);
|
|
45
69
|
startPoller(this.app);
|
|
46
70
|
});
|
|
47
71
|
this.app.on('beforeStop', () => {
|
package/src/server/poller.ts
CHANGED
|
@@ -46,49 +46,94 @@ export function getPollerStatus() {
|
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Handle returned by `tryAcquirePollerLock`. Caller must invoke `release()`
|
|
51
|
+
* exactly once when done. Returning a handle (rather than a boolean) lets us
|
|
52
|
+
* pin a Sequelize transaction to a single connection so that
|
|
53
|
+
* `pg_advisory_unlock` / `RELEASE_LOCK` run on the same connection that
|
|
54
|
+
* acquired the lock — pool-based queries previously could release on the
|
|
55
|
+
* wrong connection, leaving the lock held until that connection recycled.
|
|
56
|
+
*/
|
|
57
|
+
interface PollerLockHandle {
|
|
58
|
+
release: () => Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const ADVISORY_LOCK_KEY = 777_042;
|
|
62
|
+
|
|
49
63
|
/**
|
|
50
64
|
* Attempt to acquire a DB-level advisory lock so that only one node in an
|
|
51
65
|
* HA cluster runs the poller at any given time.
|
|
52
66
|
*
|
|
53
|
-
* Returns
|
|
54
|
-
* Falls back to
|
|
67
|
+
* Returns a handle when acquired, `null` when another node holds the lock.
|
|
68
|
+
* Falls back to a no-op handle when the DB doesn't support advisory locks
|
|
69
|
+
* (SQLite, unknown dialects) or when an unexpected error occurs.
|
|
55
70
|
*/
|
|
56
|
-
async function tryAcquirePollerLock(app: Application): Promise<
|
|
71
|
+
async function tryAcquirePollerLock(app: Application): Promise<PollerLockHandle | null> {
|
|
72
|
+
const noop: PollerLockHandle = { release: async () => undefined };
|
|
73
|
+
const sequelize = (app.db as any).sequelize;
|
|
74
|
+
if (!sequelize) return noop;
|
|
75
|
+
const dialect = sequelize.getDialect?.();
|
|
76
|
+
|
|
77
|
+
if (dialect !== 'postgres' && dialect !== 'mysql' && dialect !== 'mariadb') {
|
|
78
|
+
return noop; // SQLite / unknown — no distributed locking, allow
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// A transaction pins all queries (and the lock) to a single connection.
|
|
82
|
+
let transaction: any;
|
|
57
83
|
try {
|
|
58
|
-
|
|
59
|
-
if (!sequelize) return true; // can't lock — allow
|
|
60
|
-
const dialect = sequelize.getDialect?.();
|
|
61
|
-
// Lock key derived from a fixed identifier for this plugin's poller
|
|
62
|
-
const lockKey = 777_042; // arbitrary stable int
|
|
63
|
-
if (dialect === 'postgres') {
|
|
64
|
-
const [results] = await sequelize.query(`SELECT pg_try_advisory_lock(${lockKey}) AS locked`);
|
|
65
|
-
return results?.[0]?.locked === true;
|
|
66
|
-
}
|
|
67
|
-
// For MySQL / MariaDB
|
|
68
|
-
if (dialect === 'mysql' || dialect === 'mariadb') {
|
|
69
|
-
const [results] = await sequelize.query(`SELECT GET_LOCK('git_poller', 0) AS locked`);
|
|
70
|
-
return results?.[0]?.locked === 1;
|
|
71
|
-
}
|
|
72
|
-
// SQLite or unknown — no distributed locking, allow
|
|
73
|
-
return true;
|
|
84
|
+
transaction = await sequelize.transaction();
|
|
74
85
|
} catch {
|
|
75
|
-
return
|
|
86
|
+
return noop; // can't open txn — fall back to allowing
|
|
76
87
|
}
|
|
77
|
-
}
|
|
78
88
|
|
|
79
|
-
async function releasePollerLock(app: Application): Promise<void> {
|
|
80
89
|
try {
|
|
81
|
-
|
|
82
|
-
if (!sequelize) return;
|
|
83
|
-
const dialect = sequelize.getDialect?.();
|
|
84
|
-
const lockKey = 777_042;
|
|
90
|
+
let acquired = false;
|
|
85
91
|
if (dialect === 'postgres') {
|
|
86
|
-
await sequelize.query(
|
|
87
|
-
|
|
88
|
-
|
|
92
|
+
const [results] = await sequelize.query(
|
|
93
|
+
`SELECT pg_try_advisory_lock(${ADVISORY_LOCK_KEY}) AS locked`,
|
|
94
|
+
{ transaction },
|
|
95
|
+
);
|
|
96
|
+
acquired = (results as any)?.[0]?.locked === true;
|
|
97
|
+
} else {
|
|
98
|
+
const [results] = await sequelize.query(
|
|
99
|
+
`SELECT GET_LOCK('git_poller', 0) AS locked`,
|
|
100
|
+
{ transaction },
|
|
101
|
+
);
|
|
102
|
+
const v = (results as any)?.[0]?.locked;
|
|
103
|
+
acquired = v === 1 || v === '1' || v === true;
|
|
89
104
|
}
|
|
105
|
+
|
|
106
|
+
if (!acquired) {
|
|
107
|
+
try { await transaction.rollback(); } catch { /* ignore */ }
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
release: async () => {
|
|
113
|
+
try {
|
|
114
|
+
if (dialect === 'postgres') {
|
|
115
|
+
await sequelize.query(
|
|
116
|
+
`SELECT pg_advisory_unlock(${ADVISORY_LOCK_KEY})`,
|
|
117
|
+
{ transaction },
|
|
118
|
+
);
|
|
119
|
+
} else {
|
|
120
|
+
await sequelize.query(
|
|
121
|
+
`SELECT RELEASE_LOCK('git_poller')`,
|
|
122
|
+
{ transaction },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// best-effort release — txn close still recycles the connection
|
|
127
|
+
} finally {
|
|
128
|
+
try { await transaction.commit(); } catch {
|
|
129
|
+
try { await transaction.rollback(); } catch { /* ignore */ }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
90
134
|
} catch {
|
|
91
|
-
|
|
135
|
+
try { await transaction.rollback(); } catch { /* ignore */ }
|
|
136
|
+
return noop; // on error, fall back to allowing
|
|
92
137
|
}
|
|
93
138
|
}
|
|
94
139
|
|
|
@@ -108,9 +153,10 @@ export async function pollAllRepos(app: Application): Promise<{ scanned: number;
|
|
|
108
153
|
}
|
|
109
154
|
}
|
|
110
155
|
|
|
111
|
-
// C-1 fix: acquire distributed lock for HA
|
|
112
|
-
|
|
113
|
-
|
|
156
|
+
// C-1 fix: acquire distributed lock for HA — handle pins the connection
|
|
157
|
+
// so unlock runs on the same connection that acquired the lock.
|
|
158
|
+
const lock = await tryAcquirePollerLock(app);
|
|
159
|
+
if (!lock) {
|
|
114
160
|
app.log?.debug?.('poller: another node holds the advisory lock — skipping');
|
|
115
161
|
return { scanned: 0, triggered: 0 };
|
|
116
162
|
}
|
|
@@ -141,7 +187,7 @@ export async function pollAllRepos(app: Application): Promise<{ scanned: number;
|
|
|
141
187
|
} finally {
|
|
142
188
|
isPolling = false;
|
|
143
189
|
pollStartedAt = null;
|
|
144
|
-
await
|
|
190
|
+
await lock.release();
|
|
145
191
|
}
|
|
146
192
|
return { scanned, triggered };
|
|
147
193
|
}
|
|
@@ -232,8 +278,48 @@ export async function pollOneRepo(
|
|
|
232
278
|
async function listMergeRequests(repo: any, updatedAfter: Date | null): Promise<any[]> {
|
|
233
279
|
const repoUrl = repo.get('repoUrl') as string;
|
|
234
280
|
const pat = repo.get('pat') as string;
|
|
235
|
-
const
|
|
281
|
+
const isGitHub = typeof repoUrl === 'string' && repoUrl.includes('github.com');
|
|
282
|
+
|
|
283
|
+
if (isGitHub) {
|
|
284
|
+
// GitHub PRs: list endpoint sorts by updated; GitHub has no `updated_after`,
|
|
285
|
+
// so we filter client-side after fetching the most recently updated page.
|
|
286
|
+
const { projectPath } = parseGitLabProject(repoUrl);
|
|
287
|
+
const params = new URLSearchParams({
|
|
288
|
+
state: 'open',
|
|
289
|
+
per_page: String(MR_PAGE_SIZE),
|
|
290
|
+
sort: 'updated',
|
|
291
|
+
direction: 'desc',
|
|
292
|
+
});
|
|
293
|
+
const headers: Record<string, string> = { Accept: 'application/vnd.github.v3+json' };
|
|
294
|
+
if (pat) headers['Authorization'] = `Bearer ${pat}`;
|
|
236
295
|
|
|
296
|
+
const response = await fetch(
|
|
297
|
+
`https://api.github.com/repos/${projectPath}/pulls?${params.toString()}`,
|
|
298
|
+
{ headers },
|
|
299
|
+
);
|
|
300
|
+
if (!response.ok) {
|
|
301
|
+
const body = await response.text().catch(() => '');
|
|
302
|
+
throw new Error(`GitHub API error ${response.status}: ${body}`);
|
|
303
|
+
}
|
|
304
|
+
let prs = (await response.json()) as any[];
|
|
305
|
+
if (Array.isArray(prs) && updatedAfter) {
|
|
306
|
+
const cutoff = updatedAfter.getTime() - 1000;
|
|
307
|
+
prs = prs.filter((pr: any) => {
|
|
308
|
+
const t = pr?.updated_at ? new Date(pr.updated_at).getTime() : 0;
|
|
309
|
+
return t >= cutoff;
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
// Normalise to the GitLab MR shape that pollOneRepo consumes.
|
|
313
|
+
return (prs || []).map((pr: any) => ({
|
|
314
|
+
iid: pr.number,
|
|
315
|
+
sha: pr.head?.sha,
|
|
316
|
+
source_branch: pr.head?.ref,
|
|
317
|
+
target_branch: pr.base?.ref,
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// GitLab
|
|
322
|
+
const { apiBase, encodedProject } = parseGitLabProject(repoUrl);
|
|
237
323
|
const params = new URLSearchParams({
|
|
238
324
|
state: 'opened',
|
|
239
325
|
per_page: String(MR_PAGE_SIZE),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redact embedded credentials from URLs in arbitrary strings.
|
|
3
|
+
* Matches `scheme://user:password@host` and replaces with `scheme://***:***@host`.
|
|
4
|
+
* Used to scrub error messages before persisting them to the DB or
|
|
5
|
+
* returning them to the client, since simple-git often echoes the
|
|
6
|
+
* authenticated remote URL in stderr.
|
|
7
|
+
*/
|
|
8
|
+
export function redactPat(s: unknown): string {
|
|
9
|
+
if (typeof s !== 'string') return s == null ? '' : String(s);
|
|
10
|
+
return s.replace(/(https?:\/\/)([^\/:@\s]+):([^@\s]+)@/g, '$1***:***@');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mutate `err.message` (and common fields where simple-git stashes stderr)
|
|
15
|
+
* to remove any embedded PAT before the error propagates further.
|
|
16
|
+
*/
|
|
17
|
+
export function redactError<T>(err: T): T {
|
|
18
|
+
if (!err || typeof err !== 'object') return err;
|
|
19
|
+
const e: any = err;
|
|
20
|
+
if (typeof e.message === 'string') e.message = redactPat(e.message);
|
|
21
|
+
if (typeof e.stderr === 'string') e.stderr = redactPat(e.stderr);
|
|
22
|
+
if (typeof e.stdout === 'string') e.stdout = redactPat(e.stdout);
|
|
23
|
+
return err;
|
|
24
|
+
}
|