@link-assistant/hive-mind 1.69.17 → 1.70.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.70.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 35dc089: Add `--auto-resolve` to the `/merge` Telegram command. After the normal queue finishes, the bot now iterates every PR that was skipped because of merge conflicts and dispatches a `solve <pr-url> --auto-merge` session through `start-screen` — the same path other commands use — so conflict resolution runs with the default `sonnet` model and the PR is merged once the session finishes. Each PR/issue reference in the `/merge` progress and final messages is now rendered as a clickable MarkdownV2 link to the actual pull request or issue. Resolves #1805.
8
+
9
+ ## 1.69.18
10
+
11
+ ### Patch Changes
12
+
13
+ - 9aa5659: Fix `--auto-fork` mode failing when continuing an existing fork PR whose
14
+ fork name already contained the upstream-owner prefix. `setupRepository`
15
+ in `solve.repository.lib.mjs` was applying the
16
+ `--prefix-fork-name-with-owner-name` option to `forkRepoName` (which is
17
+ the authoritative head repo name from the PR's `headRepository.name`),
18
+ producing a doubled prefix like
19
+ `konard/labtgbot-labtgbot-telegram-claude-agent` and a 404 lookup. The
20
+ prefix option now only controls fork _creation_, not fork _lookup_:
21
+ when `forkRepoName` is present, the expected fork is
22
+ `${forkOwner}/${forkRepoName}` and no alternate-name fallback is
23
+ attempted. Resolves #1803.
24
+
3
25
  ## 1.69.17
4
26
 
5
27
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.69.17",
3
+ "version": "1.70.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -818,12 +818,12 @@ Thank you!`;
818
818
  await log(`\n${formatAligned('🍴', 'Fork mode:', 'DETECTED from PR')}`);
819
819
  await log(`${formatAligned('', 'Fork owner:', forkOwner)}`);
820
820
 
821
- // Use actual head repo name from PR data (headRepository.name) if available, otherwise guess from base repo name
821
+ // Issue #1803: prefix flag controls fork CREATION; for lookup, trust forkRepoName from PR head data.
822
822
  const headRepoName = forkRepoName || repo;
823
823
  const standardForkName = `${forkOwner}/${headRepoName}`;
824
824
  const prefixedForkName = `${forkOwner}/${owner}-${headRepoName}`;
825
- const expectedForkName = argv.prefixForkNameWithOwnerName ? prefixedForkName : standardForkName;
826
- const alternateForkName = argv.prefixForkNameWithOwnerName ? standardForkName : prefixedForkName;
825
+ const expectedForkName = forkRepoName ? `${forkOwner}/${forkRepoName}` : argv.prefixForkNameWithOwnerName ? prefixedForkName : standardForkName;
826
+ const alternateForkName = forkRepoName ? null : argv.prefixForkNameWithOwnerName ? standardForkName : prefixedForkName;
827
827
 
828
828
  await log(`${formatAligned('✅', 'Using fork:', expectedForkName)}\n`);
829
829
 
@@ -832,9 +832,9 @@ Thank you!`;
832
832
  let forkCheckResult = await $`gh repo view ${expectedForkName} --json name 2>/dev/null`;
833
833
  let actualForkName = expectedForkName;
834
834
 
835
- if (forkCheckResult.code !== 0 && !argv.prefixForkNameWithOwnerName) {
836
- // Only try alternate name if NOT using --prefix-fork-name-with-owner-name
837
- // When the option is enabled, we should only use the prefixed fork name
835
+ if (forkCheckResult.code !== 0 && alternateForkName && !argv.prefixForkNameWithOwnerName) {
836
+ // Only try alternate name if --prefix-fork-name-with-owner-name is off AND we're guessing
837
+ // (forkRepoName authoritative alternateForkName is null and no fallback is attempted).
838
838
  forkCheckResult = await $`gh repo view ${alternateForkName} --json name 2>/dev/null`;
839
839
  if (forkCheckResult.code === 0) {
840
840
  actualForkName = alternateForkName;
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { parseRepositoryUrl, checkLabelPermissions, ensureReadyLabel } from './github-merge.lib.mjs';
22
22
  import { createMergeQueueProcessor, MergeStatus, MERGE_QUEUE_CONFIG } from './telegram-merge-queue.lib.mjs';
23
+ import { executeStartScreen } from './telegram-command-execution.lib.mjs';
23
24
 
24
25
  /**
25
26
  * Active merge operations map (repoKey -> { processor, chatId, messageId })
@@ -90,6 +91,77 @@ function parseCommandArgs(text) {
90
91
  return args;
91
92
  }
92
93
 
94
+ /**
95
+ * Issue #1805: Parse boolean flags out of the tokenised `/merge` args.
96
+ * Supports `--flag`, `--flag=true`, `--flag=false`, `--no-flag` and the
97
+ * trailing positional repository URL. We keep the original positional order
98
+ * so callers can still treat `positionals[0]` as the repo URL.
99
+ *
100
+ * @param {string[]} args - The output of `parseCommandArgs(text)`.
101
+ * @returns {{ positionals: string[], flags: Record<string, boolean> }}
102
+ */
103
+ export function parseMergeArgs(args) {
104
+ const flags = {};
105
+ const positionals = [];
106
+ for (const arg of args) {
107
+ if (typeof arg !== 'string') continue;
108
+ if (arg.startsWith('--')) {
109
+ const body = arg.slice(2);
110
+ if (!body) continue;
111
+ // --no-foo => foo=false
112
+ if (body.startsWith('no-')) {
113
+ const key = body.slice(3);
114
+ if (key) flags[key] = false;
115
+ continue;
116
+ }
117
+ const eqIdx = body.indexOf('=');
118
+ if (eqIdx === -1) {
119
+ flags[body] = true;
120
+ } else {
121
+ const key = body.slice(0, eqIdx);
122
+ const value = body.slice(eqIdx + 1).toLowerCase();
123
+ flags[key] = !(value === 'false' || value === '0' || value === 'no' || value === 'off');
124
+ }
125
+ } else {
126
+ positionals.push(arg);
127
+ }
128
+ }
129
+ return { positionals, flags };
130
+ }
131
+
132
+ /**
133
+ * Issue #1805: Spawner used by the merge queue's auto-resolve pass. For each
134
+ * skipped PR we dispatch a `solve <pr-url> --auto-merge` session through
135
+ * the same `start-screen` runtime the bot uses everywhere else. Keeping this
136
+ * in one place means the per-PR sessions behave exactly like any other
137
+ * `/solve` invocation (same logs, same /watch, same isolation backend).
138
+ *
139
+ * @param {Object} target - Info for the conflicted PR.
140
+ * @param {string} target.url - PR HTML URL passed to `solve`.
141
+ * @param {boolean} verbose - Forwarded to the underlying spawn.
142
+ * @returns {Promise<{ success: boolean, sessionName: string|null, error: string|null, warning: string|null }>}
143
+ */
144
+ async function spawnAutoResolveSolve(target, verbose) {
145
+ if (!target || !target.url) {
146
+ return { success: false, sessionName: null, error: 'missing PR URL', warning: null };
147
+ }
148
+ const args = [target.url, '--auto-merge'];
149
+ try {
150
+ const result = await executeStartScreen('solve', args, { verbose });
151
+ if (result.warning) {
152
+ return { success: false, sessionName: null, error: null, warning: result.warning };
153
+ }
154
+ if (!result.success) {
155
+ return { success: false, sessionName: null, error: result.error || 'spawn failed', warning: null };
156
+ }
157
+ const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
158
+ const sessionName = match ? match[1] : null;
159
+ return { success: true, sessionName, error: null, warning: null };
160
+ } catch (error) {
161
+ return { success: false, sessionName: null, error: error.message || String(error), warning: null };
162
+ }
163
+ }
164
+
93
165
  /**
94
166
  * Format user-friendly error message
95
167
  * Hides debug info unless verbose mode is enabled
@@ -175,13 +247,17 @@ export function registerMergeCommand(bot, options) {
175
247
 
176
248
  // Parse arguments
177
249
  const args = parseCommandArgs(ctx.message.text);
250
+ // Issue #1805: split positional args from `--auto-resolve` style flags so
251
+ // the repository URL parsing still sees only the URL token.
252
+ const { positionals, flags } = parseMergeArgs(args);
253
+ const autoResolve = flags['auto-resolve'] === true;
178
254
 
179
- if (args.length === 0) {
180
- return await ctx.reply("Missing repository URL\\.\n\nUsage: `/merge <repository-url>`\n\nExample: `/merge https://github.com/owner/repo`\n\nThis will merge all PRs with the 'ready' label, one by one, waiting for CI/CD between each merge\\.", { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
255
+ if (positionals.length === 0) {
256
+ return await ctx.reply("Missing repository URL\\.\n\nUsage: `/merge <repository-url> [--auto-resolve]`\n\nExample: `/merge https://github.com/owner/repo`\n\nThis will merge all PRs with the 'ready' label, one by one, waiting for CI/CD between each merge\\.\n\nWith `--auto-resolve` the bot also dispatches `/solve <pr> --auto-merge` for every PR that was skipped because of merge conflicts\\.", { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
181
257
  }
182
258
 
183
259
  // Parse and validate repository URL
184
- const repoUrl = args[0];
260
+ const repoUrl = positionals[0];
185
261
  const parsedUrl = parseRepositoryUrl(repoUrl);
186
262
 
187
263
  if (!parsedUrl.valid) {
@@ -234,6 +310,11 @@ export function registerMergeCommand(bot, options) {
234
310
  // Create the merge queue processor
235
311
  const processor = await createMergeQueueProcessor(owner, repo, {
236
312
  verbose: VERBOSE,
313
+ // Issue #1805: forward the --auto-resolve flag and inject the spawner.
314
+ // The processor only sees the callback, so unit tests can stub it
315
+ // without spawning real screen sessions.
316
+ autoResolve,
317
+ spawnSolveSession: autoResolve ? target => spawnAutoResolveSolve(target, VERBOSE) : null,
237
318
  onProgress: async () => {
238
319
  // Update message with progress and cancel button
239
320
  try {
@@ -44,8 +44,24 @@ export const MergeItemStatus = {
44
44
  MERGED: 'merged',
45
45
  FAILED: 'failed',
46
46
  SKIPPED: 'skipped',
47
+ // Issue #1805: states reached during the post-queue `--auto-resolve` pass.
48
+ // RESOLVING is set while a `/solve <pr> --auto-merge` session is being
49
+ // spawned for a previously-skipped PR; RESOLVE_FAILED records that the
50
+ // spawn (or the resolution itself) didn't succeed.
51
+ RESOLVING: 'resolving',
52
+ RESOLVE_FAILED: 'resolve_failed',
47
53
  };
48
54
 
55
+ /**
56
+ * Marker that identifies SKIPPED items that the auto-resolve pass should
57
+ * pick up. The same string is returned by `checkPRMergeable()` for
58
+ * `mergeStateStatus === 'DIRTY'` (see github-merge.lib.mjs), so matching
59
+ * on it keeps the two modules in sync without sharing extra state.
60
+ *
61
+ * @see https://github.com/link-assistant/hive-mind/issues/1805
62
+ */
63
+ export const MERGE_CONFLICT_SKIP_REASON = 'PR has merge conflicts';
64
+
49
65
  /**
50
66
  * Configuration for merge queue operations
51
67
  * Values are loaded from config.lib.mjs which supports environment variable overrides.
@@ -134,6 +150,11 @@ class MergeQueueItem {
134
150
  return '❌';
135
151
  case MergeItemStatus.SKIPPED:
136
152
  return '⏭️';
153
+ // Issue #1805: auto-resolve pass states.
154
+ case MergeItemStatus.RESOLVING:
155
+ return '🛠️';
156
+ case MergeItemStatus.RESOLVE_FAILED:
157
+ return '⚠️';
137
158
  default:
138
159
  return '❓';
139
160
  }
@@ -152,6 +173,12 @@ export class MergeQueueProcessor {
152
173
  this.onProgress = options.onProgress || null;
153
174
  this.onComplete = options.onComplete || null;
154
175
  this.onError = options.onError || null;
176
+ // Issue #1805: when true the queue runs a second pass after the normal
177
+ // merge loop, spawning `/solve <pr> --auto-merge` for every PR that was
178
+ // SKIPPED due to merge conflicts. The actual spawner is injected so
179
+ // tests can run without touching the screen runtime.
180
+ this.autoResolve = options.autoResolve === true;
181
+ this.spawnSolveSession = typeof options.spawnSolveSession === 'function' ? options.spawnSolveSession : null;
155
182
 
156
183
  // State
157
184
  this.items = [];
@@ -161,6 +188,9 @@ export class MergeQueueProcessor {
161
188
  this.startedAt = null;
162
189
  this.completedAt = null;
163
190
  this.error = null;
191
+ // Issue #1805: track auto-resolve progress so the renderer can surface it.
192
+ this.autoResolveActive = false;
193
+ this.autoResolveCurrent = null;
164
194
 
165
195
  // Statistics
166
196
  this.stats = {
@@ -168,6 +198,11 @@ export class MergeQueueProcessor {
168
198
  merged: 0,
169
199
  failed: 0,
170
200
  skipped: 0,
201
+ // Issue #1805: number of skipped conflict PRs the auto-resolve pass
202
+ // successfully handed off to `solve`, and the number that failed to
203
+ // be handed off (e.g. screen runner missing).
204
+ autoResolved: 0,
205
+ autoResolveFailed: 0,
171
206
  };
172
207
  }
173
208
 
@@ -329,6 +364,15 @@ export class MergeQueueProcessor {
329
364
  }
330
365
  }
331
366
 
367
+ // Issue #1805: After the normal queue settles, optionally hand off
368
+ // every PR that was SKIPPED with a merge-conflict reason to the
369
+ // `/solve <pr> --auto-merge` flow. This lets a single `/merge`
370
+ // invocation both merge the easy PRs and dispatch conflict-resolution
371
+ // sessions for the rest.
372
+ if (this.autoResolve && !this.isCancelled) {
373
+ await this.runAutoResolve();
374
+ }
375
+
332
376
  this.completedAt = new Date();
333
377
  this.status = this.isCancelled ? MergeStatus.CANCELLED : MergeStatus.COMPLETED;
334
378
 
@@ -483,6 +527,100 @@ export class MergeQueueProcessor {
483
527
  this.log('Cancellation requested');
484
528
  }
485
529
 
530
+ /**
531
+ * Issue #1805: Return queue items that were skipped because of merge
532
+ * conflicts. These are the candidates the auto-resolve pass hands off
533
+ * to `/solve <pr> --auto-merge`.
534
+ *
535
+ * @returns {MergeQueueItem[]}
536
+ */
537
+ getConflictedItems() {
538
+ return this.items.filter(item => item.status === MergeItemStatus.SKIPPED && item.error === MERGE_CONFLICT_SKIP_REASON);
539
+ }
540
+
541
+ /**
542
+ * Issue #1805: Iterate every conflict-skipped item and hand it off to a
543
+ * `/solve <pr-url> --auto-merge` session via the injected
544
+ * `spawnSolveSession` callback. Each spawn is awaited so the bot doesn't
545
+ * fan out unbounded Claude sessions. The PR's status is updated as the
546
+ * spawn succeeds or fails.
547
+ *
548
+ * @returns {Promise<void>}
549
+ */
550
+ async runAutoResolve() {
551
+ const conflicted = this.getConflictedItems();
552
+ if (conflicted.length === 0) {
553
+ this.log('Auto-resolve: no merge-conflict skips to process');
554
+ return;
555
+ }
556
+
557
+ if (!this.spawnSolveSession) {
558
+ // Guard against misconfiguration — the queue can't resolve without a
559
+ // spawner. Surface this to the user via the same channel as other
560
+ // queue feedback rather than throwing.
561
+ this.log(`Auto-resolve: ${conflicted.length} conflict PR(s) but no spawnSolveSession callback provided`);
562
+ for (const item of conflicted) {
563
+ item.status = MergeItemStatus.RESOLVE_FAILED;
564
+ item.autoResolveError = 'auto-resolve is not configured';
565
+ this.stats.autoResolveFailed++;
566
+ }
567
+ if (this.onProgress) {
568
+ await this.onProgress(this.getProgressUpdate());
569
+ }
570
+ return;
571
+ }
572
+
573
+ this.autoResolveActive = true;
574
+ this.log(`Auto-resolve: dispatching ${conflicted.length} conflict PR(s) to /solve --auto-merge`);
575
+ try {
576
+ for (const item of conflicted) {
577
+ if (this.isCancelled) {
578
+ this.log('Auto-resolve: cancelled mid-pass');
579
+ break;
580
+ }
581
+
582
+ item.status = MergeItemStatus.RESOLVING;
583
+ this.autoResolveCurrent = item.pr.number;
584
+ if (this.onProgress) {
585
+ await this.onProgress(this.getProgressUpdate());
586
+ }
587
+
588
+ try {
589
+ const result = await this.spawnSolveSession({
590
+ url: item.pr.url,
591
+ owner: this.owner,
592
+ repo: this.repo,
593
+ prNumber: item.pr.number,
594
+ title: item.pr.title,
595
+ });
596
+
597
+ if (result && result.success) {
598
+ item.autoResolveSession = result.sessionName || result.session || null;
599
+ this.stats.autoResolved++;
600
+ this.log(`Auto-resolve: spawned solve session for PR #${item.pr.number}${item.autoResolveSession ? ` (session ${item.autoResolveSession})` : ''}`);
601
+ } else {
602
+ item.status = MergeItemStatus.RESOLVE_FAILED;
603
+ item.autoResolveError = (result && (result.error || result.warning)) || 'spawn failed';
604
+ this.stats.autoResolveFailed++;
605
+ this.log(`Auto-resolve: failed to spawn solve session for PR #${item.pr.number}: ${item.autoResolveError}`);
606
+ }
607
+ } catch (error) {
608
+ item.status = MergeItemStatus.RESOLVE_FAILED;
609
+ item.autoResolveError = error.message || String(error);
610
+ this.stats.autoResolveFailed++;
611
+ console.error(`[ERROR] /merge-queue: auto-resolve error for PR #${item.pr.number}: ${item.autoResolveError}`);
612
+ }
613
+
614
+ if (this.onProgress) {
615
+ await this.onProgress(this.getProgressUpdate());
616
+ }
617
+ }
618
+ } finally {
619
+ this.autoResolveActive = false;
620
+ this.autoResolveCurrent = null;
621
+ }
622
+ }
623
+
486
624
  /**
487
625
  * Wait for any active CI runs on the target branch to complete
488
626
  * Issue #1307: Prevents merging while post-merge CI from previous merges is still running
@@ -658,6 +796,13 @@ export class MergeQueueProcessor {
658
796
  status: this.status,
659
797
  current: currentItem ? currentItem.getDescription() : null,
660
798
  currentStatus: currentItem ? currentItem.status : null,
799
+ // Issue #1805: surface auto-resolve progress so renderers/tests can
800
+ // show what's happening during the post-queue pass.
801
+ autoResolve: {
802
+ enabled: this.autoResolve,
803
+ active: this.autoResolveActive,
804
+ currentPrNumber: this.autoResolveCurrent,
805
+ },
661
806
  progress: {
662
807
  processed,
663
808
  total: this.stats.total,
@@ -666,10 +811,17 @@ export class MergeQueueProcessor {
666
811
  stats: { ...this.stats },
667
812
  items: this.items.map(item => ({
668
813
  prNumber: item.pr.number,
814
+ // Issue #1805: expose PR/issue URLs so renderers can produce
815
+ // clickable links instead of plain `\#NNN` text.
816
+ prUrl: item.pr.url || null,
817
+ issueNumber: item.issue ? item.issue.number : null,
818
+ issueUrl: item.issue ? item.issue.url || null : null,
669
819
  title: item.pr.title,
670
820
  status: item.status,
671
821
  error: item.error,
672
822
  emoji: item.getStatusEmoji(),
823
+ autoResolveSession: item.autoResolveSession || null,
824
+ autoResolveError: item.autoResolveError || null,
673
825
  })),
674
826
  };
675
827
  }
@@ -684,13 +836,24 @@ export class MergeQueueProcessor {
684
836
  status: this.status,
685
837
  duration: `${Math.floor(duration / 60)}m ${duration % 60}s`,
686
838
  stats: { ...this.stats },
839
+ autoResolve: {
840
+ enabled: this.autoResolve,
841
+ resolved: this.stats.autoResolved,
842
+ failed: this.stats.autoResolveFailed,
843
+ },
687
844
  items: this.items.map(item => ({
688
845
  prNumber: item.pr.number,
846
+ // Issue #1805: expose PR/issue URLs so renderers can produce
847
+ // clickable links instead of plain `\#NNN` text.
848
+ prUrl: item.pr.url || null,
689
849
  title: item.pr.title,
690
850
  issueNumber: item.issue ? item.issue.number : null,
851
+ issueUrl: item.issue ? item.issue.url || null : null,
691
852
  status: item.status,
692
853
  error: item.error,
693
854
  emoji: item.getStatusEmoji(),
855
+ autoResolveSession: item.autoResolveSession || null,
856
+ autoResolveError: item.autoResolveError || null,
694
857
  })),
695
858
  };
696
859
  }
@@ -748,11 +911,25 @@ export class MergeQueueProcessor {
748
911
  message += `⏱️ Waiting for post\\-merge CI \\(PR \\#${this.currentPostMergePR}\\)\\.\\.\\.\n\n`;
749
912
  }
750
913
 
751
- // Current item being processed
752
- if (update.current && !this.waitingForTargetBranchCI && !this.waitingForPostMergeCI) {
914
+ // Issue #1805: surface the auto-resolve pass when it is currently
915
+ // active. This appears in place of "current item" because by then the
916
+ // main queue loop has finished.
917
+ if (update.autoResolve && update.autoResolve.active && update.autoResolve.currentPrNumber) {
918
+ const activeItem = update.items.find(it => it.prNumber === update.autoResolve.currentPrNumber);
919
+ const link = activeItem ? this.formatPrLink(activeItem.prNumber, activeItem.title, activeItem.prUrl) : `\\#${update.autoResolve.currentPrNumber}`;
920
+ message += `🛠️ Auto\\-resolving ${link}\n\n`;
921
+ } else if (update.current && !this.waitingForTargetBranchCI && !this.waitingForPostMergeCI) {
922
+ // Current item being processed
753
923
  const statusEmoji = update.currentStatus === MergeItemStatus.WAITING_CI ? '⏱️' : '🔄';
754
- // Issue #1339: escape the current item description for MarkdownV2
755
- message += `${statusEmoji} ${this.escapeMarkdown(update.current)}\n\n`;
924
+ const currentItem = this.items[this.currentIndex];
925
+ if (currentItem) {
926
+ const link = this.formatPrLink(currentItem.pr.number, currentItem.pr.title, currentItem.pr.url);
927
+ const issueSuffix = this.formatIssueRef(currentItem.issue ? currentItem.issue.number : null, currentItem.issue ? currentItem.issue.url : null);
928
+ message += `${statusEmoji} ${link}${issueSuffix}\n\n`;
929
+ } else {
930
+ // Fallback: escape the description if we somehow don't have an item handle
931
+ message += `${statusEmoji} ${this.escapeMarkdown(update.current)}\n\n`;
932
+ }
756
933
  }
757
934
 
758
935
  // Show errors/failures/skips inline so user gets immediate feedback (Issue #1269, #1294)
@@ -762,8 +939,9 @@ export class MergeQueueProcessor {
762
939
  message += `⚠️ *Issues:*\n`;
763
940
  for (const item of problemItems.slice(0, 5)) {
764
941
  const statusEmoji = item.status === MergeItemStatus.FAILED ? '❌' : '⏭️';
765
- // Issue #1339: escape the ellipsis '...' for MarkdownV2 (periods are reserved)
766
- message += ` ${statusEmoji} \\#${item.prNumber}: ${this.escapeMarkdown(item.error.substring(0, 50))}${item.error.length > 50 ? '\\.\\.\\.' : ''}\n`;
942
+ // Issue #1805: emit the PR reference as a clickable link instead of plain text.
943
+ const prRef = this.formatPrLink(item.prNumber, '', item.prUrl);
944
+ message += ` ${statusEmoji} ${prRef}: ${this.escapeMarkdown(item.error.substring(0, 50))}${item.error.length > 50 ? '\\.\\.\\.' : ''}\n`;
767
945
  }
768
946
  if (problemItems.length > 5) {
769
947
  // Issue #1339: escape the ellipsis '...' for MarkdownV2
@@ -772,11 +950,10 @@ export class MergeQueueProcessor {
772
950
  message += '\n';
773
951
  }
774
952
 
775
- // PRs list with emojis
953
+ // PRs list with emojis (Issue #1805: render as clickable MarkdownV2 links)
776
954
  message += `*Queue:*\n`;
777
955
  for (const item of update.items.slice(0, 10)) {
778
- // Issue #1339: escape the ellipsis '...' for MarkdownV2 (periods are reserved)
779
- message += `${item.emoji} \\#${item.prNumber}: ${this.escapeMarkdown(item.title.substring(0, 35))}${item.title.length > 35 ? '\\.\\.\\.' : ''}\n`;
956
+ message += `${item.emoji} ${this.formatPrLink(item.prNumber, item.title, item.prUrl)}\n`;
780
957
  }
781
958
 
782
959
  if (update.items.length > 10) {
@@ -830,7 +1007,20 @@ export class MergeQueueProcessor {
830
1007
  message += `✅ Merged: ${report.stats.merged} `;
831
1008
  message += `❌ Failed: ${report.stats.failed} `;
832
1009
  message += `⏭️ Skipped: ${report.stats.skipped} `;
833
- message += `📋 Total: ${report.stats.total}\n\n`;
1010
+ message += `📋 Total: ${report.stats.total}\n`;
1011
+
1012
+ // Issue #1805: surface the auto-resolve pass summary when it ran. We
1013
+ // always show the line when the flag was set so users see "0 dispatched"
1014
+ // when there was nothing to do.
1015
+ if (report.autoResolve && report.autoResolve.enabled) {
1016
+ message += `🛠️ Auto\\-resolve dispatched: ${report.autoResolve.resolved}`;
1017
+ if (report.autoResolve.failed > 0) {
1018
+ message += ` ⚠️ Auto\\-resolve failed: ${report.autoResolve.failed}`;
1019
+ }
1020
+ message += '\n';
1021
+ }
1022
+
1023
+ message += '\n';
834
1024
 
835
1025
  // Issue #1341: Show branch CI health failure details if applicable
836
1026
  if (this.branchCIFailedRuns && this.branchCIFailedRuns.length > 0) {
@@ -862,19 +1052,25 @@ export class MergeQueueProcessor {
862
1052
  message += '\n';
863
1053
  }
864
1054
 
865
- // Details
1055
+ // Details (Issue #1805: render PR and issue references as clickable
1056
+ // MarkdownV2 links so the user can jump directly to the PR or issue).
866
1057
  if (report.items.length > 0) {
867
1058
  message += `*Results:*\n`;
868
1059
  for (const item of report.items) {
869
- const issueRef = item.issueNumber ? ` \\(Issue \\#${item.issueNumber}\\)` : '';
1060
+ const prLink = this.formatPrLink(item.prNumber, item.title, item.prUrl);
1061
+ const issueRef = this.formatIssueRef(item.issueNumber, item.issueUrl);
870
1062
  // Issue #1294: Show skip/fail reason so users understand what action is required
871
1063
  let reasonText = '';
872
- if (item.error && (item.status === MergeItemStatus.SKIPPED || item.status === MergeItemStatus.FAILED)) {
1064
+ const isAutoResolveState = item.status === MergeItemStatus.RESOLVING || item.status === MergeItemStatus.RESOLVE_FAILED;
1065
+ if (item.autoResolveError && isAutoResolveState) {
1066
+ const truncated = item.autoResolveError.length > 50 ? item.autoResolveError.substring(0, 47) + '...' : item.autoResolveError;
1067
+ reasonText = ` \\(${this.escapeMarkdown(truncated)}\\)`;
1068
+ } else if (item.error && (item.status === MergeItemStatus.SKIPPED || item.status === MergeItemStatus.FAILED)) {
873
1069
  // Truncate long reasons and escape for MarkdownV2
874
1070
  const truncatedReason = item.error.length > 50 ? item.error.substring(0, 47) + '...' : item.error;
875
1071
  reasonText = `: ${this.escapeMarkdown(truncatedReason)}`;
876
1072
  }
877
- message += `${item.emoji} \\#${item.prNumber}${issueRef}${reasonText}\n`;
1073
+ message += `${item.emoji} ${prLink}${issueRef}${reasonText}\n`;
878
1074
  }
879
1075
  }
880
1076
 
@@ -888,6 +1084,43 @@ export class MergeQueueProcessor {
888
1084
  return String(text).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
889
1085
  }
890
1086
 
1087
+ /**
1088
+ * Issue #1805: Escape `)` and `\` inside a URL for a MarkdownV2 inline link.
1089
+ * URLs must NOT be passed through `escapeMarkdown()` because that would also
1090
+ * mangle characters that are valid inside URLs (`.`, `-`, `_`, etc.).
1091
+ */
1092
+ escapeMarkdownLinkUrl(url) {
1093
+ return String(url).replace(/[\\)]/g, '\\$&');
1094
+ }
1095
+
1096
+ /**
1097
+ * Issue #1805: Build a clickable MarkdownV2 link for a PR's `\#N: title`
1098
+ * reference. Falls back to plain escaped text when no URL is available so
1099
+ * the message still renders correctly on legacy items.
1100
+ */
1101
+ formatPrLink(prNumber, title, url, options = {}) {
1102
+ const maxTitle = typeof options.maxTitle === 'number' ? options.maxTitle : 35;
1103
+ const trimmedTitle = title || '';
1104
+ const truncated = trimmedTitle.length > maxTitle ? trimmedTitle.substring(0, maxTitle) : trimmedTitle;
1105
+ const ellipsis = trimmedTitle.length > maxTitle ? '\\.\\.\\.' : '';
1106
+ const titlePart = trimmedTitle ? `: ${this.escapeMarkdown(truncated)}${ellipsis}` : '';
1107
+ const label = `\\#${prNumber}${titlePart}`;
1108
+ if (!url) return label;
1109
+ return `[${label}](${this.escapeMarkdownLinkUrl(url)})`;
1110
+ }
1111
+
1112
+ /**
1113
+ * Issue #1805: Build the ` (Issue #N)` suffix as a clickable link. The
1114
+ * outer parentheses are literal MarkdownV2 (escaped), so the inner inline
1115
+ * link is not nested inside another entity.
1116
+ */
1117
+ formatIssueRef(issueNumber, url) {
1118
+ if (!issueNumber) return '';
1119
+ const label = `Issue \\#${issueNumber}`;
1120
+ if (!url) return ` \\(${label}\\)`;
1121
+ return ` \\([${label}](${this.escapeMarkdownLinkUrl(url)})\\)`;
1122
+ }
1123
+
891
1124
  /**
892
1125
  * Sleep helper
893
1126
  */