@link-assistant/hive-mind 2.1.4 → 2.1.5

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,11 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 26b07f2: Extend Telegram `/merge` to accept repository, issue, and pull request targets, including reply-based GitHub link extraction, and wait for unfinished PRs to become mergeable before merging.
8
+
3
9
  ## 2.1.4
4
10
 
5
11
  ### Patch Changes
package/README.hi.md CHANGED
@@ -511,6 +511,26 @@ Examples:
511
511
  /hive https://github.com/microsoft --all-issues --concurrency 3
512
512
  ```
513
513
 
514
+ #### `/merge` - तैयार Pull Requests merge करें
515
+
516
+ ```
517
+ /merge <repository-url|issue-url|pull-request-url> [--auto-resolve]
518
+
519
+ Examples:
520
+ /merge https://github.com/owner/repo
521
+ /merge https://github.com/owner/repo/issues/123
522
+ /merge https://github.com/owner/repo/pull/456
523
+ ```
524
+
525
+ Repository targets `ready` label वाले PRs को क्रम से process करते हैं। Issue और
526
+ pull request targets केवल linked या selected PR process करते हैं। आप किसी ऐसे
527
+ message पर `/merge` से reply भी कर सकते हैं जिसमें एक GitHub repository, issue,
528
+ या pull request link हो, जैसे कोई पुराना `/codex ...issues/123` command.
529
+
530
+ अगर target PR अभी finished नहीं है, तो `/merge` merge करने से पहले उसके
531
+ mergeable होने तक wait करता है। Merge-conflict skips अभी भी `--auto-resolve` के
532
+ साथ काम करते हैं।
533
+
514
534
  #### `/limits` - उपयोग सीमाएँ दिखाएँ
515
535
 
516
536
  ```
package/README.md CHANGED
@@ -532,6 +532,25 @@ Examples:
532
532
  /hive https://github.com/microsoft --all-issues --concurrency 3
533
533
  ```
534
534
 
535
+ #### `/merge` - Merge Ready Pull Requests
536
+
537
+ ```
538
+ /merge <repository-url|issue-url|pull-request-url> [--auto-resolve]
539
+
540
+ Examples:
541
+ /merge https://github.com/owner/repo
542
+ /merge https://github.com/owner/repo/issues/123
543
+ /merge https://github.com/owner/repo/pull/456
544
+ ```
545
+
546
+ Repository targets process PRs with the `ready` label sequentially. Issue and
547
+ pull request targets process only the linked or selected PR. You can also reply
548
+ with `/merge` to a message containing one GitHub repository, issue, or pull
549
+ request link, such as a previous `/codex ...issues/123` command.
550
+
551
+ If a target PR is not finished yet, `/merge` waits for it to become mergeable
552
+ before merging. Merge-conflict skips still work with `--auto-resolve`.
553
+
535
554
  #### `/limits` - Show Usage Limits
536
555
 
537
556
  ```
package/README.ru.md CHANGED
@@ -512,6 +512,26 @@ Examples:
512
512
  /hive https://github.com/microsoft --all-issues --concurrency 3
513
513
  ```
514
514
 
515
+ #### `/merge` — Слияние готовых Pull Request
516
+
517
+ ```
518
+ /merge <repository-url|issue-url|pull-request-url> [--auto-resolve]
519
+
520
+ Examples:
521
+ /merge https://github.com/owner/repo
522
+ /merge https://github.com/owner/repo/issues/123
523
+ /merge https://github.com/owner/repo/pull/456
524
+ ```
525
+
526
+ Цель-репозиторий обрабатывает PR с меткой `ready` последовательно. Цели issue и
527
+ pull request обрабатывают только связанный или выбранный PR. Также можно ответить
528
+ командой `/merge` на сообщение с одной ссылкой на GitHub repository, issue или
529
+ pull request, например на предыдущую команду `/codex ...issues/123`.
530
+
531
+ Если целевой PR ещё не завершён, `/merge` ждёт, пока он станет mergeable, и затем
532
+ выполняет слияние. Пропуск конфликтов слияния по-прежнему работает с
533
+ `--auto-resolve`.
534
+
515
535
  #### `/limits` — Показать лимиты использования
516
536
 
517
537
  ```
package/README.zh.md CHANGED
@@ -508,6 +508,25 @@ Examples:
508
508
  /hive https://github.com/microsoft --all-issues --concurrency 3
509
509
  ```
510
510
 
511
+ #### `/merge` - 合并就绪的 Pull Request
512
+
513
+ ```
514
+ /merge <repository-url|issue-url|pull-request-url> [--auto-resolve]
515
+
516
+ Examples:
517
+ /merge https://github.com/owner/repo
518
+ /merge https://github.com/owner/repo/issues/123
519
+ /merge https://github.com/owner/repo/pull/456
520
+ ```
521
+
522
+ 仓库目标会按顺序处理带有 `ready` 标签的 PR。Issue 和 Pull Request
523
+ 目标只处理关联或指定的 PR。你也可以回复一条包含单个 GitHub
524
+ 仓库、Issue 或 Pull Request 链接的消息并发送 `/merge`,例如之前的
525
+ `/codex ...issues/123` 命令。
526
+
527
+ 如果目标 PR 尚未完成,`/merge` 会等待它变为可合并后再合并。合并冲突跳过仍可配合
528
+ `--auto-resolve` 使用。
529
+
511
530
  #### `/limits` - 显示用量限制
512
531
 
513
532
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.1.4",
3
+ "version": "2.1.5",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitHub merge target helpers for `/merge`.
4
+ *
5
+ * Repository-wide merge queues still use github-merge.lib.mjs. This module
6
+ * handles the narrower targets introduced in issue #2013: a single PR URL, an
7
+ * issue URL with linked PRs, or a GitHub URL found in a replied message.
8
+ *
9
+ * @see https://github.com/link-assistant/hive-mind/issues/2013
10
+ */
11
+
12
+ import { promisify } from 'node:util';
13
+ import { exec as execCallback } from 'node:child_process';
14
+
15
+ import { githubLimits } from './config.lib.mjs';
16
+ import { parseGitHubUrl } from './github.lib.mjs';
17
+ import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
18
+ import { getLinkedPRsFromTimeline } from './github-merge-ready-sync.lib.mjs';
19
+
20
+ const execRaw = promisify(execCallback);
21
+ const exec = (cmd, opts = {}) =>
22
+ ghWithRateLimitRetry(() => execRaw(cmd, { maxBuffer: githubLimits.bufferMaxSize, ...opts }), {
23
+ label: `gh exec (${cmd.split(/\s+/).slice(0, 3).join(' ')})`,
24
+ });
25
+
26
+ const SUPPORTED_REPOSITORY_TYPES = new Set(['repo', 'issues_list', 'pulls_list']);
27
+ const SUPPORTED_TARGET_TYPES = new Set([...SUPPORTED_REPOSITORY_TYPES, 'issue', 'pull']);
28
+
29
+ function buildIssueUrl(owner, repo, issueNumber) {
30
+ return `https://github.com/${owner}/${repo}/issues/${issueNumber}`;
31
+ }
32
+
33
+ function buildPullUrl(owner, repo, prNumber) {
34
+ return `https://github.com/${owner}/${repo}/pull/${prNumber}`;
35
+ }
36
+
37
+ /**
38
+ * Parse a `/merge` target URL.
39
+ *
40
+ * @param {string} url
41
+ * @returns {{valid: boolean, mode?: 'repository'|'issue'|'pull', owner: string|null, repo: string|null, issueNumber?: number, prNumber?: number, url?: string, error: string|null}}
42
+ */
43
+ export function parseMergeTargetUrl(url) {
44
+ const parsed = parseGitHubUrl(url);
45
+
46
+ if (!parsed.valid) {
47
+ return { valid: false, owner: null, repo: null, error: parsed.error };
48
+ }
49
+
50
+ if (SUPPORTED_REPOSITORY_TYPES.has(parsed.type)) {
51
+ return {
52
+ valid: true,
53
+ mode: 'repository',
54
+ owner: parsed.owner,
55
+ repo: parsed.repo,
56
+ url: `https://github.com/${parsed.owner}/${parsed.repo}`,
57
+ error: null,
58
+ };
59
+ }
60
+
61
+ if (parsed.type === 'issue') {
62
+ return {
63
+ valid: true,
64
+ mode: 'issue',
65
+ owner: parsed.owner,
66
+ repo: parsed.repo,
67
+ issueNumber: parsed.number,
68
+ url: buildIssueUrl(parsed.owner, parsed.repo, parsed.number),
69
+ error: null,
70
+ };
71
+ }
72
+
73
+ if (parsed.type === 'pull') {
74
+ return {
75
+ valid: true,
76
+ mode: 'pull',
77
+ owner: parsed.owner,
78
+ repo: parsed.repo,
79
+ prNumber: parsed.number,
80
+ url: buildPullUrl(parsed.owner, parsed.repo, parsed.number),
81
+ error: null,
82
+ };
83
+ }
84
+
85
+ if (parsed.type === 'user' || parsed.type === 'organization') {
86
+ return {
87
+ valid: false,
88
+ owner: parsed.owner,
89
+ repo: null,
90
+ error: 'URL points to a user/organization. Please provide a repository, issue, or pull request URL.',
91
+ };
92
+ }
93
+
94
+ return {
95
+ valid: false,
96
+ owner: parsed.owner || null,
97
+ repo: parsed.repo || null,
98
+ error: `URL type '${parsed.type}' is not supported for /merge. Please provide a repository, issue, or pull request URL.`,
99
+ };
100
+ }
101
+
102
+ function trimCandidateUrl(candidate) {
103
+ return String(candidate || '')
104
+ .trim()
105
+ .replace(/^[<([{'"]+/, '')
106
+ .replace(/[>)\]}'".,;:!?]+$/, '');
107
+ }
108
+
109
+ /**
110
+ * Extract a single `/merge` target URL from arbitrary text, such as a replied
111
+ * `/codex https://github.com/owner/repo/issues/123 --think max` message.
112
+ *
113
+ * @param {string} text
114
+ * @returns {{valid: boolean, url: string|null, target: Object|null, error: string|null}}
115
+ */
116
+ export function extractMergeTargetUrlFromText(text) {
117
+ if (!text || typeof text !== 'string') {
118
+ return { valid: false, url: null, target: null, error: 'No text to inspect for GitHub links' };
119
+ }
120
+
121
+ const pattern = /(?:https?:\/\/)?github\.com\/[^\s<>)\]]+|[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/(?:issues|pull)\/\d+/g;
122
+ const matches = text.match(pattern) || [];
123
+ const targetsByUrl = new Map();
124
+
125
+ for (const match of matches) {
126
+ const candidate = trimCandidateUrl(match);
127
+ const target = parseMergeTargetUrl(candidate);
128
+ if (target.valid && SUPPORTED_TARGET_TYPES.has(target.mode === 'repository' ? 'repo' : target.mode)) {
129
+ targetsByUrl.set(target.url, target);
130
+ }
131
+ }
132
+
133
+ const targets = [...targetsByUrl.values()];
134
+ if (targets.length === 0) {
135
+ return { valid: false, url: null, target: null, error: 'No GitHub repository, issue, or pull request link found' };
136
+ }
137
+ if (targets.length > 1) {
138
+ return { valid: false, url: null, target: null, error: `Expected one GitHub merge target, found ${targets.length}` };
139
+ }
140
+
141
+ const [target] = targets;
142
+ return { valid: true, url: target.url, target, error: null };
143
+ }
144
+
145
+ /**
146
+ * Fetch a PR in the shape expected by MergeQueueItem.
147
+ *
148
+ * @param {string} owner
149
+ * @param {string} repo
150
+ * @param {number} prNumber
151
+ * @param {Object|null} issue
152
+ * @param {boolean} verbose
153
+ * @returns {Promise<{pr: Object, issue: Object|null, sortDate: Date}>}
154
+ */
155
+ export async function fetchMergeTargetPullRequest(owner, repo, prNumber, issue = null, verbose = false) {
156
+ const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json number,title,url,createdAt,headRefName,author,mergeable,mergeStateStatus,isDraft,state`);
157
+ const pr = JSON.parse(stdout.trim());
158
+ if (verbose) {
159
+ console.log(`[VERBOSE] /merge: Loaded target PR #${pr.number} from ${owner}/${repo}`);
160
+ }
161
+ return {
162
+ pr,
163
+ issue,
164
+ sortDate: new Date(pr.createdAt),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Resolve a non-repository merge target into queue item data.
170
+ *
171
+ * @param {{mode: string, owner: string, repo: string, issueNumber?: number, prNumber?: number, url?: string}} target
172
+ * @param {boolean} verbose
173
+ * @returns {Promise<Array<{pr: Object, issue: Object|null, sortDate: Date}>|null>}
174
+ */
175
+ export async function resolveMergeTargetItems(target, verbose = false) {
176
+ if (!target || target.mode === 'repository') {
177
+ return null;
178
+ }
179
+
180
+ if (target.mode === 'pull') {
181
+ return [await fetchMergeTargetPullRequest(target.owner, target.repo, target.prNumber, null, verbose)];
182
+ }
183
+
184
+ if (target.mode === 'issue') {
185
+ const issue = {
186
+ number: target.issueNumber,
187
+ url: target.url || buildIssueUrl(target.owner, target.repo, target.issueNumber),
188
+ };
189
+ const linkedPRs = await getLinkedPRsFromTimeline(target.owner, target.repo, target.issueNumber, verbose);
190
+ const items = [];
191
+ for (const linkedPR of linkedPRs) {
192
+ items.push(await fetchMergeTargetPullRequest(target.owner, target.repo, linkedPR.number, issue, verbose));
193
+ }
194
+ return items.sort((a, b) => a.sortDate - b.sortDate);
195
+ }
196
+
197
+ throw new Error(`Unsupported merge target mode: ${target.mode}`);
198
+ }
199
+
200
+ export default {
201
+ parseMergeTargetUrl,
202
+ extractMergeTargetUrlFromText,
203
+ fetchMergeTargetPullRequest,
204
+ resolveMergeTargetItems,
205
+ };
@@ -18,7 +18,8 @@
18
18
  * @see https://github.com/link-assistant/hive-mind/issues/1143
19
19
  */
20
20
 
21
- import { parseRepositoryUrl, checkLabelPermissions, ensureReadyLabel } from './github-merge.lib.mjs';
21
+ import { checkLabelPermissions, ensureReadyLabel } from './github-merge.lib.mjs';
22
+ import { extractMergeTargetUrlFromText, parseMergeTargetUrl } from './github-merge-targets.lib.mjs';
22
23
  import { createMergeQueueProcessor, MergeStatus, MERGE_QUEUE_CONFIG } from './telegram-merge-queue.lib.mjs';
23
24
  import { executeStartScreen } from './telegram-command-execution.lib.mjs';
24
25
 
@@ -129,6 +130,81 @@ export function parseMergeArgs(args) {
129
130
  return { positionals, flags };
130
131
  }
131
132
 
133
+ /**
134
+ * A real user reply can carry the issue/PR URL for `/merge`; forwarded
135
+ * commands still must not be replayed by the bot.
136
+ *
137
+ * @param {Object} ctx - Telegraf context
138
+ * @param {Object} filters - Message filter callbacks
139
+ * @returns {boolean}
140
+ */
141
+ export function shouldIgnoreMergeCommand(ctx, filters = {}) {
142
+ if (filters.isOldMessage && filters.isOldMessage(ctx)) {
143
+ return true;
144
+ }
145
+ if (typeof filters.isForwarded === 'function') {
146
+ return filters.isForwarded(ctx);
147
+ }
148
+ if (typeof filters.isForwardedOrReply === 'function') {
149
+ return filters.isForwardedOrReply(ctx);
150
+ }
151
+ return false;
152
+ }
153
+
154
+ function getMergeReplyText(message) {
155
+ const reply = message?.reply_to_message;
156
+ if (!reply || reply.forum_topic_created) {
157
+ return '';
158
+ }
159
+ return reply.text || reply.caption || '';
160
+ }
161
+
162
+ function getMergeUsageMessage() {
163
+ return "Missing merge target\\.\n\nUsage: `/merge <repository-url|issue-url|pull-request-url> [--auto-resolve]`\n\nYou can also reply with `/merge` to a message containing one GitHub repository, issue, or pull request link\\.\n\nExamples:\n`/merge https://github.com/owner/repo`\n`/merge https://github.com/owner/repo/issues/123`\n`/merge https://github.com/owner/repo/pull/456`\n\nRepository targets merge all PRs with the 'ready' label\\. Issue and pull request targets wait until the target PR is mergeable, then merge it\\.\n\nWith `--auto-resolve` the bot also dispatches `/solve <pr> --auto-merge` for every PR that was skipped because of merge conflicts\\.";
164
+ }
165
+
166
+ function getTargetFoundText(target, count) {
167
+ if (target.mode === 'repository') {
168
+ return `Found ${count} PRs with 'ready' label\\.`;
169
+ }
170
+ const plural = count === 1 ? '' : 's';
171
+ return `Found ${count} target PR${plural} to merge\\.`;
172
+ }
173
+
174
+ /**
175
+ * Resolve the merge target from explicit args, or from a replied message when
176
+ * `/merge` is sent without a URL.
177
+ *
178
+ * @param {string[]} positionals
179
+ * @param {Object} message
180
+ * @returns {{target: Object|null, targetUrl: string|null, fromReply: boolean, error: string|null}}
181
+ */
182
+ export function resolveMergeCommandTarget(positionals, message) {
183
+ if (positionals.length > 0) {
184
+ const targetUrl = positionals[0];
185
+ const target = parseMergeTargetUrl(targetUrl);
186
+ return {
187
+ target,
188
+ targetUrl,
189
+ fromReply: false,
190
+ error: target.valid ? null : target.error,
191
+ };
192
+ }
193
+
194
+ const replyText = getMergeReplyText(message);
195
+ if (!replyText) {
196
+ return { target: null, targetUrl: null, fromReply: false, error: null };
197
+ }
198
+
199
+ const extracted = extractMergeTargetUrlFromText(replyText);
200
+ return {
201
+ target: extracted.target,
202
+ targetUrl: extracted.url,
203
+ fromReply: true,
204
+ error: extracted.valid ? null : extracted.error,
205
+ };
206
+ }
207
+
132
208
  /**
133
209
  * Issue #1805: Spawner used by the merge queue's auto-resolve pass. For each
134
210
  * skipped PR we dispatch a `solve <pr-url> --auto-merge` session through
@@ -209,7 +285,7 @@ function formatUserError(error, verbose) {
209
285
  * @param {Function} [options.getStoppedChatRejectMessage] - Function to get stopped chat rejection message
210
286
  */
211
287
  export function registerMergeCommand(bot, options) {
212
- const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage } = options;
288
+ const { VERBOSE = false, isOldMessage, isForwarded, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, isChatStopped, getStoppedChatRejectMessage } = options;
213
289
 
214
290
  bot.command(/^merge$/i, async ctx => {
215
291
  VERBOSE && console.log('[VERBOSE] /merge command received');
@@ -222,7 +298,7 @@ export function registerMergeCommand(bot, options) {
222
298
  });
223
299
 
224
300
  // Standard checks
225
- if (isOldMessage(ctx) || isForwardedOrReply(ctx)) return;
301
+ if (shouldIgnoreMergeCommand(ctx, { isOldMessage, isForwarded, isForwardedOrReply })) return;
226
302
 
227
303
  if (!isGroupChat(ctx)) {
228
304
  return await ctx.reply('The /merge command only works in group chats. Please add this bot to a group and make it an admin.', {
@@ -252,24 +328,25 @@ export function registerMergeCommand(bot, options) {
252
328
  const { positionals, flags } = parseMergeArgs(args);
253
329
  const autoResolve = flags['auto-resolve'] === true;
254
330
 
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 });
257
- }
331
+ const targetResult = resolveMergeCommandTarget(positionals, ctx.message);
258
332
 
259
- // Parse and validate repository URL
260
- const repoUrl = positionals[0];
261
- const parsedUrl = parseRepositoryUrl(repoUrl);
333
+ if (!targetResult.target && !targetResult.error) {
334
+ return await ctx.reply(getMergeUsageMessage(), { parse_mode: 'MarkdownV2', reply_to_message_id: ctx.message.message_id });
335
+ }
262
336
 
263
- if (!parsedUrl.valid) {
264
- return await ctx.reply(`Invalid repository URL: ${escapeMarkdownV2(parsedUrl.error)}\n\nPlease provide a valid GitHub repository URL\\.`, {
337
+ if (!targetResult.target || !targetResult.target.valid) {
338
+ const invalidPrefix = targetResult.fromReply ? 'Invalid merge target in replied message' : 'Invalid merge target';
339
+ return await ctx.reply(`${invalidPrefix}: ${escapeMarkdownV2(targetResult.error || targetResult.target?.error || 'Unknown target error')}\n\nPlease provide a valid GitHub repository, issue, or pull request URL\\.`, {
265
340
  parse_mode: 'MarkdownV2',
266
341
  reply_to_message_id: ctx.message.message_id,
267
342
  });
268
343
  }
269
344
 
270
- const { owner, repo } = parsedUrl;
345
+ const target = targetResult.target;
346
+ const targetUrl = targetResult.targetUrl || target.url;
347
+ const { owner, repo } = target;
271
348
  const repoKey = getRepoKey(owner, repo);
272
- VERBOSE && console.log(`[VERBOSE] /merge: Processing repository ${owner}/${repo}`);
349
+ VERBOSE && console.log(`[VERBOSE] /merge: Processing ${target.mode} target ${targetUrl} in ${owner}/${repo}`);
273
350
 
274
351
  // Check if a merge operation is already running for this repository (per-repository concurrency)
275
352
  if (activeMergeOperations.has(repoKey)) {
@@ -310,6 +387,7 @@ export function registerMergeCommand(bot, options) {
310
387
  // Create the merge queue processor
311
388
  const processor = await createMergeQueueProcessor(owner, repo, {
312
389
  verbose: VERBOSE,
390
+ target,
313
391
  // Issue #1805: forward the --auto-resolve flag and inject the spawner.
314
392
  // The processor only sees the callback, so unit tests can stub it
315
393
  // without spawning real screen sessions.
@@ -384,14 +462,14 @@ export function registerMergeCommand(bot, options) {
384
462
 
385
463
  if (initResult.message) {
386
464
  // No PRs to merge
387
- 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' });
465
+ 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 repository PRs, or ensure the issue has a linked open PR\n2\\. Run \`/merge ${escapeMarkdownV2(targetUrl)}\` again`, { parse_mode: 'MarkdownV2' });
388
466
  return;
389
467
  }
390
468
 
391
469
  // Update message with PR list and cancel button, start processing
392
470
  const truncatedMsg = initResult.truncated ? `\n\n_Note: Only processing first ${MERGE_QUEUE_CONFIG.MAX_PRS_PER_SESSION} PRs_` : '';
393
471
 
394
- 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\\.\\.\\.`, {
472
+ await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, `*Merge Queue \\- ${escapeMarkdownV2(owner)}/${escapeMarkdownV2(repo)}*${labelMsg}\n\n${getTargetFoundText(target, initResult.count)}${escapeMarkdownV2(truncatedMsg)}\n\nStarting merge process\\.\\.\\.`, {
395
473
  parse_mode: 'MarkdownV2',
396
474
  reply_markup: {
397
475
  inline_keyboard: [[{ text: '🛑 Cancel', callback_data: `merge_cancel_${repoKey}` }]],
@@ -17,6 +17,8 @@
17
17
  */
18
18
 
19
19
  import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel, waitForBranchCI, getDefaultBranch, waitForCommitCI, checkBranchCIHealth, getMergeCommitSha, getPRStatus, syncReadyTags, closeLinkedIssueIfNotAutoClosed } from './github-merge.lib.mjs';
20
+ import { resolveMergeTargetItems } from './github-merge-targets.lib.mjs';
21
+ import { waitForPRReady as waitForPRReadyHelper } from './telegram-merge-wait.lib.mjs';
20
22
  import { mergeQueue as mergeQueueConfig } from './config.lib.mjs';
21
23
  import { getProgressBar } from './limits.lib.mjs';
22
24
 
@@ -38,6 +40,7 @@ export const MergeStatus = {
38
40
  export const MergeItemStatus = {
39
41
  PENDING: 'pending',
40
42
  CHECKING_CI: 'checking_ci',
43
+ WAITING_READY: 'waiting_ready',
41
44
  WAITING_CI: 'waiting_ci',
42
45
  READY_TO_MERGE: 'ready_to_merge',
43
46
  MERGING: 'merging',
@@ -144,6 +147,8 @@ class MergeQueueItem {
144
147
  return '⏳';
145
148
  case MergeItemStatus.CHECKING_CI:
146
149
  return '🔍';
150
+ case MergeItemStatus.WAITING_READY:
151
+ return '⏱️';
147
152
  case MergeItemStatus.WAITING_CI:
148
153
  return '⏱️';
149
154
  case MergeItemStatus.READY_TO_MERGE:
@@ -185,6 +190,8 @@ export class MergeQueueProcessor {
185
190
  // tests can run without touching the screen runtime.
186
191
  this.autoResolve = options.autoResolve === true;
187
192
  this.spawnSolveSession = typeof options.spawnSolveSession === 'function' ? options.spawnSolveSession : null;
193
+ this.target = options.target || { mode: 'repository', owner: this.owner, repo: this.repo, url: `https://github.com/${this.owner}/${this.repo}` };
194
+ this.waitForUnfinished = options.waitForUnfinished !== false;
188
195
 
189
196
  // State
190
197
  this.items = [];
@@ -208,6 +215,17 @@ export class MergeQueueProcessor {
208
215
  this.getPRStatus = typeof options.getPRStatus === 'function' ? options.getPRStatus : getPRStatus;
209
216
  // Same idea for `getMergeCommitSha` so tests don't need to stub gh.
210
217
  this.getMergeCommitSha = typeof options.getMergeCommitSha === 'function' ? options.getMergeCommitSha : getMergeCommitSha;
218
+ this.checkPRMergeable = typeof options.checkPRMergeable === 'function' ? options.checkPRMergeable : checkPRMergeable;
219
+ this.checkPRCIStatus = typeof options.checkPRCIStatus === 'function' ? options.checkPRCIStatus : checkPRCIStatus;
220
+ this.waitForCI = typeof options.waitForCI === 'function' ? options.waitForCI : waitForCI;
221
+ this.mergePullRequest = typeof options.mergePullRequest === 'function' ? options.mergePullRequest : mergePullRequest;
222
+ this.closeLinkedIssueIfNotAutoClosed = typeof options.closeLinkedIssueIfNotAutoClosed === 'function' ? options.closeLinkedIssueIfNotAutoClosed : closeLinkedIssueIfNotAutoClosed;
223
+ this.resolveMergeTargetItems = typeof options.resolveMergeTargetItems === 'function' ? options.resolveMergeTargetItems : resolveMergeTargetItems;
224
+ this.ensureReadyLabel = typeof options.ensureReadyLabel === 'function' ? options.ensureReadyLabel : ensureReadyLabel;
225
+ this.syncReadyTags = typeof options.syncReadyTags === 'function' ? options.syncReadyTags : syncReadyTags;
226
+ this.getAllReadyPRs = typeof options.getAllReadyPRs === 'function' ? options.getAllReadyPRs : getAllReadyPRs;
227
+ this.targetItemsTimeoutMs = options.targetItemsTimeoutMs ?? MERGE_QUEUE_CONFIG.CI_TIMEOUT_MS;
228
+ this.targetItemsPollIntervalMs = options.targetItemsPollIntervalMs ?? MERGE_QUEUE_CONFIG.CI_POLL_INTERVAL_MS;
211
229
 
212
230
  // Statistics
213
231
  this.stats = {
@@ -241,7 +259,7 @@ export class MergeQueueProcessor {
241
259
  this.log(`Initializing merge queue for ${this.owner}/${this.repo}`);
242
260
 
243
261
  // Ensure ready label exists
244
- const labelResult = await ensureReadyLabel(this.owner, this.repo, this.verbose);
262
+ const labelResult = await this.ensureReadyLabel(this.owner, this.repo, this.verbose);
245
263
  if (!labelResult.success) {
246
264
  return { success: false, error: labelResult.error };
247
265
  }
@@ -249,21 +267,27 @@ export class MergeQueueProcessor {
249
267
  this.log("Created 'ready' label in repository");
250
268
  }
251
269
 
252
- // Issue #1367: Sync 'ready' tags between linked PRs and issues before collecting the queue
253
- // This ensures the final list reflects all ready work regardless of where the tag was applied
254
- const syncResult = await syncReadyTags(this.owner, this.repo, this.verbose);
255
- if (syncResult.synced > 0) {
256
- this.log(`Synced 'ready' tag: ${syncResult.synced} item(s) updated`);
257
- }
258
- if (syncResult.errors > 0) {
259
- this.log(`Tag sync had ${syncResult.errors} error(s) (non-fatal, proceeding)`);
260
- }
270
+ let readyPRs;
271
+ if (this.target?.mode && this.target.mode !== 'repository') {
272
+ readyPRs = await this.resolveMergeTargetItemsWithWait();
273
+ } else {
274
+ // Issue #1367: Sync 'ready' tags between linked PRs and issues before collecting the queue
275
+ // This ensures the final list reflects all ready work regardless of where the tag was applied
276
+ const syncResult = await this.syncReadyTags(this.owner, this.repo, this.verbose);
277
+ if (syncResult.synced > 0) {
278
+ this.log(`Synced 'ready' tag: ${syncResult.synced} item(s) updated`);
279
+ }
280
+ if (syncResult.errors > 0) {
281
+ this.log(`Tag sync had ${syncResult.errors} error(s) (non-fatal, proceeding)`);
282
+ }
261
283
 
262
- // Fetch all ready PRs
263
- const readyPRs = await getAllReadyPRs(this.owner, this.repo, this.verbose);
284
+ // Fetch all ready PRs
285
+ readyPRs = await this.getAllReadyPRs(this.owner, this.repo, this.verbose);
286
+ }
264
287
 
265
288
  if (readyPRs.length === 0) {
266
- return { success: true, error: null, message: "No PRs with 'ready' label found" };
289
+ const message = this.target?.mode === 'issue' ? `No open PRs linked to issue #${this.target.issueNumber} found` : this.target?.mode === 'pull' ? `Pull request #${this.target.prNumber} was not found` : "No PRs with 'ready' label found";
290
+ return { success: true, error: null, message };
267
291
  }
268
292
 
269
293
  // Limit to max PRs per session
@@ -279,6 +303,7 @@ export class MergeQueueProcessor {
279
303
  success: true,
280
304
  error: null,
281
305
  count: this.items.length,
306
+ targetMode: this.target?.mode || 'repository',
282
307
  truncated: readyPRs.length > MERGE_QUEUE_CONFIG.MAX_PRS_PER_SESSION,
283
308
  };
284
309
  } catch (error) {
@@ -419,6 +444,46 @@ export class MergeQueueProcessor {
419
444
  }
420
445
  }
421
446
 
447
+ /**
448
+ * Resolve targeted PR data, waiting for issue-linked PRs that may be created
449
+ * shortly after a replied `/codex <issue>` message.
450
+ *
451
+ * @returns {Promise<Array<{pr: Object, issue: Object|null, sortDate: Date}>>}
452
+ */
453
+ async resolveMergeTargetItemsWithWait() {
454
+ const maxPollInterval = Math.max(1, this.targetItemsPollIntervalMs || 1);
455
+ const maxAttempts = Math.max(1, Math.ceil((this.targetItemsTimeoutMs || 0) / maxPollInterval));
456
+
457
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
458
+ if (this.isCancelled) {
459
+ return [];
460
+ }
461
+
462
+ const items = (await this.resolveMergeTargetItems(this.target, this.verbose)) || [];
463
+ if (items.length > 0 || this.target?.mode !== 'issue') {
464
+ return items;
465
+ }
466
+
467
+ if (attempt === maxAttempts) {
468
+ return [];
469
+ }
470
+
471
+ this.log(`Waiting for a linked PR to appear for issue #${this.target.issueNumber}`);
472
+ await this.sleep(this.targetItemsPollIntervalMs);
473
+ }
474
+
475
+ return [];
476
+ }
477
+
478
+ async waitForPRReady(item, initialCheck) {
479
+ return waitForPRReadyHelper(this, item, initialCheck, {
480
+ MergeItemStatus,
481
+ conflictSkipReason: MERGE_CONFLICT_SKIP_REASON,
482
+ timeoutMs: MERGE_QUEUE_CONFIG.CI_TIMEOUT_MS,
483
+ pollIntervalMs: MERGE_QUEUE_CONFIG.CI_POLL_INTERVAL_MS,
484
+ });
485
+ }
486
+
422
487
  /**
423
488
  * Process a single merge queue item
424
489
  * @param {MergeQueueItem} item
@@ -430,7 +495,7 @@ export class MergeQueueProcessor {
430
495
  try {
431
496
  // Step 1: Check if PR is mergeable
432
497
  item.status = MergeItemStatus.CHECKING_CI;
433
- const mergeableCheck = await checkPRMergeable(this.owner, this.repo, item.pr.number, this.verbose);
498
+ const mergeableCheck = await this.checkPRMergeable(this.owner, this.repo, item.pr.number, this.verbose);
434
499
 
435
500
  if (mergeableCheck.terminal) {
436
501
  item.status = MergeItemStatus.FAILED;
@@ -441,15 +506,34 @@ export class MergeQueueProcessor {
441
506
  }
442
507
 
443
508
  if (!mergeableCheck.mergeable) {
444
- item.status = MergeItemStatus.SKIPPED;
445
- item.error = mergeableCheck.reason;
446
- this.stats.skipped++;
447
- this.log(`Skipped PR #${item.pr.number}: ${mergeableCheck.reason}`);
448
- return;
509
+ if (!this.waitForUnfinished || mergeableCheck.reason === MERGE_CONFLICT_SKIP_REASON) {
510
+ item.status = MergeItemStatus.SKIPPED;
511
+ item.error = mergeableCheck.reason;
512
+ this.stats.skipped++;
513
+ this.log(`Skipped PR #${item.pr.number}: ${mergeableCheck.reason}`);
514
+ return;
515
+ }
516
+
517
+ const waitForReadyResult = await this.waitForPRReady(item, mergeableCheck);
518
+ if (!waitForReadyResult.success) {
519
+ if (waitForReadyResult.status === 'cancelled' || waitForReadyResult.status === 'conflict') {
520
+ item.status = MergeItemStatus.SKIPPED;
521
+ item.error = waitForReadyResult.error;
522
+ this.stats.skipped++;
523
+ this.log(`Skipped PR #${item.pr.number}: ${waitForReadyResult.error}`);
524
+ return;
525
+ }
526
+
527
+ item.status = MergeItemStatus.FAILED;
528
+ item.error = waitForReadyResult.error;
529
+ this.stats.failed++;
530
+ this.log(`Failed PR #${item.pr.number}: ${waitForReadyResult.error}`);
531
+ return;
532
+ }
449
533
  }
450
534
 
451
535
  // Step 2: Check CI status
452
- const ciStatus = await checkPRCIStatus(this.owner, this.repo, item.pr.number, this.verbose);
536
+ const ciStatus = await this.checkPRCIStatus(this.owner, this.repo, item.pr.number, this.verbose);
453
537
  item.ciStatus = ciStatus;
454
538
 
455
539
  if (ciStatus.status === 'failure') {
@@ -472,7 +556,7 @@ export class MergeQueueProcessor {
472
556
  if (ciStatus.status === 'pending') {
473
557
  item.status = MergeItemStatus.WAITING_CI;
474
558
 
475
- const waitResult = await waitForCI(
559
+ const waitResult = await this.waitForCI(
476
560
  this.owner,
477
561
  this.repo,
478
562
  item.pr.number,
@@ -512,7 +596,7 @@ export class MergeQueueProcessor {
512
596
  // Step 4: Merge the PR
513
597
  // Issue #1269: Pass the configured merge method to prevent "not running interactively" error
514
598
  item.status = MergeItemStatus.MERGING;
515
- const mergeResult = await mergePullRequest(this.owner, this.repo, item.pr.number, { mergeMethod: MERGE_QUEUE_CONFIG.MERGE_METHOD }, this.verbose);
599
+ const mergeResult = await this.mergePullRequest(this.owner, this.repo, item.pr.number, { mergeMethod: MERGE_QUEUE_CONFIG.MERGE_METHOD }, this.verbose);
516
600
 
517
601
  if (!mergeResult.success) {
518
602
  item.status = MergeItemStatus.FAILED;
@@ -531,7 +615,7 @@ export class MergeQueueProcessor {
531
615
  // Issue #1895: GitHub does not auto-close linked issues for PRs merged into
532
616
  // a non-default branch. Close the linked issue explicitly in that case.
533
617
  try {
534
- const closeResult = await closeLinkedIssueIfNotAutoClosed(this.owner, this.repo, item.pr.number, this.verbose);
618
+ const closeResult = await this.closeLinkedIssueIfNotAutoClosed(this.owner, this.repo, item.pr.number, this.verbose);
535
619
  if (closeResult.closed) {
536
620
  this.log(`Closed linked issue #${closeResult.issueNumber} for PR #${item.pr.number} (merged into non-default branch)`);
537
621
  }
@@ -542,7 +626,7 @@ export class MergeQueueProcessor {
542
626
  // Issue #1341: Get the merge commit SHA for post-merge CI tracking
543
627
  // Need a small delay to allow GitHub to update the PR state
544
628
  await this.sleep(5000);
545
- const mergeCommitResult = await getMergeCommitSha(this.owner, this.repo, item.pr.number, this.verbose);
629
+ const mergeCommitResult = await this.getMergeCommitSha(this.owner, this.repo, item.pr.number, this.verbose);
546
630
  if (mergeCommitResult.sha) {
547
631
  item.mergeCommitSha = mergeCommitResult.sha;
548
632
  this.log(`PR #${item.pr.number} merge commit: ${mergeCommitResult.sha.substring(0, 7)}`);
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Wait helpers for Telegram merge queue.
4
+ *
5
+ * Split from telegram-merge-queue.lib.mjs to keep that file under the
6
+ * repository line limit.
7
+ *
8
+ * @see https://github.com/link-assistant/hive-mind/issues/2013
9
+ */
10
+
11
+ /**
12
+ * Wait for an unfinished PR to become mergeable before CI/merge processing.
13
+ *
14
+ * @param {Object} processor - MergeQueueProcessor-like object
15
+ * @param {Object} item - Merge queue item
16
+ * @param {{mergeable: boolean, terminal?: boolean, reason?: string}} initialCheck
17
+ * @param {Object} options
18
+ * @param {Object} options.MergeItemStatus
19
+ * @param {string} options.conflictSkipReason
20
+ * @param {number} options.timeoutMs
21
+ * @param {number} options.pollIntervalMs
22
+ * @returns {Promise<{success: boolean, status: string, error: string|null}>}
23
+ */
24
+ export async function waitForPRReady(processor, item, initialCheck, options) {
25
+ const { MergeItemStatus, conflictSkipReason, timeoutMs, pollIntervalMs } = options;
26
+ const startedAt = Date.now();
27
+ let latestCheck = initialCheck;
28
+
29
+ while (true) {
30
+ if (processor.isCancelled) {
31
+ return { success: false, status: 'cancelled', error: 'Cancelled' };
32
+ }
33
+
34
+ if (latestCheck?.terminal) {
35
+ return {
36
+ success: false,
37
+ status: 'terminal',
38
+ error: latestCheck.reason || 'GitHub repository, pull request, issue, or branch is no longer accessible',
39
+ };
40
+ }
41
+
42
+ if (latestCheck?.mergeable) {
43
+ item.status = MergeItemStatus.CHECKING_CI;
44
+ item.error = null;
45
+ return { success: true, status: 'ready', error: null };
46
+ }
47
+
48
+ if (latestCheck?.reason === conflictSkipReason) {
49
+ return { success: false, status: 'conflict', error: conflictSkipReason };
50
+ }
51
+
52
+ if (Date.now() - startedAt >= timeoutMs) {
53
+ const reason = latestCheck?.reason || 'PR did not become mergeable';
54
+ return {
55
+ success: false,
56
+ status: 'timeout',
57
+ error: `Timed out waiting for PR #${item.pr.number} to become mergeable: ${reason}`,
58
+ };
59
+ }
60
+
61
+ item.status = MergeItemStatus.WAITING_READY;
62
+ item.error = latestCheck?.reason || 'Waiting for PR to become mergeable';
63
+ processor.log(`Waiting for PR #${item.pr.number} to become mergeable: ${item.error}`);
64
+ if (processor.onProgress) {
65
+ await processor.onProgress(processor.getProgressUpdate());
66
+ }
67
+
68
+ await processor.sleep(pollIntervalMs);
69
+ latestCheck = await processor.checkPRMergeable(processor.owner, processor.repo, item.pr.number, processor.verbose);
70
+ }
71
+ }
72
+
73
+ export default {
74
+ waitForPRReady,
75
+ };