@link-assistant/hive-mind 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 53e1686: Add experimental /merge command to hive-telegram-bot for sequential PR merging
8
+ - New `/merge <repository-url>` command to process merge queues
9
+ - Automatically checks/creates 'ready' label in repository
10
+ - Merges PRs with 'ready' label sequentially (oldest first)
11
+ - Waits for CI/CD completion between each merge
12
+ - Includes `/merge_cancel` and `/merge_status` helper commands
13
+ - Supports linking issues to PRs (uses minimum creation date for ordering)
14
+
15
+ ## 1.7.2
16
+
17
+ ### Patch Changes
18
+
19
+ - e6a656f: Use `screen -R` instead of `screen -S` and `screen -r` in all docs and code for better session management. The `-R` flag ensures we open existing screen if created, and new if not yet created, making it the most safe and universal option.
20
+
3
21
  ## 1.7.1
4
22
 
5
23
  ### Patch Changes
package/README.md CHANGED
@@ -209,7 +209,7 @@ See [docs/HELM.md](./docs/HELM.md) for detailed Helm configuration options.
209
209
  **Using Links Notation (recommended):**
210
210
 
211
211
  ```
212
- screen -S bot # Enter new screen for bot
212
+ screen -R bot # Enter new screen for bot
213
213
 
214
214
  hive-telegram-bot --configuration "
215
215
  TELEGRAM_BOT_TOKEN: '849...355:AAG...rgk_YZk...aPU'
@@ -238,7 +238,7 @@ See [docs/HELM.md](./docs/HELM.md) for detailed Helm configuration options.
238
238
  **Using individual command-line options:**
239
239
 
240
240
  ```
241
- screen -S bot # Enter new screen for bot
241
+ screen -R bot # Enter new screen for bot
242
242
 
243
243
  hive-telegram-bot --token 849...355:AAG...rgk_YZk...aPU --allowed-chats "(
244
244
  -1002975819706
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -168,6 +168,23 @@ export const version = {
168
168
  default: getenv('HIVE_MIND_VERSION_DEFAULT', '0.14.3'),
169
169
  };
170
170
 
171
+ // Merge queue configurations
172
+ // See: https://github.com/link-assistant/hive-mind/issues/1143
173
+ export const mergeQueue = {
174
+ // Maximum PRs to process in one merge session
175
+ // Default: 10 PRs per session
176
+ maxPrsPerSession: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_MAX_PRS', 10),
177
+ // CI/CD polling interval in milliseconds
178
+ // Default: 5 minutes (300000ms) - checks CI status every 5 minutes
179
+ ciPollIntervalMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_CI_POLL_INTERVAL_MS', 5 * 60 * 1000),
180
+ // CI/CD timeout in milliseconds
181
+ // Default: 7 hours (25200000ms) - maximum wait time for CI to complete
182
+ ciTimeoutMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_CI_TIMEOUT_MS', 7 * 60 * 60 * 1000),
183
+ // Wait time after merge before processing next PR
184
+ // Default: 1 minute (60000ms) - allows CI to stabilize
185
+ postMergeWaitMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_POST_MERGE_WAIT_MS', 60 * 1000),
186
+ };
187
+
171
188
  // Helper function to validate configuration values
172
189
  export function validateConfig() {
173
190
  // Ensure all numeric values are valid
@@ -213,6 +230,7 @@ export function getAllConfigurations() {
213
230
  externalUrls,
214
231
  modelConfig,
215
232
  version,
233
+ mergeQueue,
216
234
  };
217
235
  }
218
236
 
@@ -0,0 +1,560 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitHub Merge Queue Library
4
+ *
5
+ * Provides utilities for the /merge command including:
6
+ * - Label management (create/check 'ready' label)
7
+ * - Fetching PRs with 'ready' label
8
+ * - CI/CD status monitoring
9
+ * - Sequential merge execution
10
+ *
11
+ * @see https://github.com/link-assistant/hive-mind/issues/1143
12
+ */
13
+
14
+ import { promisify } from 'util';
15
+ import { exec as execCallback } from 'child_process';
16
+
17
+ const exec = promisify(execCallback);
18
+
19
+ // Import GitHub URL parser
20
+ import { parseGitHubUrl } from './github.lib.mjs';
21
+
22
+ // Default label configuration
23
+ export const READY_LABEL = {
24
+ name: 'ready',
25
+ description: 'Is ready to be merged',
26
+ color: '0E8A16', // Green color
27
+ };
28
+
29
+ /**
30
+ * Check if 'ready' label exists in repository
31
+ * @param {string} owner - Repository owner
32
+ * @param {string} repo - Repository name
33
+ * @param {boolean} verbose - Whether to log verbose output
34
+ * @returns {Promise<{exists: boolean, label: Object|null}>}
35
+ */
36
+ export async function checkReadyLabelExists(owner, repo, verbose = false) {
37
+ try {
38
+ const { stdout } = await exec(`gh api repos/${owner}/${repo}/labels/${READY_LABEL.name} 2>/dev/null || echo ""`);
39
+ if (stdout.trim()) {
40
+ const label = JSON.parse(stdout.trim());
41
+ if (verbose) {
42
+ console.log(`[VERBOSE] /merge: 'ready' label exists in ${owner}/${repo}`);
43
+ }
44
+ return { exists: true, label };
45
+ }
46
+ if (verbose) {
47
+ console.log(`[VERBOSE] /merge: 'ready' label does not exist in ${owner}/${repo}`);
48
+ }
49
+ return { exists: false, label: null };
50
+ } catch (error) {
51
+ if (verbose) {
52
+ console.log(`[VERBOSE] /merge: Error checking label: ${error.message}`);
53
+ }
54
+ return { exists: false, label: null };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Create 'ready' label in repository
60
+ * @param {string} owner - Repository owner
61
+ * @param {string} repo - Repository name
62
+ * @param {boolean} verbose - Whether to log verbose output
63
+ * @returns {Promise<{success: boolean, label: Object|null, error: string|null}>}
64
+ */
65
+ export async function createReadyLabel(owner, repo, verbose = false) {
66
+ try {
67
+ const labelData = JSON.stringify({
68
+ name: READY_LABEL.name,
69
+ description: READY_LABEL.description,
70
+ color: READY_LABEL.color,
71
+ });
72
+
73
+ const { stdout } = await exec(`gh api repos/${owner}/${repo}/labels -X POST -H "Accept: application/vnd.github+json" --input - <<< '${labelData}'`);
74
+ const label = JSON.parse(stdout.trim());
75
+
76
+ if (verbose) {
77
+ console.log(`[VERBOSE] /merge: Created 'ready' label in ${owner}/${repo}`);
78
+ }
79
+
80
+ return { success: true, label, error: null };
81
+ } catch (error) {
82
+ if (verbose) {
83
+ console.log(`[VERBOSE] /merge: Failed to create label: ${error.message}`);
84
+ }
85
+ return { success: false, label: null, error: error.message };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Check if we have admin/write permissions to manage labels
91
+ * @param {string} owner - Repository owner
92
+ * @param {string} repo - Repository name
93
+ * @param {boolean} verbose - Whether to log verbose output
94
+ * @returns {Promise<{canManageLabels: boolean, permission: string|null}>}
95
+ */
96
+ export async function checkLabelPermissions(owner, repo, verbose = false) {
97
+ try {
98
+ const { stdout } = await exec(`gh api repos/${owner}/${repo} --jq .permissions`);
99
+ const permissions = JSON.parse(stdout.trim());
100
+
101
+ const canManageLabels = permissions.admin === true || permissions.push === true || permissions.maintain === true;
102
+
103
+ if (verbose) {
104
+ console.log(`[VERBOSE] /merge: Repository permissions for ${owner}/${repo}: ${JSON.stringify(permissions)}`);
105
+ console.log(`[VERBOSE] /merge: Can manage labels: ${canManageLabels}`);
106
+ }
107
+
108
+ return {
109
+ canManageLabels,
110
+ permission: permissions.admin ? 'admin' : permissions.maintain ? 'maintain' : permissions.push ? 'push' : 'read',
111
+ };
112
+ } catch (error) {
113
+ if (verbose) {
114
+ console.log(`[VERBOSE] /merge: Error checking permissions: ${error.message}`);
115
+ }
116
+ return { canManageLabels: false, permission: null };
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Ensure 'ready' label exists, creating it if we have permissions
122
+ * @param {string} owner - Repository owner
123
+ * @param {string} repo - Repository name
124
+ * @param {boolean} verbose - Whether to log verbose output
125
+ * @returns {Promise<{success: boolean, created: boolean, error: string|null}>}
126
+ */
127
+ export async function ensureReadyLabel(owner, repo, verbose = false) {
128
+ // Check if label already exists
129
+ const { exists } = await checkReadyLabelExists(owner, repo, verbose);
130
+ if (exists) {
131
+ return { success: true, created: false, error: null };
132
+ }
133
+
134
+ // Check permissions before trying to create
135
+ const { canManageLabels } = await checkLabelPermissions(owner, repo, verbose);
136
+ if (!canManageLabels) {
137
+ return {
138
+ success: false,
139
+ created: false,
140
+ error: `No permission to create labels in ${owner}/${repo}. Please ask a repository admin to create the 'ready' label.`,
141
+ };
142
+ }
143
+
144
+ // Create the label
145
+ const createResult = await createReadyLabel(owner, repo, verbose);
146
+ if (createResult.success) {
147
+ return { success: true, created: true, error: null };
148
+ }
149
+
150
+ return { success: false, created: false, error: createResult.error };
151
+ }
152
+
153
+ /**
154
+ * Fetch all open PRs with 'ready' label
155
+ * @param {string} owner - Repository owner
156
+ * @param {string} repo - Repository name
157
+ * @param {boolean} verbose - Whether to log verbose output
158
+ * @returns {Promise<Array<Object>>} Array of PR objects sorted by creation date
159
+ */
160
+ export async function fetchReadyPullRequests(owner, repo, verbose = false) {
161
+ try {
162
+ const { stdout } = await exec(`gh pr list --repo ${owner}/${repo} --label "${READY_LABEL.name}" --state open --json number,title,url,createdAt,headRefName,author,mergeable,mergeStateStatus --limit 100`);
163
+
164
+ const prs = JSON.parse(stdout.trim() || '[]');
165
+
166
+ if (verbose) {
167
+ console.log(`[VERBOSE] /merge: Found ${prs.length} open PRs with 'ready' label in ${owner}/${repo}`);
168
+ }
169
+
170
+ // Sort by creation date (oldest first)
171
+ prs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
172
+
173
+ return prs;
174
+ } catch (error) {
175
+ if (verbose) {
176
+ console.log(`[VERBOSE] /merge: Error fetching PRs: ${error.message}`);
177
+ }
178
+ return [];
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Fetch all open issues with 'ready' label and find their linked PRs
184
+ * @param {string} owner - Repository owner
185
+ * @param {string} repo - Repository name
186
+ * @param {boolean} verbose - Whether to log verbose output
187
+ * @returns {Promise<Array<Object>>} Array of {issue, pr} objects sorted by creation date
188
+ */
189
+ export async function fetchReadyIssuesWithPRs(owner, repo, verbose = false) {
190
+ try {
191
+ // Fetch open issues with 'ready' label
192
+ const { stdout: issuesJson } = await exec(`gh issue list --repo ${owner}/${repo} --label "${READY_LABEL.name}" --state open --json number,title,url,createdAt --limit 100`);
193
+
194
+ const issues = JSON.parse(issuesJson.trim() || '[]');
195
+
196
+ if (verbose) {
197
+ console.log(`[VERBOSE] /merge: Found ${issues.length} open issues with 'ready' label in ${owner}/${repo}`);
198
+ }
199
+
200
+ // For each issue, find linked PRs using the closing keyword search
201
+ const result = [];
202
+ for (const issue of issues) {
203
+ try {
204
+ // Search for PRs that reference this issue with closing keywords
205
+ const { stdout: searchJson } = await exec(`gh pr list --repo ${owner}/${repo} --search "in:body closes #${issue.number} OR fixes #${issue.number} OR resolves #${issue.number}" --state open --json number,title,url,createdAt,headRefName,author,mergeable,mergeStateStatus --limit 5`);
206
+
207
+ const linkedPRs = JSON.parse(searchJson.trim() || '[]');
208
+
209
+ if (linkedPRs.length > 0) {
210
+ // Take the first linked PR (oldest if multiple)
211
+ linkedPRs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
212
+ result.push({
213
+ issue,
214
+ pr: linkedPRs[0],
215
+ // Use minimum of issue and PR creation dates for sorting
216
+ sortDate: new Date(Math.min(new Date(issue.createdAt), new Date(linkedPRs[0].createdAt))),
217
+ });
218
+
219
+ if (verbose) {
220
+ console.log(`[VERBOSE] /merge: Issue #${issue.number} linked to PR #${linkedPRs[0].number}`);
221
+ }
222
+ } else if (verbose) {
223
+ console.log(`[VERBOSE] /merge: Issue #${issue.number} has no linked open PR`);
224
+ }
225
+ } catch (err) {
226
+ if (verbose) {
227
+ console.log(`[VERBOSE] /merge: Error finding linked PR for issue #${issue.number}: ${err.message}`);
228
+ }
229
+ }
230
+ }
231
+
232
+ // Sort by the minimum creation date
233
+ result.sort((a, b) => a.sortDate - b.sortDate);
234
+
235
+ return result;
236
+ } catch (error) {
237
+ if (verbose) {
238
+ console.log(`[VERBOSE] /merge: Error fetching issues: ${error.message}`);
239
+ }
240
+ return [];
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Get combined list of ready PRs (from both direct PR labels and issue labels)
246
+ * @param {string} owner - Repository owner
247
+ * @param {string} repo - Repository name
248
+ * @param {boolean} verbose - Whether to log verbose output
249
+ * @returns {Promise<Array<Object>>} Array of PR objects with optional issue reference, sorted by creation date
250
+ */
251
+ export async function getAllReadyPRs(owner, repo, verbose = false) {
252
+ // Fetch both direct PRs and issue-linked PRs in parallel
253
+ const [directPRs, issueLinkedPRs] = await Promise.all([fetchReadyPullRequests(owner, repo, verbose), fetchReadyIssuesWithPRs(owner, repo, verbose)]);
254
+
255
+ // Build a map to deduplicate by PR number
256
+ const prMap = new Map();
257
+
258
+ // Add direct PRs
259
+ for (const pr of directPRs) {
260
+ prMap.set(pr.number, {
261
+ pr,
262
+ issue: null,
263
+ sortDate: new Date(pr.createdAt),
264
+ });
265
+ }
266
+
267
+ // Add issue-linked PRs (may override if PR is already in map)
268
+ for (const { issue, pr, sortDate } of issueLinkedPRs) {
269
+ const existing = prMap.get(pr.number);
270
+ if (existing) {
271
+ // If PR exists, use the minimum of both sort dates
272
+ existing.issue = issue;
273
+ existing.sortDate = new Date(Math.min(existing.sortDate, sortDate));
274
+ } else {
275
+ prMap.set(pr.number, { pr, issue, sortDate });
276
+ }
277
+ }
278
+
279
+ // Convert to array and sort by sortDate
280
+ const result = Array.from(prMap.values());
281
+ result.sort((a, b) => a.sortDate - b.sortDate);
282
+
283
+ if (verbose) {
284
+ console.log(`[VERBOSE] /merge: Total unique ready PRs: ${result.length}`);
285
+ for (const { pr, issue } of result) {
286
+ const issueInfo = issue ? ` (linked to issue #${issue.number})` : '';
287
+ console.log(`[VERBOSE] /merge: PR #${pr.number}: ${pr.title}${issueInfo}`);
288
+ }
289
+ }
290
+
291
+ return result;
292
+ }
293
+
294
+ /**
295
+ * Check CI/CD status for a PR
296
+ * @param {string} owner - Repository owner
297
+ * @param {string} repo - Repository name
298
+ * @param {number} prNumber - Pull request number
299
+ * @param {boolean} verbose - Whether to log verbose output
300
+ * @returns {Promise<{status: string, checks: Array<Object>, allPassed: boolean, hasPending: boolean}>}
301
+ */
302
+ export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
303
+ try {
304
+ // Get the PR's head SHA
305
+ const { stdout: prJson } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json headRefOid`);
306
+ const prData = JSON.parse(prJson.trim());
307
+ const sha = prData.headRefOid;
308
+
309
+ // Get check runs for this SHA
310
+ const { stdout: checksJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/check-runs --paginate --jq '.check_runs'`);
311
+ const checkRuns = JSON.parse(checksJson.trim() || '[]');
312
+
313
+ // Get commit statuses (some CI systems use status API instead of checks API)
314
+ const { stdout: statusJson } = await exec(`gh api repos/${owner}/${repo}/commits/${sha}/status --jq '.statuses'`);
315
+ const statuses = JSON.parse(statusJson.trim() || '[]');
316
+
317
+ // Combine both check runs and statuses
318
+ const allChecks = [
319
+ ...checkRuns.map(check => ({
320
+ name: check.name,
321
+ status: check.status,
322
+ conclusion: check.conclusion,
323
+ type: 'check_run',
324
+ })),
325
+ ...statuses.map(status => ({
326
+ name: status.context,
327
+ status: status.state === 'pending' ? 'in_progress' : 'completed',
328
+ conclusion: status.state === 'pending' ? null : status.state === 'success' ? 'success' : status.state === 'failure' ? 'failure' : status.state,
329
+ type: 'status',
330
+ })),
331
+ ];
332
+
333
+ const hasPending = allChecks.some(c => c.status !== 'completed' || c.conclusion === null);
334
+ const allPassed = !hasPending && allChecks.every(c => c.conclusion === 'success' || c.conclusion === 'skipped' || c.conclusion === 'neutral');
335
+ const hasFailed = allChecks.some(c => c.conclusion === 'failure' || c.conclusion === 'cancelled' || c.conclusion === 'timed_out');
336
+
337
+ let status;
338
+ if (hasPending) {
339
+ status = 'pending';
340
+ } else if (allPassed) {
341
+ status = 'success';
342
+ } else if (hasFailed) {
343
+ status = 'failure';
344
+ } else {
345
+ status = 'unknown';
346
+ }
347
+
348
+ if (verbose) {
349
+ console.log(`[VERBOSE] /merge: PR #${prNumber} CI status: ${status}`);
350
+ console.log(`[VERBOSE] /merge: Checks: ${allChecks.length}, Passed: ${allPassed}, Pending: ${hasPending}`);
351
+ for (const check of allChecks) {
352
+ console.log(`[VERBOSE] /merge: - ${check.name}: ${check.status}/${check.conclusion}`);
353
+ }
354
+ }
355
+
356
+ return {
357
+ status,
358
+ checks: allChecks,
359
+ allPassed,
360
+ hasPending,
361
+ };
362
+ } catch (error) {
363
+ if (verbose) {
364
+ console.log(`[VERBOSE] /merge: Error checking CI status: ${error.message}`);
365
+ }
366
+ return {
367
+ status: 'unknown',
368
+ checks: [],
369
+ allPassed: false,
370
+ hasPending: false,
371
+ };
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Check if PR is mergeable
377
+ * @param {string} owner - Repository owner
378
+ * @param {string} repo - Repository name
379
+ * @param {number} prNumber - Pull request number
380
+ * @param {boolean} verbose - Whether to log verbose output
381
+ * @returns {Promise<{mergeable: boolean, reason: string|null}>}
382
+ */
383
+ export async function checkPRMergeable(owner, repo, prNumber, verbose = false) {
384
+ try {
385
+ const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json mergeable,mergeStateStatus`);
386
+ const pr = JSON.parse(stdout.trim());
387
+
388
+ const mergeable = pr.mergeable === 'MERGEABLE';
389
+ let reason = null;
390
+
391
+ if (!mergeable) {
392
+ switch (pr.mergeStateStatus) {
393
+ case 'BLOCKED':
394
+ reason = 'PR is blocked (possibly by branch protection rules)';
395
+ break;
396
+ case 'BEHIND':
397
+ reason = 'PR branch is behind the base branch';
398
+ break;
399
+ case 'DIRTY':
400
+ reason = 'PR has merge conflicts';
401
+ break;
402
+ case 'UNSTABLE':
403
+ reason = 'PR has failing required status checks';
404
+ break;
405
+ case 'DRAFT':
406
+ reason = 'PR is a draft';
407
+ break;
408
+ default:
409
+ reason = `Merge state: ${pr.mergeStateStatus || 'unknown'}`;
410
+ }
411
+ }
412
+
413
+ if (verbose) {
414
+ console.log(`[VERBOSE] /merge: PR #${prNumber} mergeable: ${mergeable}, state: ${pr.mergeStateStatus}`);
415
+ }
416
+
417
+ return { mergeable, reason };
418
+ } catch (error) {
419
+ if (verbose) {
420
+ console.log(`[VERBOSE] /merge: Error checking mergeability: ${error.message}`);
421
+ }
422
+ return { mergeable: false, reason: error.message };
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Merge a pull request
428
+ * @param {string} owner - Repository owner
429
+ * @param {string} repo - Repository name
430
+ * @param {number} prNumber - Pull request number
431
+ * @param {Object} options - Merge options
432
+ * @param {boolean} options.squash - Whether to squash merge (default: false, uses default merge method)
433
+ * @param {boolean} options.deleteAfter - Whether to delete branch after merge (default: false)
434
+ * @param {boolean} verbose - Whether to log verbose output
435
+ * @returns {Promise<{success: boolean, error: string|null}>}
436
+ */
437
+ export async function mergePullRequest(owner, repo, prNumber, options = {}, verbose = false) {
438
+ const { squash = false, deleteAfter = false } = options;
439
+
440
+ try {
441
+ let mergeArgs = `--repo ${owner}/${repo}`;
442
+ if (squash) {
443
+ mergeArgs += ' --squash';
444
+ }
445
+ if (deleteAfter) {
446
+ mergeArgs += ' --delete-branch';
447
+ }
448
+
449
+ const { stdout } = await exec(`gh pr merge ${prNumber} ${mergeArgs}`);
450
+
451
+ if (verbose) {
452
+ console.log(`[VERBOSE] /merge: Successfully merged PR #${prNumber}`);
453
+ if (stdout) console.log(`[VERBOSE] /merge: stdout: ${stdout.trim()}`);
454
+ }
455
+
456
+ return { success: true, error: null };
457
+ } catch (error) {
458
+ if (verbose) {
459
+ console.log(`[VERBOSE] /merge: Failed to merge PR #${prNumber}: ${error.message}`);
460
+ }
461
+ return { success: false, error: error.message };
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Wait for CI/CD to complete with polling
467
+ * @param {string} owner - Repository owner
468
+ * @param {string} repo - Repository name
469
+ * @param {number} prNumber - Pull request number
470
+ * @param {Object} options - Wait options
471
+ * @param {number} options.timeout - Maximum wait time in ms (default: 30 minutes)
472
+ * @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
473
+ * @param {Function} options.onStatusUpdate - Callback for status updates
474
+ * @param {boolean} verbose - Whether to log verbose output
475
+ * @returns {Promise<{success: boolean, status: string, error: string|null}>}
476
+ */
477
+ export async function waitForCI(owner, repo, prNumber, options = {}, verbose = false) {
478
+ const { timeout = 30 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null } = options;
479
+
480
+ const startTime = Date.now();
481
+
482
+ while (Date.now() - startTime < timeout) {
483
+ const ciStatus = await checkPRCIStatus(owner, repo, prNumber, verbose);
484
+
485
+ if (onStatusUpdate) {
486
+ await onStatusUpdate(ciStatus);
487
+ }
488
+
489
+ if (ciStatus.status === 'success') {
490
+ return { success: true, status: 'success', error: null };
491
+ }
492
+
493
+ if (ciStatus.status === 'failure') {
494
+ return { success: false, status: 'failure', error: 'CI checks failed' };
495
+ }
496
+
497
+ if (ciStatus.status === 'pending') {
498
+ if (verbose) {
499
+ console.log(`[VERBOSE] /merge: Waiting for CI... (${Math.round((Date.now() - startTime) / 1000)}s elapsed)`);
500
+ }
501
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
502
+ continue;
503
+ }
504
+
505
+ // Unknown status - wait and retry
506
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
507
+ }
508
+
509
+ return { success: false, status: 'timeout', error: 'CI check timeout exceeded' };
510
+ }
511
+
512
+ /**
513
+ * Parse and validate a repository URL for the merge command
514
+ * @param {string} url - Repository URL
515
+ * @returns {{valid: boolean, owner: string|null, repo: string|null, error: string|null}}
516
+ */
517
+ export function parseRepositoryUrl(url) {
518
+ const parsed = parseGitHubUrl(url);
519
+
520
+ if (!parsed.valid) {
521
+ return { valid: false, owner: null, repo: null, error: parsed.error };
522
+ }
523
+
524
+ // Accept repo, issues_list, pulls_list, or organization URLs
525
+ if (parsed.type === 'repo' || parsed.type === 'issues_list' || parsed.type === 'pulls_list') {
526
+ return { valid: true, owner: parsed.owner, repo: parsed.repo, error: null };
527
+ }
528
+
529
+ if (parsed.type === 'user' || parsed.type === 'organization') {
530
+ return {
531
+ valid: false,
532
+ owner: parsed.owner,
533
+ repo: null,
534
+ error: 'URL points to a user/organization. Please provide a specific repository URL.',
535
+ };
536
+ }
537
+
538
+ return {
539
+ valid: false,
540
+ owner: parsed.owner,
541
+ repo: parsed.repo,
542
+ error: `URL type '${parsed.type}' is not supported for merge queue. Please provide a repository URL.`,
543
+ };
544
+ }
545
+
546
+ export default {
547
+ READY_LABEL,
548
+ checkReadyLabelExists,
549
+ createReadyLabel,
550
+ checkLabelPermissions,
551
+ ensureReadyLabel,
552
+ fetchReadyPullRequests,
553
+ fetchReadyIssuesWithPRs,
554
+ getAllReadyPRs,
555
+ checkPRCIStatus,
556
+ checkPRMergeable,
557
+ mergePullRequest,
558
+ waitForCI,
559
+ parseRepositoryUrl,
560
+ };
@@ -163,7 +163,7 @@ async function createOrEnterScreen(sessionName, command, args, autoTerminate = f
163
163
  // The \n at the end simulates pressing Enter
164
164
  await execAsync(`screen -S ${sessionName} -X stuff '${escapedCommand}\n'`);
165
165
  console.log(`Command sent to session '${sessionName}' successfully.`);
166
- console.log(`To attach and view the session, run: screen -r ${sessionName}`);
166
+ console.log(`To attach and view the session, run: screen -R ${sessionName}`);
167
167
  } catch (error) {
168
168
  console.error('Failed to send command to existing screen session:', error.message);
169
169
  console.error('You may need to terminate the old session and try again.');
@@ -208,7 +208,7 @@ async function createOrEnterScreen(sessionName, command, args, autoTerminate = f
208
208
  } else {
209
209
  console.log('Session will remain active after command completes');
210
210
  }
211
- console.log(`To attach to this session, run: screen -r ${sessionName}`);
211
+ console.log(`To attach to this session, run: screen -R ${sessionName}`);
212
212
  } catch (error) {
213
213
  console.error('Failed to create screen session:', error.message);
214
214
  process.exit(1);