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.
@@ -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
- if (existing.get('status') === 'running') {
138
- // Already in flight return existing id, do not start another
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: err?.message || String(err),
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
- if (!result?.messages || !Array.isArray(result.messages)) return '';
482
- for (let i = result.messages.length - 1; i >= 0; i--) {
483
- const msg = result.messages[i];
484
- if (!msg) continue;
485
- const className = msg?.constructor?.name;
486
- if (className === 'HumanMessage' || className === 'ToolMessage') continue;
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.type === 'text');
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. Overly long patterns are rejected.
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) return true; // reject overly complex patterns
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
- return true; // invalid regex don't block
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
+ }
@@ -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: 'password',
35
+ type: 'string',
36
36
  name: 'pat',
37
37
  interface: 'password',
38
38
  uiSchema: { title: 'Personal Access Token', type: 'string', 'x-component': 'Password' },
@@ -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', () => {
@@ -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 `true` if the lock was acquired, `false` if another node holds it.
54
- * Falls back to the in-memory flag if the DB doesn't support advisory locks.
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<boolean> {
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
- const sequelize = (app.db as any).sequelize;
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 true; // on error, fall back to allowing
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
- const sequelize = (app.db as any).sequelize;
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(`SELECT pg_advisory_unlock(${lockKey})`);
87
- } else if (dialect === 'mysql' || dialect === 'mariadb') {
88
- await sequelize.query(`SELECT RELEASE_LOCK('git_poller')`);
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
- // best-effort release
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
- const lockAcquired = await tryAcquirePollerLock(app);
113
- if (!lockAcquired) {
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 releasePollerLock(app);
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 { apiBase, encodedProject } = parseGitLabProject(repoUrl);
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
+ }