@link-assistant/hive-mind 1.7.2 → 1.9.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.
@@ -762,12 +762,15 @@ bot.command('help', async ctx => {
762
762
  message += '*/limits* - Show usage limits\n';
763
763
  message += '*/version* - Show bot and runtime versions\n';
764
764
  message += '*/accept\\_invites* - Accept all pending GitHub invitations\n';
765
+ message += '*/merge* - Merge queue (experimental)\n';
766
+ message += 'Usage: `/merge <github-repo-url>`\n';
767
+ message += "Merges all PRs with 'ready' label sequentially.\n";
765
768
  message += '*/help* - Show this help message\n\n';
766
- message += '⚠️ *Note:* /solve, /hive, /limits, /version and /accept\\_invites commands only work in group chats.\n\n';
769
+ message += '⚠️ *Note:* /solve, /hive, /limits, /version, /accept\\_invites and /merge commands only work in group chats.\n\n';
767
770
  message += '🔧 *Common Options:*\n';
768
771
  message += '• `--model <model>` or `-m` - Specify AI model (sonnet, opus, haiku, haiku-3-5, haiku-3)\n';
769
772
  message += '• `--base-branch <branch>` or `-b` - Target branch for PR (default: repo default branch)\n';
770
- message += '• `--think <level>` - Thinking level (low/medium/high/max)\n';
773
+ message += '• `--think <level>` - Thinking level (off/low/medium/high/max) | `--thinking-budget <num>` - Token budget (0-63999)\n';
771
774
  message += '• `--verbose` or `-v` - Verbose output | `--attach-logs` - Attach logs to PR\n';
772
775
  message += '\n💡 *Tip:* Many more options available. See full documentation for complete list.\n';
773
776
 
@@ -901,6 +904,17 @@ registerAcceptInvitesCommand(bot, {
901
904
  addBreadcrumb,
902
905
  });
903
906
 
907
+ // Register /merge command from separate module (experimental, see issue #1143)
908
+ const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs');
909
+ registerMergeCommand(bot, {
910
+ VERBOSE,
911
+ isOldMessage,
912
+ isForwardedOrReply,
913
+ isGroupChat,
914
+ isChatAuthorized,
915
+ addBreadcrumb,
916
+ });
917
+
904
918
  bot.command(/^solve$/i, async ctx => {
905
919
  if (VERBOSE) {
906
920
  console.log('[VERBOSE] /solve command received');
@@ -1460,14 +1474,12 @@ bot.telegram
1460
1474
  process.exit(1);
1461
1475
  });
1462
1476
 
1463
- // Helper to stop solve queue gracefully on shutdown
1464
- // See: https://github.com/link-assistant/hive-mind/issues/1083
1477
+ // Helper to stop solve queue gracefully on shutdown (see issue #1083)
1465
1478
  const stopSolveQueue = () => {
1466
1479
  try {
1467
1480
  getSolveQueue({ verbose: VERBOSE }).stop();
1468
- if (VERBOSE) console.log('[VERBOSE] Solve queue stopped');
1469
- } catch (err) {
1470
- if (VERBOSE) console.log('[VERBOSE] Could not stop solve queue:', err.message);
1481
+ } catch {
1482
+ /* ignore errors during shutdown */
1471
1483
  }
1472
1484
  };
1473
1485
 
@@ -1481,10 +1493,8 @@ process.once('SIGINT', () => {
1481
1493
 
1482
1494
  process.once('SIGTERM', () => {
1483
1495
  isShuttingDown = true;
1484
- console.log('\n🛑 Received SIGTERM, stopping bot...');
1496
+ console.log('\n🛑 Received SIGTERM, stopping bot... (Check system logs: journalctl -u <service> or dmesg)');
1485
1497
  if (VERBOSE) console.log(`[VERBOSE] Signal: SIGTERM, PID: ${process.pid}, PPID: ${process.ppid}`);
1486
- console.log('ℹ️ SIGTERM is typically sent by: system shutdown, process manager, kill command, or container orchestration');
1487
- console.log('💡 Check system logs for details: journalctl -u <service> or dmesg');
1488
1498
  stopSolveQueue();
1489
1499
  bot.stop('SIGTERM');
1490
1500
  });
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Telegram /merge command implementation
3
+ *
4
+ * This module provides the /merge command functionality for the Telegram bot,
5
+ * allowing users to process a repository's merge queue - merging all PRs
6
+ * with the 'ready' label sequentially.
7
+ *
8
+ * Features:
9
+ * - Accepts repository URL
10
+ * - Checks and creates 'ready' label if needed
11
+ * - Fetches all PRs/issues with 'ready' label
12
+ * - Merges PRs sequentially (oldest first)
13
+ * - Monitors CI/CD between merges (every 5 minutes)
14
+ * - Provides progress updates via single updated Telegram message
15
+ * - Cancel via inline button
16
+ * - Per-repository concurrency control (not per-chat)
17
+ *
18
+ * @see https://github.com/link-assistant/hive-mind/issues/1143
19
+ */
20
+
21
+ import { parseRepositoryUrl, checkLabelPermissions, ensureReadyLabel } from './github-merge.lib.mjs';
22
+ import { createMergeQueueProcessor, MergeStatus, MERGE_QUEUE_CONFIG } from './telegram-merge-queue.lib.mjs';
23
+
24
+ /**
25
+ * Active merge operations map (repoKey -> { processor, chatId, messageId })
26
+ * Uses repository key (owner/repo) for per-repository concurrency control
27
+ */
28
+ const activeMergeOperations = new Map();
29
+
30
+ /**
31
+ * Generate repository key for the operations map
32
+ * @param {string} owner - Repository owner
33
+ * @param {string} repo - Repository name
34
+ * @returns {string} Repository key
35
+ */
36
+ function getRepoKey(owner, repo) {
37
+ return `${owner}/${repo}`.toLowerCase();
38
+ }
39
+
40
+ /**
41
+ * Escapes special characters in text for Telegram MarkdownV2 formatting
42
+ * @param {string} text - The text to escape
43
+ * @returns {string} The escaped text
44
+ */
45
+ function escapeMarkdownV2(text) {
46
+ return String(text).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
47
+ }
48
+
49
+ /**
50
+ * Parse command arguments for /merge
51
+ * @param {string} text - Message text
52
+ * @returns {string[]} Array of arguments
53
+ */
54
+ function parseCommandArgs(text) {
55
+ const firstLine = text.split('\n')[0].trim();
56
+ const argsText = firstLine.replace(/^\/\w+\s*/, '');
57
+
58
+ if (!argsText.trim()) {
59
+ return [];
60
+ }
61
+
62
+ const args = [];
63
+ let currentArg = '';
64
+ let inQuotes = false;
65
+ let quoteChar = null;
66
+
67
+ for (let i = 0; i < argsText.length; i++) {
68
+ const char = argsText[i];
69
+
70
+ if ((char === '"' || char === "'") && !inQuotes) {
71
+ inQuotes = true;
72
+ quoteChar = char;
73
+ } else if (char === quoteChar && inQuotes) {
74
+ inQuotes = false;
75
+ quoteChar = null;
76
+ } else if (char === ' ' && !inQuotes) {
77
+ if (currentArg) {
78
+ args.push(currentArg);
79
+ currentArg = '';
80
+ }
81
+ } else {
82
+ currentArg += char;
83
+ }
84
+ }
85
+
86
+ if (currentArg) {
87
+ args.push(currentArg);
88
+ }
89
+
90
+ return args;
91
+ }
92
+
93
+ /**
94
+ * Format user-friendly error message
95
+ * Hides debug info unless verbose mode is enabled
96
+ * @param {Error} error - The error object
97
+ * @param {boolean} verbose - Whether verbose logging is enabled
98
+ * @returns {string} User-friendly error message
99
+ */
100
+ function formatUserError(error, verbose) {
101
+ // Map common errors to user-friendly messages
102
+ const errorMessage = error.message || String(error);
103
+
104
+ if (errorMessage.includes('rate limit')) {
105
+ return 'GitHub API rate limit exceeded. Please try again later.';
106
+ }
107
+ if (errorMessage.includes('permission') || errorMessage.includes('403')) {
108
+ return 'Insufficient permissions to access this repository. Please check access rights.';
109
+ }
110
+ if (errorMessage.includes('not found') || errorMessage.includes('404')) {
111
+ return 'Repository not found. Please check the URL and try again.';
112
+ }
113
+ if (errorMessage.includes('network') || errorMessage.includes('ECONNREFUSED')) {
114
+ return 'Network error. Please check your connection and try again.';
115
+ }
116
+
117
+ // For unknown errors, show generic message (detailed logs are in verbose mode)
118
+ if (verbose) {
119
+ return `Error: ${errorMessage}`;
120
+ }
121
+ return 'An error occurred. Please try again or contact support.';
122
+ }
123
+
124
+ /**
125
+ * Registers the /merge command handler with the bot
126
+ * @param {Object} bot - The Telegraf bot instance
127
+ * @param {Object} options - Options object
128
+ * @param {boolean} options.VERBOSE - Whether to enable verbose logging
129
+ * @param {Function} options.isOldMessage - Function to check if message is old
130
+ * @param {Function} options.isForwardedOrReply - Function to check if message is forwarded/reply
131
+ * @param {Function} options.isGroupChat - Function to check if chat is a group
132
+ * @param {Function} options.isChatAuthorized - Function to check if chat is authorized
133
+ * @param {Function} options.addBreadcrumb - Function to add breadcrumbs for monitoring
134
+ */
135
+ export function registerMergeCommand(bot, options) {
136
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, addBreadcrumb } = options;
137
+
138
+ bot.command(/^merge$/i, async ctx => {
139
+ VERBOSE && console.log('[VERBOSE] /merge command received');
140
+
141
+ await addBreadcrumb({
142
+ category: 'telegram.command',
143
+ message: '/merge command received',
144
+ level: 'info',
145
+ data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
146
+ });
147
+
148
+ // Standard checks
149
+ if (isOldMessage(ctx) || isForwardedOrReply(ctx)) return;
150
+
151
+ if (!isGroupChat(ctx)) {
152
+ return await ctx.reply('The /merge command only works in group chats. Please add this bot to a group and make it an admin.', {
153
+ reply_to_message_id: ctx.message.message_id,
154
+ });
155
+ }
156
+
157
+ const chatId = ctx.chat.id;
158
+ if (!isChatAuthorized(chatId)) {
159
+ return await ctx.reply(`This chat (ID: ${chatId}) is not authorized to use this bot. Please contact the bot administrator.`, {
160
+ reply_to_message_id: ctx.message.message_id,
161
+ });
162
+ }
163
+
164
+ // Parse arguments
165
+ const args = parseCommandArgs(ctx.message.text);
166
+
167
+ if (args.length === 0) {
168
+ 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 });
169
+ }
170
+
171
+ // Parse and validate repository URL
172
+ const repoUrl = args[0];
173
+ const parsedUrl = parseRepositoryUrl(repoUrl);
174
+
175
+ if (!parsedUrl.valid) {
176
+ return await ctx.reply(`Invalid repository URL: ${escapeMarkdownV2(parsedUrl.error)}\n\nPlease provide a valid GitHub repository URL\\.`, {
177
+ parse_mode: 'MarkdownV2',
178
+ reply_to_message_id: ctx.message.message_id,
179
+ });
180
+ }
181
+
182
+ const { owner, repo } = parsedUrl;
183
+ const repoKey = getRepoKey(owner, repo);
184
+ VERBOSE && console.log(`[VERBOSE] /merge: Processing repository ${owner}/${repo}`);
185
+
186
+ // Check if a merge operation is already running for this repository (per-repository concurrency)
187
+ if (activeMergeOperations.has(repoKey)) {
188
+ const existingOp = activeMergeOperations.get(repoKey);
189
+ if (existingOp.processor.status === MergeStatus.RUNNING) {
190
+ return await ctx.reply(`A merge operation is already running for ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}\\.\n\nPlease wait for it to complete or cancel it\\.`, {
191
+ parse_mode: 'MarkdownV2',
192
+ reply_to_message_id: ctx.message.message_id,
193
+ });
194
+ }
195
+ }
196
+
197
+ // Send initial status message (reply to the /merge command)
198
+ const statusMessage = await ctx.reply(`Initializing merge queue for ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}\\.\\.\\.\n\nThis may take a moment\\.`, {
199
+ parse_mode: 'MarkdownV2',
200
+ reply_to_message_id: ctx.message.message_id,
201
+ });
202
+
203
+ try {
204
+ // Check permissions
205
+ const permCheck = await checkLabelPermissions(owner, repo, VERBOSE);
206
+ if (!permCheck.canManageLabels) {
207
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `No permission to manage repository ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}\\.\n\nPlease ensure you have write access to this repository\\.`, { parse_mode: 'MarkdownV2' });
208
+ return;
209
+ }
210
+
211
+ // Ensure ready label exists
212
+ const labelResult = await ensureReadyLabel(owner, repo, VERBOSE);
213
+ if (!labelResult.success) {
214
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `Failed to setup 'ready' label: ${escapeMarkdownV2(labelResult.error)}`, {
215
+ parse_mode: 'MarkdownV2',
216
+ });
217
+ return;
218
+ }
219
+
220
+ const labelMsg = labelResult.created ? "\nCreated 'ready' label in repository\\." : '';
221
+
222
+ // Create the merge queue processor
223
+ const processor = await createMergeQueueProcessor(owner, repo, {
224
+ verbose: VERBOSE,
225
+ onProgress: async () => {
226
+ // Update message with progress and cancel button
227
+ try {
228
+ const message = processor.formatProgressMessage();
229
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, message, {
230
+ parse_mode: 'MarkdownV2',
231
+ reply_markup: {
232
+ inline_keyboard: [[{ text: '🛑 Cancel', callback_data: `merge_cancel_${repoKey}` }]],
233
+ },
234
+ });
235
+ } catch (err) {
236
+ // Ignore message edit errors (e.g., message not modified)
237
+ if (!err.message?.includes('message is not modified')) {
238
+ VERBOSE && console.log(`[VERBOSE] /merge: Error updating message: ${err.message}`);
239
+ }
240
+ }
241
+ },
242
+ onComplete: async () => {
243
+ try {
244
+ const message = processor.formatFinalMessage();
245
+ // Remove cancel button on completion
246
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, message, {
247
+ parse_mode: 'MarkdownV2',
248
+ });
249
+ } catch (err) {
250
+ VERBOSE && console.log(`[VERBOSE] /merge: Error sending final message: ${err.message}`);
251
+ }
252
+ activeMergeOperations.delete(repoKey);
253
+ },
254
+ onError: async error => {
255
+ VERBOSE && console.error(`[VERBOSE] /merge error for ${repoKey}:`, error);
256
+ try {
257
+ const userMessage = formatUserError(error, VERBOSE);
258
+ const finalReport = processor.formatFinalMessage();
259
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `❌ *Merge queue failed*\n\n${escapeMarkdownV2(userMessage)}\n\n${finalReport}`, {
260
+ parse_mode: 'MarkdownV2',
261
+ });
262
+ } catch (err) {
263
+ VERBOSE && console.log(`[VERBOSE] /merge: Error sending error message: ${err.message}`);
264
+ }
265
+ activeMergeOperations.delete(repoKey);
266
+ },
267
+ });
268
+
269
+ // Initialize the processor
270
+ const initResult = await processor.initialize();
271
+
272
+ if (!initResult.success) {
273
+ const userMessage = formatUserError(new Error(initResult.error), VERBOSE);
274
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `Failed to initialize merge queue: ${escapeMarkdownV2(userMessage)}`, {
275
+ parse_mode: 'MarkdownV2',
276
+ });
277
+ return;
278
+ }
279
+
280
+ if (initResult.message) {
281
+ // No PRs to merge
282
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\n${escapeMarkdownV2(initResult.message)}\n\nTo use the merge queue:\n1\\. Add the \`ready\` label to PRs you want to merge\n2\\. Run \`/merge ${escapeMarkdownV2(repoUrl)}\` again`, { parse_mode: 'MarkdownV2' });
283
+ return;
284
+ }
285
+
286
+ // Update message with PR list and cancel button, start processing
287
+ const truncatedMsg = initResult.truncated ? `\n\n_Note: Only processing first ${MERGE_QUEUE_CONFIG.MAX_PRS_PER_SESSION} PRs_` : '';
288
+
289
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\nFound ${initResult.count} PRs with 'ready' label\\.${escapeMarkdownV2(truncatedMsg)}\n\nStarting merge process\\.\\.\\.`, {
290
+ parse_mode: 'MarkdownV2',
291
+ reply_markup: {
292
+ inline_keyboard: [[{ text: '🛑 Cancel', callback_data: `merge_cancel_${repoKey}` }]],
293
+ },
294
+ });
295
+
296
+ // Store processor for this repository
297
+ activeMergeOperations.set(repoKey, {
298
+ processor,
299
+ chatId,
300
+ messageId: statusMessage.message_id,
301
+ });
302
+
303
+ // Run the merge queue (this runs asynchronously)
304
+ processor.run().catch(error => {
305
+ VERBOSE && console.error(`[VERBOSE] /merge: Unhandled error in run(): ${error.message}`);
306
+ activeMergeOperations.delete(repoKey);
307
+ });
308
+ } catch (error) {
309
+ VERBOSE && console.error('[VERBOSE] /merge error:', error);
310
+
311
+ try {
312
+ const userMessage = formatUserError(error, VERBOSE);
313
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `Error processing merge queue: ${escapeMarkdownV2(userMessage)}\n\nPlease check the repository URL and try again\\.`, { parse_mode: 'MarkdownV2' });
314
+ } catch (editError) {
315
+ VERBOSE && console.error('[VERBOSE] /merge: Failed to edit error message:', editError);
316
+ }
317
+ }
318
+ });
319
+
320
+ // Handle cancel button callback
321
+ bot.action(/^merge_cancel_(.+)$/, async ctx => {
322
+ const repoKey = ctx.match[1];
323
+ VERBOSE && console.log(`[VERBOSE] /merge cancel callback received for ${repoKey}`);
324
+
325
+ const operation = activeMergeOperations.get(repoKey);
326
+ if (!operation || operation.processor.status !== MergeStatus.RUNNING) {
327
+ await ctx.answerCbQuery('No active merge operation found.');
328
+ return;
329
+ }
330
+
331
+ // Cancel the operation
332
+ operation.processor.cancel();
333
+ await ctx.answerCbQuery('Merge operation cancellation requested. The current PR will finish processing.');
334
+
335
+ VERBOSE && console.log(`[VERBOSE] /merge: Cancelled operation for ${repoKey}`);
336
+ });
337
+ }
338
+
339
+ /**
340
+ * Get active merge operation for a repository
341
+ * @param {string} owner - Repository owner
342
+ * @param {string} repo - Repository name
343
+ * @returns {Object|null} Operation object or null
344
+ */
345
+ export function getActiveMergeOperation(owner, repo) {
346
+ const repoKey = getRepoKey(owner, repo);
347
+ return activeMergeOperations.get(repoKey) || null;
348
+ }
349
+
350
+ /**
351
+ * Clear all active merge operations (useful for testing)
352
+ */
353
+ export function clearAllMergeOperations() {
354
+ for (const [, operation] of activeMergeOperations) {
355
+ if (operation.processor.status === MergeStatus.RUNNING) {
356
+ operation.processor.cancel();
357
+ }
358
+ }
359
+ activeMergeOperations.clear();
360
+ }
361
+
362
+ export default {
363
+ registerMergeCommand,
364
+ getActiveMergeOperation,
365
+ clearAllMergeOperations,
366
+ };