@link-assistant/hive-mind 1.31.3 → 1.32.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,39 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.32.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b2c94db: Support all options via /solve command when replying to a message containing a GitHub link (issue #1325)
8
+
9
+ Previously, `/solve` as a reply only worked when used without any arguments. Now users can reply to a message containing a GitHub issue/PR link with `/solve --model opus` or any other options, and the bot will:
10
+ 1. Extract the GitHub URL from the replied message
11
+ 2. Use the provided options
12
+ 3. Execute the solve command with both the extracted URL and the user-provided options
13
+
14
+ ## 1.31.4
15
+
16
+ ### Patch Changes
17
+
18
+ - Extract large inline script blocks from release.yml into ./scripts/ to fix CI line-limit violation (issue #1428)
19
+
20
+ fix: configure release pipeline to react to docker=true so Dockerfile changes trigger Docker image rebuild (Issue #1423)
21
+
22
+ Previously, commits that changed only `Dockerfile` or `coolify/Dockerfile` produced `docker=true` but `code=false`. The `release` job required all test jobs to `succeed` — but those tests were correctly skipped (no JavaScript code changed). Since `skipped != 'success'`, the release job was also skipped, and no Docker image was rebuilt.
23
+
24
+ This was observed when PR #1420 (fixing `/home/hive/.config` ownership) was merged: both Dockerfiles changed, but CI run `23040959919` showed all Docker publish jobs as skipped.
25
+
26
+ The `release` job condition is now updated to:
27
+ - Also trigger when `docker-changed == 'true'` (not only `code=true`)
28
+ - Accept `skipped` as well as `success` for test/lint jobs (skipped = intentionally not run, not a failure)
29
+ - Block on any actual job `failure`
30
+
31
+ This directly configures CI/CD to react to `docker=true` — without misclassifying Dockerfiles as "code" files.
32
+
33
+ Full root cause analysis and timeline in `docs/case-studies/issue-1423/`.
34
+
35
+ Migrate GitHub Actions to Node.js 24 compatible versions to eliminate deprecation warnings before the June 2026 deadline
36
+
3
37
  ## 1.31.3
4
38
 
5
39
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.31.3",
3
+ "version": "1.32.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",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "homepage": "https://github.com/link-assistant/hive-mind#readme",
49
49
  "engines": {
50
- "node": ">=18.0.0"
50
+ "node": ">=24.0.0"
51
51
  },
52
52
  "files": [
53
53
  "src",
@@ -18,7 +18,9 @@ if (typeof globalThis.use === 'undefined') {
18
18
  }
19
19
  }
20
20
 
21
- const getenv = await use('getenv');
21
+ const getenvModule = await use('getenv');
22
+ // Node 24 CJS/ESM interop may return the whole module object instead of the function directly
23
+ const getenv = typeof getenvModule === 'function' ? getenvModule : getenvModule.default || getenvModule;
22
24
 
23
25
  // Use semver package for version comparison (see issue #1146)
24
26
  import semver from 'semver';
@@ -516,9 +516,7 @@ export async function checkMergePermissions(owner, repo, verbose = false) {
516
516
  * @param {string} repo - Repository name
517
517
  * @param {number} prNumber - Pull request number
518
518
  * @param {Object} options - Merge options
519
- * @param {string} options.mergeMethod - Merge method: 'merge', 'squash', or 'rebase' (default: 'merge')
520
- * Note: Must specify one method when running non-interactively.
521
- * See Issue #1269 for details.
519
+ * @param {string} options.mergeMethod - Merge method: 'merge', 'squash', or 'rebase' (default: 'merge'). Must specify one method non-interactively (Issue #1269).
522
520
  * @param {boolean} options.squash - DEPRECATED: Use mergeMethod: 'squash' instead
523
521
  * @param {boolean} options.deleteAfter - Whether to delete branch after merge (default: false)
524
522
  * @param {boolean} verbose - Whether to log verbose output
package/src/hive.mjs CHANGED
@@ -20,8 +20,10 @@ if (earlyArgs.includes('--help') || earlyArgs.includes('-h')) {
20
20
  globalThis.use = use;
21
21
  const yargsModule = await use('yargs@17.7.2');
22
22
  const yargs = yargsModule.default || yargsModule;
23
- const { hideBin } = await use('yargs@17.7.2/helpers');
24
- const rawArgs = hideBin(process.argv);
23
+ const helpersModuleHelp = await use('yargs@17.7.2/helpers');
24
+ const _helpersHelp = helpersModuleHelp.default || helpersModuleHelp;
25
+ const hideBinHelp = _helpersHelp.hideBin || (argv => argv.slice(2));
26
+ const rawArgs = hideBinHelp(process.argv);
25
27
  // Reuse createYargsConfig from shared module to avoid duplication
26
28
  const { createYargsConfig } = await import('./hive.config.lib.mjs');
27
29
  const helpYargs = createYargsConfig(yargs(rawArgs)).version(false);
@@ -86,7 +88,9 @@ if (isDirectExecution) {
86
88
  );
87
89
  const yargsModule = await withTimeout(use('yargs@17.7.2'), 30000, 'loading yargs');
88
90
  const yargs = yargsModule.default || yargsModule;
89
- const { hideBin } = await withTimeout(use('yargs@17.7.2/helpers'), 30000, 'loading yargs helpers');
91
+ const helpersModuleMain = await withTimeout(use('yargs@17.7.2/helpers'), 30000, 'loading yargs helpers');
92
+ const _helpersMain = helpersModuleMain.default || helpersModuleMain;
93
+ const hideBin = _helpersMain.hideBin || (argv => argv.slice(2));
90
94
  const path = (await withTimeout(use('path'), 30000, 'loading path')).default;
91
95
  const fs = (await withTimeout(use('fs'), 30000, 'loading fs')).promises;
92
96
  // Import shared library functions
@@ -19,7 +19,10 @@ const $silent = $({ mirror: false, capture: true });
19
19
 
20
20
  const yargsModule = await use('yargs@17.7.2');
21
21
  const yargs = yargsModule.default || yargsModule;
22
- const { hideBin } = await use('yargs@17.7.2/helpers');
22
+ const helpersModule = await use('yargs@17.7.2/helpers');
23
+ // Node 24 CJS/ESM interop may return the whole module object instead of named exports directly
24
+ const _helpers = helpersModule.default || helpersModule;
25
+ const hideBin = _helpers.hideBin || (argv => argv.slice(2));
23
26
  const fs = (await use('fs')).promises;
24
27
 
25
28
  // Import log function from lib.mjs
@@ -36,7 +36,9 @@ if (typeof globalThis.use === 'undefined') {
36
36
  }
37
37
  }
38
38
 
39
- const getenv = await use('getenv');
39
+ const getenvModule = await use('getenv');
40
+ // Node 24 CJS/ESM interop may return the whole module object instead of the function directly
41
+ const getenv = typeof getenvModule === 'function' ? getenvModule : getenvModule.default || getenvModule;
40
42
  const linoModule = await use('links-notation');
41
43
  const LinoParser = linoModule.Parser || linoModule.default?.Parser;
42
44
 
@@ -17,7 +17,10 @@ export const initializeConfig = async use => {
17
17
  // Import yargs with specific version for hideBin support
18
18
  const yargsModule = await use('yargs@17.7.2');
19
19
  const yargs = yargsModule.default || yargsModule;
20
- const { hideBin } = await use('yargs@17.7.2/helpers');
20
+ const helpersModule = await use('yargs@17.7.2/helpers');
21
+ // Node 24 CJS/ESM interop may return the whole module object instead of named exports directly
22
+ const helpers = helpersModule.default || helpersModule;
23
+ const hideBin = helpers.hideBin || (argv => argv.slice(2));
21
24
 
22
25
  return { yargs, hideBin };
23
26
  };
package/src/solve.mjs CHANGED
@@ -77,8 +77,7 @@ const { startAutoRestartUntilMergeable } = await import('./solve.auto-merge.lib.
77
77
  const { runAutoEnsureRequirements } = await import('./solve.auto-ensure.lib.mjs');
78
78
  const exitHandler = await import('./exit-handler.lib.mjs');
79
79
  const { initializeExitHandler, installGlobalExitHandlers, safeExit } = exitHandler;
80
- const interruptLib = await import('./solve.interrupt.lib.mjs');
81
- const { createInterruptWrapper } = interruptLib;
80
+ const { createInterruptWrapper } = await import('./solve.interrupt.lib.mjs');
82
81
  const getResourceSnapshot = memoryCheck.getResourceSnapshot;
83
82
 
84
83
  // Import new modular components
@@ -99,12 +98,10 @@ const { validateAndExitOnInvalidModel } = modelValidation;
99
98
  const acceptInviteLib = await import('./solve.accept-invite.lib.mjs');
100
99
  const { autoAcceptInviteForRepo } = acceptInviteLib;
101
100
 
102
- // Initialize log file EARLY to capture all output including version and command
103
- // Use default directory (cwd) initially, will be set from argv.logDir after parsing
101
+ // Initialize log file EARLY (use cwd initially, will be updated after argv parsing)
104
102
  const logFile = await initializeLogFile(null);
105
103
 
106
- // Log version and raw command IMMEDIATELY after log file initialization
107
- // This ensures they appear in both console and log file, even if argument parsing fails
104
+ // Log version and raw command IMMEDIATELY after log file initialization (ensures they appear even if parsing fails)
108
105
  const versionInfo = await getVersionInfo();
109
106
  await log('');
110
107
  await log(`🚀 solve v${versionInfo}`);
@@ -125,9 +122,7 @@ try {
125
122
  }
126
123
  global.verboseMode = argv.verbose;
127
124
 
128
- // If user specified a custom log directory, we would need to move the log file
129
- // However, this adds complexity, so we accept that early logs go to cwd
130
- // The trade-off is: early logs in cwd vs missing version/command in error cases
125
+ // Early logs go to cwd; custom log dir takes effect after argv is parsed
131
126
 
132
127
  // Conditionally import tool-specific functions after argv is parsed
133
128
  let checkForUncommittedChanges;
@@ -190,7 +185,6 @@ if (!urlValidation.isValid) {
190
185
  }
191
186
  const { isIssueUrl, isPrUrl, normalizedUrl, owner, repo, number: urlNumber } = urlValidation;
192
187
  issueUrl = normalizedUrl || issueUrl;
193
- // Store owner and repo globally for error handlers and interrupt context
194
188
  global.owner = owner;
195
189
  global.repo = repo;
196
190
  cleanupContext.owner = owner;
@@ -225,9 +219,7 @@ if (!(await validateContinueOnlyOnFeedback(argv, isPrUrl, isIssueUrl))) {
225
219
  const tool = argv.tool || 'claude';
226
220
  await validateAndExitOnInvalidModel(argv.model, tool, safeExit);
227
221
 
228
- // Perform all system checks using validation module
229
- // Skip tool CONNECTION validation in dry-run mode or when --skip-tool-connection-check or --no-tool-connection-check is enabled
230
- // Note: This does NOT skip model validation which is performed above
222
+ // Perform all system checks (skip tool connection check in dry-run or when --skip-tool-connection-check; model validation always runs)
231
223
  const skipToolConnectionCheck = argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
232
224
  if (!(await performSystemChecks(argv.minDiskSpace || 2048, skipToolConnectionCheck, argv.model, argv))) {
233
225
  await safeExit(1, 'System checks failed');
@@ -240,9 +232,7 @@ if (argv.verbose) {
240
232
  await log(` Is PR URL: ${!!isPrUrl}`, { verbose: true });
241
233
  }
242
234
  const claudePath = argv.executeToolWithBun ? 'bunx claude' : process.env.CLAUDE_PATH || 'claude';
243
- // Note: owner, repo, and urlNumber are already extracted from validateGitHubUrl() above
244
- // The parseUrlComponents() call was removed as it had a bug with hash fragments (#issuecomment-xyz)
245
- // and the validation result already provides these values correctly parsed
235
+ // Note: owner, repo, and urlNumber are extracted from validateGitHubUrl() above (parseUrlComponents() removed due to hash fragment bug)
246
236
 
247
237
  // Handle --auto-fork option: automatically fork public repositories without write access
248
238
  if (argv.autoFork && !argv.fork) {
@@ -517,17 +507,13 @@ if (isPrUrl) {
517
507
  issueNumber = urlNumber;
518
508
  await log(`📝 Issue mode: Working with issue #${issueNumber}`);
519
509
  }
520
- // Create or find temporary directory for cloning the repository
521
- // Pass workspace info for --enable-workspaces mode (works with all tools)
522
510
  const workspaceInfo = argv.enableWorkspaces ? { owner, repo, issueNumber } : null;
523
511
  const { tempDir, workspaceTmpDir, needsClone } = await setupTempDirectory(argv, workspaceInfo);
524
- // Populate cleanup context for signal handlers (owner/repo updated again here for redundancy)
525
512
  cleanupContext.tempDir = tempDir;
526
513
  cleanupContext.argv = argv;
527
514
  cleanupContext.owner = owner;
528
515
  cleanupContext.repo = repo;
529
516
  if (prNumber) cleanupContext.prNumber = prNumber;
530
- // Initialize limitReached variable outside try block for finally clause
531
517
  let limitReached = false;
532
518
  try {
533
519
  // Set up repository and clone using the new module
@@ -24,7 +24,9 @@ const { loadLenvConfig } = await import('./lenv-reader.lib.mjs');
24
24
  const dotenvxModule = await use('@dotenvx/dotenvx');
25
25
  const dotenvx = dotenvxModule.default || dotenvxModule;
26
26
 
27
- const getenv = await use('getenv');
27
+ const getenvModule = await use('getenv');
28
+ // Node 24 CJS/ESM interop may return the whole module object instead of the function directly
29
+ const getenv = typeof getenvModule === 'function' ? getenvModule : getenvModule.default || getenvModule;
28
30
 
29
31
  // Load .env configuration as base
30
32
  // quiet: true suppresses info messages, ignore: ['MISSING_ENV_FILE'] suppresses error when .env doesn't exist
@@ -37,7 +39,10 @@ loadLenvConfig({ override: true, quiet: true });
37
39
 
38
40
  const yargsModule = await use('yargs@17.7.2');
39
41
  const yargs = yargsModule.default || yargsModule;
40
- const { hideBin } = await use('yargs@17.7.2/helpers');
42
+ const helpersModuleBot = await use('yargs@17.7.2/helpers');
43
+ // Node 24 CJS/ESM interop may return the whole module object instead of named exports directly
44
+ const _helpersBot = helpersModuleBot.default || helpersModuleBot;
45
+ const hideBin = _helpersBot.hideBin || (argv => argv.slice(2));
41
46
  // Import yargs configurations, GitHub utilities, and telegram helpers
42
47
  const { createYargsConfig: createSolveYargsConfig, detectMalformedFlags } = await import('./solve.config.lib.mjs');
43
48
  const { createYargsConfig: createHiveYargsConfig } = await import('./hive.config.lib.mjs');
@@ -47,7 +52,7 @@ const { formatUsageMessage, getAllCachedLimits } = await import('./limits.lib.mj
47
52
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
48
53
  const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
49
54
  const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
50
- const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText } = await import('./telegram-message-filters.lib.mjs');
55
+ const { isOldMessage: _isOldMessage, isGroupChat: _isGroupChat, isChatAuthorized: _isChatAuthorized, isForwardedOrReply: _isForwardedOrReply, extractCommandFromText, extractGitHubUrl: _extractGitHubUrl } = await import('./telegram-message-filters.lib.mjs');
51
56
  // Import bot launcher with exponential backoff retry (issue #1240)
52
57
  const { launchBotWithRetry } = await import('./telegram-bot-launcher.lib.mjs');
53
58
 
@@ -308,10 +313,6 @@ function isOldMessage(ctx) {
308
313
  return _isOldMessage(ctx, BOT_START_TIME, { verbose: VERBOSE });
309
314
  }
310
315
 
311
- function isGroupChat(ctx) {
312
- return _isGroupChat(ctx);
313
- }
314
-
315
316
  function isForwardedOrReply(ctx) {
316
317
  return _isForwardedOrReply(ctx, { verbose: VERBOSE });
317
318
  }
@@ -591,46 +592,6 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
591
592
  }
592
593
  }
593
594
 
594
- /**
595
- * Extract GitHub issue/PR URL from message text
596
- * Validates that message contains exactly one GitHub issue/PR link
597
- *
598
- * @param {string} text - Message text to search
599
- * @returns {{ url: string|null, error: string|null, linkCount: number }}
600
- */
601
- function extractGitHubUrl(text) {
602
- if (!text || typeof text !== 'string') {
603
- return { url: null, error: null, linkCount: 0 };
604
- }
605
-
606
- text = cleanNonPrintableChars(text); // Clean non-printable chars before processing
607
- const words = text.split(/\s+/);
608
- const foundUrls = [];
609
-
610
- for (const word of words) {
611
- // Try to parse as GitHub URL
612
- const parsed = parseGitHubUrl(word);
613
-
614
- // Accept issue or PR URLs
615
- if (parsed.valid && (parsed.type === 'issue' || parsed.type === 'pull')) {
616
- foundUrls.push(parsed.normalized);
617
- }
618
- }
619
-
620
- // Check if multiple links were found
621
- if (foundUrls.length === 0) {
622
- return { url: null, error: null, linkCount: 0 };
623
- } else if (foundUrls.length === 1) {
624
- return { url: foundUrls[0], error: null, linkCount: 1 };
625
- } else {
626
- return {
627
- url: null,
628
- error: `Found ${foundUrls.length} GitHub links in the message. Please reply to a message with only one GitHub issue or PR link.`,
629
- linkCount: foundUrls.length,
630
- };
631
- }
632
- }
633
-
634
595
  bot.command('help', async ctx => {
635
596
  if (VERBOSE) {
636
597
  console.log('[VERBOSE] /help command received');
@@ -755,7 +716,7 @@ bot.command('limits', async ctx => {
755
716
  return;
756
717
  }
757
718
 
758
- if (!isGroupChat(ctx)) {
719
+ if (!_isGroupChat(ctx)) {
759
720
  if (VERBOSE) {
760
721
  console.log('[VERBOSE] /limits ignored: not a group chat');
761
722
  }
@@ -804,7 +765,7 @@ bot.command('version', async ctx => {
804
765
  data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
805
766
  });
806
767
  if (isOldMessage(ctx) || isForwardedOrReply(ctx)) return;
807
- if (!isGroupChat(ctx)) return await ctx.reply('❌ The /version command only works in group chats. Please add this bot to a group and make it an admin.', { reply_to_message_id: ctx.message.message_id });
768
+ if (!_isGroupChat(ctx)) return await ctx.reply('❌ The /version command only works in group chats. Please add this bot to a group and make it an admin.', { reply_to_message_id: ctx.message.message_id });
808
769
  const chatId = ctx.chat.id;
809
770
  if (!isChatAuthorized(chatId)) return await ctx.reply(`❌ This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, { reply_to_message_id: ctx.message.message_id });
810
771
  const fetchingMessage = await ctx.reply('🔄 Gathering version information...', {
@@ -822,7 +783,7 @@ registerAcceptInvitesCommand(bot, {
822
783
  VERBOSE,
823
784
  isOldMessage,
824
785
  isForwardedOrReply,
825
- isGroupChat,
786
+ isGroupChat: _isGroupChat,
826
787
  isChatAuthorized,
827
788
  addBreadcrumb,
828
789
  });
@@ -833,7 +794,7 @@ registerMergeCommand(bot, {
833
794
  VERBOSE,
834
795
  isOldMessage,
835
796
  isForwardedOrReply,
836
- isGroupChat,
797
+ isGroupChat: _isGroupChat,
837
798
  isChatAuthorized,
838
799
  addBreadcrumb,
839
800
  });
@@ -844,7 +805,7 @@ const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, {
844
805
  VERBOSE,
845
806
  isOldMessage,
846
807
  isForwardedOrReply,
847
- isGroupChat,
808
+ isGroupChat: _isGroupChat,
848
809
  isChatAuthorized,
849
810
  addBreadcrumb,
850
811
  getSolveQueue,
@@ -898,7 +859,7 @@ async function handleSolveCommand(ctx) {
898
859
  return;
899
860
  }
900
861
 
901
- if (!isGroupChat(ctx)) {
862
+ if (!_isGroupChat(ctx)) {
902
863
  if (VERBOSE) {
903
864
  console.log('[VERBOSE] /solve ignored: not a group chat');
904
865
  }
@@ -921,17 +882,23 @@ async function handleSolveCommand(ctx) {
921
882
 
922
883
  let userArgs = parseCommandArgs(ctx.message.text);
923
884
 
924
- // Check if this is a reply to a message and user didn't provide URL
885
+ // Check if this is a reply to a message and user didn't provide URL as first argument
925
886
  // In that case, try to extract GitHub URL from the replied message
887
+ // Issue #1325: Support all options via /solve command when replying (e.g., "/solve --model opus")
926
888
  const isReply = message.reply_to_message && message.reply_to_message.message_id && !message.reply_to_message.forum_topic_created;
927
889
 
928
- if (isReply && userArgs.length === 0) {
890
+ // Check if the first argument looks like a GitHub URL
891
+ // If not, we should try to extract the URL from the replied message
892
+ const firstArgIsUrl = userArgs.length > 0 && (userArgs[0].includes('github.com') || userArgs[0].match(/^https?:\/\//));
893
+
894
+ if (isReply && !firstArgIsUrl) {
929
895
  if (VERBOSE) {
930
- console.log('[VERBOSE] /solve is a reply without URL, extracting from replied message...');
896
+ console.log('[VERBOSE] /solve is a reply without URL in args, extracting from replied message...');
897
+ console.log('[VERBOSE] User args:', userArgs);
931
898
  }
932
899
 
933
900
  const replyText = message.reply_to_message.text || '';
934
- const extraction = extractGitHubUrl(replyText);
901
+ const extraction = _extractGitHubUrl(replyText, { parseGitHubUrl, cleanNonPrintableChars });
935
902
 
936
903
  if (extraction.error) {
937
904
  // Multiple links found
@@ -944,18 +911,18 @@ async function handleSolveCommand(ctx) {
944
911
  });
945
912
  return;
946
913
  } else if (extraction.url) {
947
- // Single link found
914
+ // Single link found - prepend it to existing user args (issue #1325)
948
915
  if (VERBOSE) {
949
916
  console.log('[VERBOSE] Extracted URL from reply:', extraction.url);
950
917
  }
951
- // Add the extracted URL as the first argument
952
- userArgs = [extraction.url];
918
+ // Prepend the extracted URL to user's options (e.g., ['--model', 'opus'] -> ['url', '--model', 'opus'])
919
+ userArgs = [extraction.url, ...userArgs];
953
920
  } else {
954
921
  // No link found
955
922
  if (VERBOSE) {
956
923
  console.log('[VERBOSE] No GitHub URL found in replied message');
957
924
  }
958
- await ctx.reply('❌ No GitHub issue/PR link found in the replied message.\n\nExample: Reply to a message containing a GitHub issue link with `/solve`', { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
925
+ await ctx.reply('❌ No GitHub issue/PR link found in the replied message.\n\nExample: Reply to a message containing a GitHub issue link with `/solve`\n\nOr with options: `/solve --model opus`', { parse_mode: 'Markdown', reply_to_message_id: ctx.message.message_id });
959
926
  return;
960
927
  }
961
928
  }
@@ -1108,7 +1075,7 @@ async function handleHiveCommand(ctx) {
1108
1075
  return;
1109
1076
  }
1110
1077
 
1111
- if (!isGroupChat(ctx)) {
1078
+ if (!_isGroupChat(ctx)) {
1112
1079
  if (VERBOSE) {
1113
1080
  console.log('[VERBOSE] /hive ignored: not a group chat');
1114
1081
  }
@@ -1212,7 +1179,7 @@ registerTopCommand(bot, {
1212
1179
  VERBOSE,
1213
1180
  isOldMessage,
1214
1181
  isForwardedOrReply,
1215
- isGroupChat,
1182
+ isGroupChat: _isGroupChat,
1216
1183
  isChatAuthorized,
1217
1184
  });
1218
1185
 
@@ -171,3 +171,48 @@ export function extractCommandFromText(text, botUsername = null) {
171
171
 
172
172
  return { command, botMention };
173
173
  }
174
+
175
+ /**
176
+ * Extract GitHub issue/PR URL from message text.
177
+ * Validates that message contains exactly one GitHub issue/PR link.
178
+ * Extracted from telegram-bot.mjs to reduce file size (issue #1325).
179
+ *
180
+ * @param {string} text - Message text to search
181
+ * @param {Object} deps - Dependencies for parsing
182
+ * @param {Function} deps.parseGitHubUrl - Function to parse GitHub URLs
183
+ * @param {Function} deps.cleanNonPrintableChars - Function to clean non-printable characters
184
+ * @returns {{ url: string|null, error: string|null, linkCount: number }}
185
+ * @see https://github.com/link-assistant/hive-mind/issues/1325
186
+ */
187
+ export function extractGitHubUrl(text, { parseGitHubUrl, cleanNonPrintableChars }) {
188
+ if (!text || typeof text !== 'string') {
189
+ return { url: null, error: null, linkCount: 0 };
190
+ }
191
+
192
+ text = cleanNonPrintableChars(text); // Clean non-printable chars before processing
193
+ const words = text.split(/\s+/);
194
+ const foundUrls = [];
195
+
196
+ for (const word of words) {
197
+ // Try to parse as GitHub URL
198
+ const parsed = parseGitHubUrl(word);
199
+
200
+ // Accept issue or PR URLs
201
+ if (parsed.valid && (parsed.type === 'issue' || parsed.type === 'pull')) {
202
+ foundUrls.push(parsed.normalized);
203
+ }
204
+ }
205
+
206
+ // Check if multiple links were found
207
+ if (foundUrls.length === 0) {
208
+ return { url: null, error: null, linkCount: 0 };
209
+ } else if (foundUrls.length === 1) {
210
+ return { url: foundUrls[0], error: null, linkCount: 1 };
211
+ } else {
212
+ return {
213
+ url: null,
214
+ error: `Found ${foundUrls.length} GitHub links in the message. Please reply to a message with only one GitHub issue or PR link.`,
215
+ linkCount: foundUrls.length,
216
+ };
217
+ }
218
+ }