@link-assistant/hive-mind 1.56.16 → 1.56.18
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 +12 -0
- package/package.json +2 -2
- package/src/github-entity-validation.lib.mjs +9 -2
- package/src/session-monitor.lib.mjs +92 -1
- package/src/solve.mjs +1 -1
- package/src/telegram-bot.mjs +20 -16
- package/src/telegram-isolation.lib.mjs +21 -1
- package/src/telegram-solve-queue.lib.mjs +19 -1
- package/src/telegram-subscribers.lib.mjs +220 -0
- package/src/work-session-formatting.lib.mjs +36 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.56.18
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 47810ae: Telegram bot: add experimental `/subscribe` + `/unsubscribe` commands so users can opt in to receive a private DM forward of the `/solve` work-session completion message (commands work in both private and group chats; subscriptions are kept in memory and reset on bot restart). The completion message now includes both an `Issue:` line (the original URL passed to `/solve`) and, when the agent created a pull request for that issue, a follow-up `Pull request:` line so reviewers see both links without leaving the chat. (#1688)
|
|
8
|
+
|
|
9
|
+
## 1.56.17
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- b693172: Improve the repository-not-accessible error message in `/solve` (issue #1692). The headline drops the redundant "not found or" wording and the technical "(GitHub returns 404 for private repos without permissions)" parenthetical, leads with the most-actionable hypothesis ("Repository may be private — ensure the bot has been granted access"), and only suggests `--auto-accept-invite` when that flag is _not_ already active. The Telegram bot surface picks up the same suppression so users do not see the hint echoed back when they already passed the flag.
|
|
14
|
+
|
|
3
15
|
## 1.56.16
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/hive-mind",
|
|
3
|
-
"version": "1.56.
|
|
3
|
+
"version": "1.56.18",
|
|
4
4
|
"description": "AI-powered issue solver and hive mind for collaborative problem solving",
|
|
5
5
|
"main": "src/hive.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"hive-telegram-bot": "./src/telegram-bot.mjs"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-log-upload-output-1678.mjs && node tests/test-log-upload-output-1682.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-issue-1680-session-monitoring.mjs && node tests/test-issue-1684-message-formatting.mjs && node tests/test-issue-1694-stabilized-defaults.mjs && node tests/test-telegram-bot-launcher.mjs",
|
|
18
|
+
"test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-claude-quiet-config.mjs && node tests/test-configure-claude-bin.mjs && node tests/test-docker-release-order.mjs && node tests/test-docker-box-migration.mjs && node tests/test-hive-screens.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-ready-to-merge-pagination-1645.mjs && node tests/test-require-gh-paginate-rule.mjs && node tests/test-auto-restart-limits-1664.mjs && node tests/test-log-upload-output-1678.mjs && node tests/test-log-upload-output-1682.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-telegram-options-before-url.mjs && node tests/test-telegram-bot-configuration-isolation-links-notation.mjs && node tests/test-extract-isolation-from-args.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-issue-1670-screen-status-monitoring.mjs && node tests/test-issue-1680-session-monitoring.mjs && node tests/test-issue-1684-message-formatting.mjs && node tests/test-issue-1688-subscribe-and-pr-link.mjs && node tests/test-issue-1694-stabilized-defaults.mjs && node tests/test-telegram-bot-launcher.mjs",
|
|
19
19
|
"test:queue": "node tests/solve-queue.test.mjs",
|
|
20
20
|
"test:limits-display": "node tests/limits-display.test.mjs",
|
|
21
21
|
"test:usage-limit": "node tests/test-usage-limit.mjs",
|
|
@@ -21,13 +21,16 @@ import { ghPrView, ghIssueView } from './github.lib.mjs';
|
|
|
21
21
|
* @param {number|string} [options.number] - Issue or PR number (if applicable)
|
|
22
22
|
* @param {string} [options.type] - URL type: 'issue' or 'pull'
|
|
23
23
|
* @param {boolean} [options.verbose=false] - Whether verbose logging is enabled
|
|
24
|
+
* @param {boolean} [options.autoAcceptInvite=false] - Whether the caller already passed
|
|
25
|
+
* `--auto-accept-invite`. When true, the repo-404 message omits the suggestion to
|
|
26
|
+
* use that flag, since it would not be actionable (issue #1692).
|
|
24
27
|
* @returns {Promise<{valid: boolean, error?: string, level?: string, details?: string}>}
|
|
25
28
|
* - valid: true if all entities exist and are accessible
|
|
26
29
|
* - error: user-facing error message (when valid=false)
|
|
27
30
|
* - level: which entity level failed ('user', 'repo', 'issue', 'pull')
|
|
28
31
|
* - details: additional context for verbose logging
|
|
29
32
|
*/
|
|
30
|
-
export async function validateGitHubEntityExistence({ owner, repo, number, type, verbose = false }) {
|
|
33
|
+
export async function validateGitHubEntityExistence({ owner, repo, number, type, verbose = false, autoAcceptInvite = false }) {
|
|
31
34
|
// Step 1: Check user/organization existence
|
|
32
35
|
try {
|
|
33
36
|
const userResult = await ghCmdRetry(() => $`gh api users/${owner} --jq .login`, { label: `check user ${owner}` });
|
|
@@ -53,9 +56,13 @@ export async function validateGitHubEntityExistence({ owner, repo, number, type,
|
|
|
53
56
|
if (repoResult.code !== 0) {
|
|
54
57
|
const errorOutput = (repoResult.stderr ? repoResult.stderr.toString() : '') + (repoResult.stdout ? repoResult.stdout.toString() : '');
|
|
55
58
|
if (errorOutput.includes('404') || errorOutput.includes('Not Found')) {
|
|
59
|
+
const bullets = ['• Repository may be private — ensure the bot has been granted access', '• The repository name is spelled correctly', '• The repository has not been deleted, transferred, or never existed'];
|
|
60
|
+
if (!autoAcceptInvite) {
|
|
61
|
+
bullets.push('• If Hive Mind bot was recently invited, try using --auto-accept-invite to accept pending invitations');
|
|
62
|
+
}
|
|
56
63
|
return {
|
|
57
64
|
valid: false,
|
|
58
|
-
error: `Repository '${owner}/${repo}'
|
|
65
|
+
error: `Repository '${owner}/${repo}' is not accessible.\n\n💡 Please check:\n${bullets.join('\n')}`,
|
|
59
66
|
level: 'repo',
|
|
60
67
|
};
|
|
61
68
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { promisify } from 'util';
|
|
19
19
|
import { exec as execCallback } from 'child_process';
|
|
20
20
|
import { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
21
|
+
import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
|
|
21
22
|
|
|
22
23
|
export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
|
|
23
24
|
|
|
@@ -251,6 +252,20 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
251
252
|
|
|
252
253
|
try {
|
|
253
254
|
const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
|
|
255
|
+
|
|
256
|
+
// Issue #1688: When the original /solve URL was an issue, look up the
|
|
257
|
+
// linked PR so the completion message can include both an `Issue:` and
|
|
258
|
+
// a `Pull request:` line. Failures are logged and ignored — the
|
|
259
|
+
// notification still goes out without the PR line.
|
|
260
|
+
let pullRequestUrl = null;
|
|
261
|
+
try {
|
|
262
|
+
pullRequestUrl = await resolvePullRequestUrlForSession(sessionInfo, { verbose, lookupLinkedPullRequest: options.lookupLinkedPullRequest });
|
|
263
|
+
} catch (lookupError) {
|
|
264
|
+
if (verbose) {
|
|
265
|
+
console.log(`[VERBOSE] Pull request lookup failed for ${sessionName}: ${lookupError?.message || lookupError}`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
254
269
|
const message = formatSessionCompletionMessage({
|
|
255
270
|
sessionName,
|
|
256
271
|
sessionInfo,
|
|
@@ -258,13 +273,44 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
258
273
|
observedEndTime: new Date(),
|
|
259
274
|
exitCode: finalExitCode,
|
|
260
275
|
infoBlock: sessionInfo?.infoBlock || '',
|
|
276
|
+
pullRequestUrl,
|
|
261
277
|
});
|
|
262
278
|
|
|
263
279
|
// Update the original reply message if messageId is available, otherwise send new message
|
|
280
|
+
let notifyFromChatId = null;
|
|
281
|
+
let notifyMessageId = null;
|
|
264
282
|
if (sessionInfo.messageId) {
|
|
265
283
|
await bot.telegram.editMessageText(sessionInfo.chatId, sessionInfo.messageId, undefined, message, { parse_mode: 'Markdown' });
|
|
284
|
+
notifyFromChatId = sessionInfo.chatId;
|
|
285
|
+
notifyMessageId = sessionInfo.messageId;
|
|
266
286
|
} else {
|
|
267
|
-
await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
|
|
287
|
+
const sent = await bot.telegram.sendMessage(sessionInfo.chatId, message, { parse_mode: 'Markdown' });
|
|
288
|
+
notifyFromChatId = sent?.chat?.id || sessionInfo.chatId;
|
|
289
|
+
notifyMessageId = sent?.message_id || null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Issue #1688: forward the same completion message to every /subscribe-d user
|
|
293
|
+
// in their private chat with the bot. Failures are logged but don't block
|
|
294
|
+
// completion of the parent session.
|
|
295
|
+
if (getSubscriberCount() > 0 && notifyFromChatId && notifyMessageId) {
|
|
296
|
+
try {
|
|
297
|
+
const skipUserIds = new Set();
|
|
298
|
+
if (sessionInfo?.requesterUserId) skipUserIds.add(sessionInfo.requesterUserId);
|
|
299
|
+
const summary = await notifySubscribers({
|
|
300
|
+
bot,
|
|
301
|
+
fromChatId: notifyFromChatId,
|
|
302
|
+
messageId: notifyMessageId,
|
|
303
|
+
fallbackText: message,
|
|
304
|
+
fallbackOptions: { parse_mode: 'Markdown' },
|
|
305
|
+
skipUserIds,
|
|
306
|
+
verbose,
|
|
307
|
+
});
|
|
308
|
+
if (verbose) {
|
|
309
|
+
console.log(`[VERBOSE] Subscribe notify summary for ${sessionName}: forwarded=${summary.forwarded}, sent=${summary.sent}, skipped=${summary.skipped}, failures=${summary.failures.length}`);
|
|
310
|
+
}
|
|
311
|
+
} catch (notifyError) {
|
|
312
|
+
console.error(`[session-monitor] notifySubscribers failed for ${sessionName}:`, notifyError);
|
|
313
|
+
}
|
|
268
314
|
}
|
|
269
315
|
|
|
270
316
|
completeSession(sessionName, finalExitCode || 0, verbose);
|
|
@@ -285,6 +331,51 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
|
|
|
285
331
|
}
|
|
286
332
|
}
|
|
287
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Look up the URL of a pull request linked to the issue this session worked on.
|
|
336
|
+
* Returns null when the session was already operating on a PR, the URL context
|
|
337
|
+
* is missing, or no linked PR exists.
|
|
338
|
+
*
|
|
339
|
+
* Lazy-loads the GitHub batch helper so unrelated tests/imports don't pull
|
|
340
|
+
* GitHub deps. Tests can override the lookup via `options.lookupLinkedPullRequest`.
|
|
341
|
+
*
|
|
342
|
+
* @param {Object} sessionInfo
|
|
343
|
+
* @param {Object} [options]
|
|
344
|
+
* @param {boolean} [options.verbose]
|
|
345
|
+
* @param {Function} [options.lookupLinkedPullRequest] - Optional override `(ctx) => Promise<string|null>`
|
|
346
|
+
* @returns {Promise<string|null>} PR URL or null
|
|
347
|
+
*
|
|
348
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1688
|
|
349
|
+
*/
|
|
350
|
+
async function resolvePullRequestUrlForSession(sessionInfo, { verbose = false, lookupLinkedPullRequest = null } = {}) {
|
|
351
|
+
const ctx = sessionInfo?.urlContext;
|
|
352
|
+
if (!ctx || ctx.type !== 'issue' || !ctx.owner || !ctx.repo || !ctx.number) {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (typeof lookupLinkedPullRequest === 'function') {
|
|
357
|
+
return await lookupLinkedPullRequest(ctx);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
try {
|
|
361
|
+
const { batchCheckPullRequestsForIssues } = await import('./github.lib.mjs');
|
|
362
|
+
const result = await batchCheckPullRequestsForIssues(ctx.owner, ctx.repo, [ctx.number]);
|
|
363
|
+
const linkedPRs = result?.[ctx.number]?.linkedPRs || [];
|
|
364
|
+
if (linkedPRs.length > 0 && linkedPRs[0].url) {
|
|
365
|
+
if (verbose) {
|
|
366
|
+
console.log(`[VERBOSE] Found linked PR ${linkedPRs[0].url} for issue ${ctx.owner}/${ctx.repo}#${ctx.number}`);
|
|
367
|
+
}
|
|
368
|
+
return linkedPRs[0].url;
|
|
369
|
+
}
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (verbose) {
|
|
372
|
+
console.log(`[VERBOSE] batchCheckPullRequestsForIssues failed for ${ctx.owner}/${ctx.repo}#${ctx.number}: ${error?.message || error}`);
|
|
373
|
+
}
|
|
374
|
+
throw error;
|
|
375
|
+
}
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
288
379
|
/**
|
|
289
380
|
* Start the session monitoring interval
|
|
290
381
|
* @param {Object} bot - Telegraf bot instance for sending messages
|
package/src/solve.mjs
CHANGED
|
@@ -290,7 +290,7 @@ if (!hasWriteAccess) {
|
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
// Issue #1552: Validate entity existence AFTER permissions (cascade: user/org → repo → issue/PR)
|
|
293
|
-
const entityCheck = await (await import('./github-entity-validation.lib.mjs')).validateGitHubEntityExistence({ owner, repo, number: urlNumber, type: isIssueUrl ? 'issue' : isPrUrl ? 'pull' : undefined, verbose: argv.verbose });
|
|
293
|
+
const entityCheck = await (await import('./github-entity-validation.lib.mjs')).validateGitHubEntityExistence({ owner, repo, number: urlNumber, type: isIssueUrl ? 'issue' : isPrUrl ? 'pull' : undefined, verbose: argv.verbose, autoAcceptInvite: !!argv.autoAcceptInvite });
|
|
294
294
|
if (!entityCheck.valid) {
|
|
295
295
|
await log(`\n❌ ${entityCheck.error}\n`, { level: 'error' });
|
|
296
296
|
await safeExit(1, `GitHub entity not found (${entityCheck.level})`);
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -551,7 +551,7 @@ async function safeReply(ctx, text, options = {}) {
|
|
|
551
551
|
}
|
|
552
552
|
}
|
|
553
553
|
|
|
554
|
-
async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude') {
|
|
554
|
+
async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null) {
|
|
555
555
|
const { chat, message_id: msgId } = startingMessage;
|
|
556
556
|
const safeEdit = async text => {
|
|
557
557
|
try {
|
|
@@ -560,22 +560,20 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
|
|
|
560
560
|
console.error(`[telegram-bot] Failed to update message for ${commandName}: ${e.message}`);
|
|
561
561
|
}
|
|
562
562
|
};
|
|
563
|
+
const requesterUserId = ctx.from?.id ?? null; // Issue #1688: suppress duplicate /subscribe DM
|
|
563
564
|
const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
|
|
564
565
|
let result, session;
|
|
565
566
|
if (iso) {
|
|
566
567
|
session = iso.runner.generateSessionId();
|
|
567
568
|
VERBOSE && console.log(`[VERBOSE] Using isolation (${iso.backend}), session: ${session}`);
|
|
568
569
|
result = await iso.runner.executeWithIsolation(commandName, args, { backend: iso.backend, sessionId: session, verbose: VERBOSE });
|
|
569
|
-
if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool, infoBlock }, VERBOSE);
|
|
570
|
+
if (result.success) trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, isolationBackend: iso.backend, sessionId: session, tool, infoBlock, urlContext, requesterUserId }, VERBOSE);
|
|
570
571
|
} else {
|
|
571
572
|
result = await executeStartScreen(commandName, args);
|
|
572
573
|
const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
|
|
573
574
|
session = match ? match[1] : 'unknown';
|
|
574
|
-
// Issue #1586:
|
|
575
|
-
|
|
576
|
-
// `exec bash`), so active URL checks auto-expire them after 10 min.
|
|
577
|
-
// This prevents accidental duplicate commands within the timeout window.
|
|
578
|
-
if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock }, VERBOSE);
|
|
575
|
+
// Issue #1586: Non-isolation sessions auto-expire after 10 min — screen stays alive via `exec bash` so completion can't be detected reliably; this still blocks duplicate commands in the timeout window.
|
|
576
|
+
if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId }, VERBOSE);
|
|
579
577
|
}
|
|
580
578
|
if (result.warning) return safeEdit(`⚠️ ${result.warning}`);
|
|
581
579
|
if (result.success) {
|
|
@@ -662,13 +660,14 @@ bot.command('help', async ctx => {
|
|
|
662
660
|
message += '*/merge* - Merge queue (experimental)\n';
|
|
663
661
|
message += 'Usage: `/merge <github-repo-url>`\n';
|
|
664
662
|
message += "Merges all PRs with 'ready' label sequentially.\n";
|
|
663
|
+
message += '*/subscribe* / */unsubscribe* - 🔔 Get private DM forward of /solve completion (experimental, #1688)\n';
|
|
665
664
|
message += '*/help* - Show this help message\n';
|
|
666
665
|
message += '*/stop* - Stop accepting new tasks (owner only)\n';
|
|
667
666
|
message += '*/start* - Resume accepting tasks (owner only)\n\n';
|
|
668
|
-
message += '🔔 *Session Notifications:* The bot monitors sessions and notifies when they complete.\n';
|
|
667
|
+
message += '🔔 *Session Notifications:* The bot monitors sessions and notifies when they complete. Use /subscribe to also get DM forwards (in-memory, resets on restart).\n';
|
|
669
668
|
if (ISOLATION_BACKEND) message += `🔒 *Isolation Mode:* \`${ISOLATION_BACKEND}\` (experimental)\n`;
|
|
670
669
|
message += '\n';
|
|
671
|
-
message += '⚠️ *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats.\n\n';
|
|
670
|
+
message += '⚠️ *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /hive, /solve\\_queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats. /subscribe and /unsubscribe work in private and group chats.\n\n';
|
|
672
671
|
message += '🔧 *Common Options:*\n';
|
|
673
672
|
message += `• \`--model <model>\` or \`-m\` - ${buildModelOptionDescription()}\n`;
|
|
674
673
|
message += '• `--base-branch <branch>` or `-b` - Target branch for PR (default: repo default branch)\n';
|
|
@@ -772,6 +771,8 @@ const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs'
|
|
|
772
771
|
registerMergeCommand(bot, sharedCommandOpts);
|
|
773
772
|
const { registerSolveQueueCommand } = await import('./telegram-solve-queue-command.lib.mjs');
|
|
774
773
|
const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, { ...sharedCommandOpts, getSolveQueue });
|
|
774
|
+
const { registerSubscribeCommands } = await import('./telegram-subscribers.lib.mjs'); // #1688
|
|
775
|
+
registerSubscribeCommands(bot, sharedCommandOpts);
|
|
775
776
|
|
|
776
777
|
// Named handler for /solve command - extracted for reuse by text-based fallback (issue #1207)
|
|
777
778
|
async function handleSolveCommand(ctx) {
|
|
@@ -974,7 +975,7 @@ async function handleSolveCommand(ctx) {
|
|
|
974
975
|
VERBOSE && console.log(`[VERBOSE] Auto-accept invite pre-check failed: ${e.message}`);
|
|
975
976
|
}
|
|
976
977
|
}
|
|
977
|
-
const entityCheck = await validateGitHubEntityExistence({ owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, verbose: VERBOSE });
|
|
978
|
+
const entityCheck = await validateGitHubEntityExistence({ owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, verbose: VERBOSE, autoAcceptInvite: args.some(a => a === '--auto-accept-invite') });
|
|
978
979
|
if (!entityCheck.valid) {
|
|
979
980
|
await safeReply(ctx, `❌ ${escapeMarkdown(entityCheck.error)}`, { reply_to_message_id: ctx.message.message_id });
|
|
980
981
|
return;
|
|
@@ -983,10 +984,10 @@ async function handleSolveCommand(ctx) {
|
|
|
983
984
|
const normalizedUrl = validation.parsed.normalized;
|
|
984
985
|
|
|
985
986
|
const requester = buildUserMention({ user: ctx.from, parseMode: 'Markdown' });
|
|
986
|
-
//
|
|
987
|
-
// Issue #1460: Escape options text to prevent Markdown parsing errors
|
|
987
|
+
// #1228: only user options; #1460: escape; #1688: 'Issue:' / 'Pull request:' label so completion can append PR link.
|
|
988
988
|
const userOptionsRaw = userArgs.slice(1).join(' ');
|
|
989
|
-
|
|
989
|
+
const urlLabel = validation.parsed?.type === 'pull' ? 'Pull request' : 'Issue';
|
|
990
|
+
let infoBlock = `Requested by: ${requester}\n${urlLabel}: ${escapeMarkdown(normalizedUrl)}`;
|
|
990
991
|
if (userOptionsRaw) infoBlock += `\n\n🛠 Options: ${escapeMarkdown(userOptionsRaw)}`;
|
|
991
992
|
if (solveOverrides.length > 0) infoBlock += `${userOptionsRaw ? '\n' : '\n\n'}🔒 Locked options: ${escapeMarkdown(solveOverrides.join(' '))}`;
|
|
992
993
|
const solveQueue = getSolveQueue({ verbose: VERBOSE });
|
|
@@ -1012,12 +1013,15 @@ async function handleSolveCommand(ctx) {
|
|
|
1012
1013
|
return;
|
|
1013
1014
|
}
|
|
1014
1015
|
|
|
1016
|
+
// Issue #1688: parsed URL context lets the completion message look up linked PRs.
|
|
1017
|
+
const solveUrlContext = validation.parsed ? { owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, normalized: validation.parsed.normalized || normalizedUrl } : null;
|
|
1018
|
+
|
|
1015
1019
|
const toolQueuedCount = queueStats.queuedByTool[solveTool] || 0; // tool-specific queue count (#1551)
|
|
1016
1020
|
if (check.canStart && toolQueuedCount === 0) {
|
|
1017
1021
|
const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock }), { reply_to_message_id: ctx.message.message_id });
|
|
1018
|
-
await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool);
|
|
1022
|
+
await executeAndUpdateMessage(ctx, startingMessage, 'solve', args, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext);
|
|
1019
1023
|
} else {
|
|
1020
|
-
const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation });
|
|
1024
|
+
const queueItem = solveQueue.enqueue({ url: normalizedUrl, args, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext });
|
|
1021
1025
|
let queueMessage = `📋 Solve command queued (${solveTool} queue position #${toolQueuedCount + 1})\n\n${infoBlock}`; // tool-specific position (#1551)
|
|
1022
1026
|
if (check.reason) queueMessage += `\n\n⏳ Waiting: ${escapeMarkdown(check.reason)}`;
|
|
1023
1027
|
const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
|
|
@@ -1252,7 +1256,7 @@ bot.on('message', async (ctx, next) => {
|
|
|
1252
1256
|
}
|
|
1253
1257
|
}
|
|
1254
1258
|
|
|
1255
|
-
//
|
|
1259
|
+
// /subscribe + /unsubscribe (#1688) are intentionally not in the text fallback — Telegraf's bot.command() is sufficient.
|
|
1256
1260
|
const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
|
|
1257
1261
|
const handlers = { ...solveHandlers, hive: handleHiveCommand, solve_queue: handleSolveQueueCommand, solvequeue: handleSolveQueueCommand };
|
|
1258
1262
|
|
|
@@ -80,7 +80,27 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
|
|
|
80
80
|
if (iso) {
|
|
81
81
|
const sid = iso.runner.generateSessionId();
|
|
82
82
|
const r = await iso.runner.executeWithIsolation(item.command || 'solve', item.args, { backend: iso.backend, sessionId: sid, verbose });
|
|
83
|
-
if (r.success)
|
|
83
|
+
if (r.success)
|
|
84
|
+
trackSession(
|
|
85
|
+
sid,
|
|
86
|
+
{
|
|
87
|
+
chatId: item.ctx?.chat?.id,
|
|
88
|
+
messageId: item.messageInfo?.messageId,
|
|
89
|
+
startTime: new Date(),
|
|
90
|
+
url: item.url,
|
|
91
|
+
command: item.command || 'solve',
|
|
92
|
+
isolationBackend: iso.backend,
|
|
93
|
+
sessionId: sid,
|
|
94
|
+
tool: item.tool || 'claude',
|
|
95
|
+
infoBlock: item.infoBlock,
|
|
96
|
+
// Issue #1688: propagate URL context + requester through the queue so the
|
|
97
|
+
// completion notification can append a 'Pull request:' line and skip
|
|
98
|
+
// notifying the requester twice via /subscribe.
|
|
99
|
+
urlContext: item.urlContext || null,
|
|
100
|
+
requesterUserId: item.requesterUserId ?? null,
|
|
101
|
+
},
|
|
102
|
+
verbose
|
|
103
|
+
);
|
|
84
104
|
return { ...r, sessionId: sid, isolationBackend: iso.backend, output: r.output || `session: ${sid}` };
|
|
85
105
|
}
|
|
86
106
|
return fallbackCallback(item);
|
|
@@ -43,6 +43,11 @@ class SolveQueueItem {
|
|
|
43
43
|
this.requester = options.requester;
|
|
44
44
|
this.infoBlock = options.infoBlock;
|
|
45
45
|
this.tool = options.tool || 'claude';
|
|
46
|
+
// Issue #1688: keep parsed URL context (owner/repo/number/type) so completion
|
|
47
|
+
// notifications can look up linked PRs for issue URLs.
|
|
48
|
+
this.urlContext = options.urlContext || null;
|
|
49
|
+
// Issue #1688: requester user ID for /subscribe duplicate-suppression.
|
|
50
|
+
this.requesterUserId = options.ctx?.from?.id ?? null;
|
|
46
51
|
this.createdAt = new Date();
|
|
47
52
|
this.startedAt = null;
|
|
48
53
|
this.status = QueueItemStatus.QUEUED;
|
|
@@ -1361,7 +1366,20 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
|
|
|
1361
1366
|
const match = result.output && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
|
|
1362
1367
|
const session = match ? match[1] : null;
|
|
1363
1368
|
if (session) {
|
|
1364
|
-
trackSessionFn(session, {
|
|
1369
|
+
trackSessionFn(session, {
|
|
1370
|
+
chatId: item.ctx?.chat?.id,
|
|
1371
|
+
messageId: item.messageInfo?.messageId,
|
|
1372
|
+
startTime: new Date(),
|
|
1373
|
+
url: item.url,
|
|
1374
|
+
command: 'solve',
|
|
1375
|
+
tool: item.tool || 'claude',
|
|
1376
|
+
infoBlock: item.infoBlock,
|
|
1377
|
+
// Issue #1688: propagate URL context + requester so the completion
|
|
1378
|
+
// notification can append a 'Pull request:' line and skip
|
|
1379
|
+
// notifying the requester twice via /subscribe.
|
|
1380
|
+
urlContext: item.urlContext || null,
|
|
1381
|
+
requesterUserId: item.requesterUserId ?? null,
|
|
1382
|
+
});
|
|
1365
1383
|
}
|
|
1366
1384
|
}
|
|
1367
1385
|
return result;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram /subscribe and /unsubscribe command implementation (experimental).
|
|
3
|
+
*
|
|
4
|
+
* In-memory store of users who want to receive a private notification when
|
|
5
|
+
* a /solve (or alias) work session completes. Storage is intentionally
|
|
6
|
+
* volatile: subscriptions are cleared on bot restart since we do not yet
|
|
7
|
+
* have a database (issue #1688).
|
|
8
|
+
*
|
|
9
|
+
* /subscribe and /unsubscribe work in both private chats and public group
|
|
10
|
+
* chats. They store the Telegram user ID (not the chat ID) so a single
|
|
11
|
+
* user only ever receives one private notification, regardless of which
|
|
12
|
+
* chat they ran the command in.
|
|
13
|
+
*
|
|
14
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1688
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Map<userId, { username, firstName, subscribedAt, sourceChatId }>
|
|
18
|
+
const subscribers = new Map();
|
|
19
|
+
|
|
20
|
+
export function isSubscribed(userId) {
|
|
21
|
+
if (userId === null || userId === undefined) return false;
|
|
22
|
+
return subscribers.has(userId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function addSubscriber(userId, info = {}) {
|
|
26
|
+
if (userId === null || userId === undefined) return false;
|
|
27
|
+
const existed = subscribers.has(userId);
|
|
28
|
+
subscribers.set(userId, {
|
|
29
|
+
username: info.username || null,
|
|
30
|
+
firstName: info.firstName || null,
|
|
31
|
+
subscribedAt: existed ? subscribers.get(userId).subscribedAt : new Date(),
|
|
32
|
+
sourceChatId: info.sourceChatId ?? null,
|
|
33
|
+
});
|
|
34
|
+
return !existed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function removeSubscriber(userId) {
|
|
38
|
+
if (userId === null || userId === undefined) return false;
|
|
39
|
+
return subscribers.delete(userId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getSubscribers() {
|
|
43
|
+
return Array.from(subscribers.entries()).map(([userId, info]) => ({ userId, ...info }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getSubscriberCount() {
|
|
47
|
+
return subscribers.size;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resetSubscribersForTests() {
|
|
51
|
+
subscribers.clear();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Forward (or send) a session-completion notification to every subscribed user
|
|
56
|
+
* in their private chat with the bot.
|
|
57
|
+
*
|
|
58
|
+
* Strategy:
|
|
59
|
+
* 1. Try forwardMessage(userId, sourceChatId, messageId) — preserves the
|
|
60
|
+
* original visual style of the reply Telegram users already see in chat.
|
|
61
|
+
* 2. If that fails (e.g. the user has never started a private chat with
|
|
62
|
+
* the bot, or the message can't be forwarded), fall back to
|
|
63
|
+
* sendMessage(userId, fallbackText) so the notification still arrives
|
|
64
|
+
* when possible.
|
|
65
|
+
*
|
|
66
|
+
* Returns a summary so callers can log delivery results.
|
|
67
|
+
*
|
|
68
|
+
* @param {Object} params
|
|
69
|
+
* @param {Object} params.bot - Telegraf bot instance
|
|
70
|
+
* @param {number} params.fromChatId - Chat ID of the original /solve reply
|
|
71
|
+
* @param {number} params.messageId - Message ID of the (now-edited) reply
|
|
72
|
+
* @param {string} [params.fallbackText] - Plain-text body to send when forwardMessage is rejected
|
|
73
|
+
* @param {Object} [params.fallbackOptions] - Telegram sendMessage options for fallback
|
|
74
|
+
* @param {Set<number>} [params.skipUserIds] - Users that should not receive the notification (e.g. requester is already in the chat)
|
|
75
|
+
* @param {boolean} [params.verbose]
|
|
76
|
+
* @returns {Promise<{forwarded: number, sent: number, skipped: number, failures: Array}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function notifySubscribers({ bot, fromChatId, messageId, fallbackText = '', fallbackOptions = {}, skipUserIds = null, verbose = false } = {}) {
|
|
79
|
+
const summary = { forwarded: 0, sent: 0, skipped: 0, failures: [] };
|
|
80
|
+
if (!bot || !bot.telegram) {
|
|
81
|
+
if (verbose) console.log('[VERBOSE] notifySubscribers: missing bot/telegram, skipping');
|
|
82
|
+
return summary;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const [userId, info] of subscribers.entries()) {
|
|
86
|
+
if (skipUserIds && skipUserIds.has(userId)) {
|
|
87
|
+
summary.skipped += 1;
|
|
88
|
+
if (verbose) console.log(`[VERBOSE] notifySubscribers: skipping user ${userId} (in skip set)`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let forwarded = false;
|
|
93
|
+
if (fromChatId !== null && fromChatId !== undefined && messageId !== null && messageId !== undefined) {
|
|
94
|
+
try {
|
|
95
|
+
await bot.telegram.forwardMessage(userId, fromChatId, messageId);
|
|
96
|
+
summary.forwarded += 1;
|
|
97
|
+
forwarded = true;
|
|
98
|
+
if (verbose) {
|
|
99
|
+
console.log(`[VERBOSE] notifySubscribers: forwarded to user ${userId} (${info.username || info.firstName || 'unknown'})`);
|
|
100
|
+
}
|
|
101
|
+
} catch (error) {
|
|
102
|
+
if (verbose) {
|
|
103
|
+
console.log(`[VERBOSE] notifySubscribers: forwardMessage to ${userId} failed: ${error?.message || error}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (forwarded) continue;
|
|
109
|
+
|
|
110
|
+
if (!fallbackText) {
|
|
111
|
+
summary.failures.push({ userId, reason: 'forwardMessage failed and no fallback text supplied' });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
await bot.telegram.sendMessage(userId, fallbackText, fallbackOptions);
|
|
117
|
+
summary.sent += 1;
|
|
118
|
+
if (verbose) {
|
|
119
|
+
console.log(`[VERBOSE] notifySubscribers: sent fallback message to user ${userId}`);
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
summary.failures.push({ userId, reason: error?.message || String(error) });
|
|
123
|
+
if (verbose) {
|
|
124
|
+
console.log(`[VERBOSE] notifySubscribers: sendMessage to ${userId} failed: ${error?.message || error}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return summary;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const SUBSCRIBE_CONFIRMATION = '🔔 *Subscribed* (experimental)\n\n' + 'You will receive a private notification each time a /solve command finishes (in any chat where this bot runs).\n\n' + '⚠️ Subscriptions are kept in memory and are cleared whenever the bot restarts.\n\n' + '💡 If notifications never arrive, open a private chat with the bot and send /start so Telegram lets the bot DM you.\n\n' + 'Use /unsubscribe to stop receiving these notifications.';
|
|
133
|
+
|
|
134
|
+
const UNSUBSCRIBE_CONFIRMATION = '🔕 *Unsubscribed*\n\n' + 'You will no longer receive private notifications when /solve commands finish.\n\n' + 'Use /subscribe to resume notifications.';
|
|
135
|
+
|
|
136
|
+
const NOT_SUBSCRIBED_MESSAGE = 'ℹ️ You are not subscribed.\n\nUse /subscribe to start receiving private notifications when /solve commands finish.';
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Register /subscribe and /unsubscribe handlers.
|
|
140
|
+
*
|
|
141
|
+
* @param {Object} bot - Telegraf bot instance
|
|
142
|
+
* @param {Object} options - Shared command options (VERBOSE, isOldMessage, ...)
|
|
143
|
+
*/
|
|
144
|
+
export function registerSubscribeCommands(bot, options = {}) {
|
|
145
|
+
const { VERBOSE = false, isOldMessage, isForwardedOrReply, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb } = options;
|
|
146
|
+
|
|
147
|
+
async function shouldHandle(ctx, cmdName) {
|
|
148
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} command received`);
|
|
149
|
+
if (addBreadcrumb) {
|
|
150
|
+
await addBreadcrumb({
|
|
151
|
+
category: 'telegram.command',
|
|
152
|
+
message: `${cmdName} command received`,
|
|
153
|
+
level: 'info',
|
|
154
|
+
data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (isOldMessage && isOldMessage(ctx)) {
|
|
158
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: old message`);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
if (isForwardedOrReply && isForwardedOrReply(ctx)) {
|
|
162
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: forwarded or reply`);
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
// Issue #1688: /subscribe and /unsubscribe work in both private and group chats.
|
|
166
|
+
// In group chats we still require chat/topic authorization so unauthorized
|
|
167
|
+
// chats cannot use the bot to spam.
|
|
168
|
+
const chatType = ctx.chat?.type;
|
|
169
|
+
const isPrivateChat = chatType === 'private';
|
|
170
|
+
if (!isPrivateChat && isTopicAuthorized && !isTopicAuthorized(ctx)) {
|
|
171
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: not authorized`);
|
|
172
|
+
if (buildAuthErrorMessage) {
|
|
173
|
+
await ctx.reply(buildAuthErrorMessage(ctx), { reply_to_message_id: ctx.message?.message_id });
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (!ctx.from?.id) {
|
|
178
|
+
VERBOSE && console.log(`[VERBOSE] ${cmdName} ignored: no user id on update`);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
bot.command('subscribe', async ctx => {
|
|
185
|
+
if (!(await shouldHandle(ctx, '/subscribe'))) return;
|
|
186
|
+
|
|
187
|
+
const userId = ctx.from.id;
|
|
188
|
+
const wasNew = addSubscriber(userId, {
|
|
189
|
+
username: ctx.from.username,
|
|
190
|
+
firstName: ctx.from.first_name,
|
|
191
|
+
sourceChatId: ctx.chat?.id ?? null,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
let message = SUBSCRIBE_CONFIRMATION;
|
|
195
|
+
if (!wasNew) {
|
|
196
|
+
message = 'ℹ️ You are already subscribed.\n\nUse /unsubscribe to stop receiving private notifications when /solve commands finish.';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
await ctx.reply(message, {
|
|
200
|
+
parse_mode: 'Markdown',
|
|
201
|
+
reply_to_message_id: ctx.message?.message_id,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
VERBOSE && console.log(`[VERBOSE] Subscriber ${userId} (${ctx.from.username || ctx.from.first_name || 'unknown'}) added (new=${wasNew}); total=${getSubscriberCount()}`);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
bot.command('unsubscribe', async ctx => {
|
|
208
|
+
if (!(await shouldHandle(ctx, '/unsubscribe'))) return;
|
|
209
|
+
|
|
210
|
+
const userId = ctx.from.id;
|
|
211
|
+
const wasRemoved = removeSubscriber(userId);
|
|
212
|
+
|
|
213
|
+
await ctx.reply(wasRemoved ? UNSUBSCRIBE_CONFIRMATION : NOT_SUBSCRIBED_MESSAGE, {
|
|
214
|
+
parse_mode: 'Markdown',
|
|
215
|
+
reply_to_message_id: ctx.message?.message_id,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
VERBOSE && console.log(`[VERBOSE] Subscriber ${userId} (${ctx.from.username || ctx.from.first_name || 'unknown'}) removed=${wasRemoved}; total=${getSubscriberCount()}`);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -52,7 +52,38 @@ export function formatExecutingWorkSessionMessage({ sessionName = 'unknown', iso
|
|
|
52
52
|
return `⏳ Executing...\n\n📊 Session: \`${sessionName}\`${isolationInfo}${details}`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Append an extra "Pull request:" line to an existing infoBlock when an issue's
|
|
57
|
+
* /solve session has produced a PR. Idempotent — already present URLs are not
|
|
58
|
+
* duplicated.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} infoBlock - Existing infoBlock (already contains an Issue: line)
|
|
61
|
+
* @param {string|null} pullRequestUrl - PR URL discovered after the session completed
|
|
62
|
+
* @returns {string} New infoBlock
|
|
63
|
+
*
|
|
64
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1688
|
|
65
|
+
*/
|
|
66
|
+
export function appendPullRequestLine(infoBlock, pullRequestUrl) {
|
|
67
|
+
if (!pullRequestUrl || !infoBlock) return infoBlock || '';
|
|
68
|
+
if (infoBlock.includes(pullRequestUrl)) return infoBlock;
|
|
69
|
+
|
|
70
|
+
const lines = infoBlock.split('\n');
|
|
71
|
+
let lastUrlLineIdx = -1;
|
|
72
|
+
for (let i = 0; i < lines.length; i++) {
|
|
73
|
+
if (/^(Issue|Pull request|URL):\s/.test(lines[i])) {
|
|
74
|
+
lastUrlLineIdx = i;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const prLine = `Pull request: ${pullRequestUrl}`;
|
|
78
|
+
if (lastUrlLineIdx === -1) {
|
|
79
|
+
return `${infoBlock}\n${prLine}`;
|
|
80
|
+
}
|
|
81
|
+
const before = lines.slice(0, lastUrlLineIdx + 1);
|
|
82
|
+
const after = lines.slice(lastUrlLineIdx + 1);
|
|
83
|
+
return [...before, prLine, ...after].join('\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatSessionCompletionMessage({ sessionName, sessionInfo, statusResult = null, observedEndTime = new Date(), exitCode = null, infoBlock = '', pullRequestUrl = null } = {}) {
|
|
56
87
|
const finalExitCode = getSessionCompletionExitCode({ exitCode, statusResult });
|
|
57
88
|
const failed = finalExitCode !== null && finalExitCode !== 0;
|
|
58
89
|
const statusEmoji = failed ? '❌' : '✅';
|
|
@@ -61,7 +92,10 @@ export function formatSessionCompletionMessage({ sessionName, sessionInfo, statu
|
|
|
61
92
|
const startTime = parseDateValue(statusResult?.startTime) || parseDateValue(sessionInfo?.startTime) || observedEndTime;
|
|
62
93
|
const endTime = parseDateValue(statusResult?.endTime) || observedEndTime;
|
|
63
94
|
const durationSeconds = Math.max(0, (endTime.getTime() - startTime.getTime()) / 1000);
|
|
64
|
-
|
|
95
|
+
let resolvedInfoBlock = infoBlock || sessionInfo?.infoBlock || '';
|
|
96
|
+
// Issue #1688: When the agent created a PR for an issue-driven /solve, append
|
|
97
|
+
// a 'Pull request:' line so the completion message includes both Issue and PR links.
|
|
98
|
+
if (pullRequestUrl) resolvedInfoBlock = appendPullRequestLine(resolvedInfoBlock, pullRequestUrl);
|
|
65
99
|
const details = resolvedInfoBlock ? `\n\n${resolvedInfoBlock}` : '';
|
|
66
100
|
|
|
67
101
|
let message = `${statusEmoji} *${statusText}*\n\n`;
|