@link-assistant/hive-mind 1.23.8 → 1.23.9

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
+ ## 1.23.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 4456760: Fix merge queue to wait for target branch CI before merging (Issue #1307). The merge queue now checks for active CI runs on the target branch (main) before processing the first PR in the queue. This prevents cancelled workflows, incomplete releases, and failed post-merge checks when multiple PRs are merged in quick succession.
8
+
3
9
  ## 1.23.8
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.23.8",
3
+ "version": "1.23.9",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -409,6 +409,7 @@ export const version = {
409
409
  // Merge queue configurations
410
410
  // See: https://github.com/link-assistant/hive-mind/issues/1143
411
411
  // See: https://github.com/link-assistant/hive-mind/issues/1269
412
+ // See: https://github.com/link-assistant/hive-mind/issues/1307
412
413
  export const mergeQueue = {
413
414
  // Maximum PRs to process in one merge session
414
415
  // Default: 10 PRs per session
@@ -426,6 +427,18 @@ export const mergeQueue = {
426
427
  // Issue #1269: gh pr merge requires explicit method when running non-interactively
427
428
  // Default: 'merge' - creates a merge commit
428
429
  mergeMethod: getenv('HIVE_MIND_MERGE_QUEUE_MERGE_METHOD', 'merge'),
430
+ // Issue #1307: Wait for main branch CI to complete before processing merge queue
431
+ // When enabled, the merge queue will wait for any active CI runs on the target branch
432
+ // (usually main) to complete before merging the first PR.
433
+ // Default: true - ensures all post-merge CI workflows complete before next merge
434
+ waitForTargetBranchCI: getenv('HIVE_MIND_MERGE_QUEUE_WAIT_FOR_TARGET_CI', 'true').toLowerCase() === 'true',
435
+ // Issue #1307: Timeout for waiting on target branch CI (in milliseconds)
436
+ // If active runs don't complete within this time, proceed with merge anyway
437
+ // Default: 45 minutes (2700000ms)
438
+ targetBranchCITimeoutMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_TARGET_CI_TIMEOUT_MS', 45 * 60 * 1000),
439
+ // Issue #1307: Polling interval for checking target branch CI status (in milliseconds)
440
+ // Default: 30 seconds (30000ms) - more frequent than PR CI polling since we're blocking
441
+ targetBranchCIPollIntervalMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_TARGET_CI_POLL_INTERVAL_MS', 30 * 1000),
429
442
  };
430
443
 
431
444
  // Helper function to validate configuration values
@@ -636,6 +636,160 @@ export function parseRepositoryUrl(url) {
636
636
  };
637
637
  }
638
638
 
639
+ /**
640
+ * Get active workflow runs on a specific branch
641
+ * Issue #1307: Used to check if there are any in-progress or queued runs on the target branch
642
+ * @param {string} owner - Repository owner
643
+ * @param {string} repo - Repository name
644
+ * @param {string} branch - Branch name (default: main)
645
+ * @param {boolean} verbose - Whether to log verbose output
646
+ * @returns {Promise<{runs: Array<Object>, hasActiveRuns: boolean, count: number}>}
647
+ */
648
+ export async function getActiveBranchRuns(owner, repo, branch = 'main', verbose = false) {
649
+ try {
650
+ // Query for in_progress and queued runs on the specified branch
651
+ const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?branch=${branch}&per_page=10" --jq '[.workflow_runs[] | select(.status=="in_progress" or .status=="queued")] | map({id: .id, name: .name, status: .status, created_at: .created_at, html_url: .html_url})'`);
652
+
653
+ const runs = JSON.parse(stdout.trim() || '[]');
654
+
655
+ if (verbose) {
656
+ console.log(`[VERBOSE] /merge: Found ${runs.length} active runs on ${owner}/${repo} branch ${branch}`);
657
+ for (const run of runs) {
658
+ console.log(`[VERBOSE] /merge: - Run #${run.id}: ${run.name} (${run.status})`);
659
+ }
660
+ }
661
+
662
+ return {
663
+ runs,
664
+ hasActiveRuns: runs.length > 0,
665
+ count: runs.length,
666
+ };
667
+ } catch (error) {
668
+ if (verbose) {
669
+ console.log(`[VERBOSE] /merge: Error checking active runs on ${branch}: ${error.message}`);
670
+ }
671
+ return {
672
+ runs: [],
673
+ hasActiveRuns: false,
674
+ count: 0,
675
+ };
676
+ }
677
+ }
678
+
679
+ /**
680
+ * Wait for all active workflow runs on a branch to complete
681
+ * Issue #1307: Ensures all CI runs on target branch are complete before merging
682
+ * @param {string} owner - Repository owner
683
+ * @param {string} repo - Repository name
684
+ * @param {string} branch - Branch name (default: main)
685
+ * @param {Object} options - Wait options
686
+ * @param {number} options.timeout - Maximum wait time in ms (default: 45 minutes)
687
+ * @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
688
+ * @param {Function} options.onStatusUpdate - Callback for status updates
689
+ * @param {boolean} verbose - Whether to log verbose output
690
+ * @returns {Promise<{success: boolean, waitedForRuns: boolean, completedRuns: number, error: string|null}>}
691
+ */
692
+ export async function waitForBranchCI(owner, repo, branch = 'main', options = {}, verbose = false) {
693
+ const { timeout = 45 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null } = options;
694
+
695
+ const startTime = Date.now();
696
+ let totalWaitedRuns = 0;
697
+
698
+ if (verbose) {
699
+ console.log(`[VERBOSE] /merge: Checking for active CI runs on ${owner}/${repo} branch ${branch}...`);
700
+ }
701
+
702
+ while (Date.now() - startTime < timeout) {
703
+ let activeRuns;
704
+ try {
705
+ activeRuns = await getActiveBranchRuns(owner, repo, branch, verbose);
706
+ } catch (error) {
707
+ // Log and continue on errors
708
+ console.error(`[ERROR] /merge: Error checking branch CI: ${error.message}`);
709
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
710
+ continue;
711
+ }
712
+
713
+ if (onStatusUpdate) {
714
+ try {
715
+ await onStatusUpdate({
716
+ hasActiveRuns: activeRuns.hasActiveRuns,
717
+ count: activeRuns.count,
718
+ runs: activeRuns.runs,
719
+ elapsedMs: Date.now() - startTime,
720
+ });
721
+ } catch (callbackError) {
722
+ // Log callback errors but continue
723
+ console.error(`[ERROR] /merge: Status update callback failed: ${callbackError.message}`);
724
+ }
725
+ }
726
+
727
+ if (!activeRuns.hasActiveRuns) {
728
+ if (verbose) {
729
+ console.log(`[VERBOSE] /merge: No active CI runs on ${branch} branch. Ready to proceed.`);
730
+ }
731
+ return {
732
+ success: true,
733
+ waitedForRuns: totalWaitedRuns > 0,
734
+ completedRuns: totalWaitedRuns,
735
+ error: null,
736
+ };
737
+ }
738
+
739
+ totalWaitedRuns = Math.max(totalWaitedRuns, activeRuns.count);
740
+
741
+ if (verbose) {
742
+ const elapsedSec = Math.round((Date.now() - startTime) / 1000);
743
+ console.log(`[VERBOSE] /merge: Waiting for ${activeRuns.count} active runs on ${branch}... (${elapsedSec}s elapsed)`);
744
+ }
745
+
746
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
747
+ }
748
+
749
+ // Timeout reached
750
+ const finalCheck = await getActiveBranchRuns(owner, repo, branch, verbose);
751
+ if (finalCheck.hasActiveRuns) {
752
+ return {
753
+ success: false,
754
+ waitedForRuns: true,
755
+ completedRuns: totalWaitedRuns - finalCheck.count,
756
+ error: `Timeout waiting for ${finalCheck.count} CI runs on ${branch} branch`,
757
+ };
758
+ }
759
+
760
+ return {
761
+ success: true,
762
+ waitedForRuns: totalWaitedRuns > 0,
763
+ completedRuns: totalWaitedRuns,
764
+ error: null,
765
+ };
766
+ }
767
+
768
+ /**
769
+ * Get the default branch for a repository
770
+ * @param {string} owner - Repository owner
771
+ * @param {string} repo - Repository name
772
+ * @param {boolean} verbose - Whether to log verbose output
773
+ * @returns {Promise<string>} Default branch name (e.g., 'main' or 'master')
774
+ */
775
+ export async function getDefaultBranch(owner, repo, verbose = false) {
776
+ try {
777
+ const { stdout } = await exec(`gh api repos/${owner}/${repo} --jq '.default_branch'`);
778
+ const branch = stdout.trim();
779
+
780
+ if (verbose) {
781
+ console.log(`[VERBOSE] /merge: Default branch for ${owner}/${repo}: ${branch}`);
782
+ }
783
+
784
+ return branch || 'main';
785
+ } catch (error) {
786
+ if (verbose) {
787
+ console.log(`[VERBOSE] /merge: Error getting default branch, falling back to 'main': ${error.message}`);
788
+ }
789
+ return 'main';
790
+ }
791
+ }
792
+
639
793
  export default {
640
794
  READY_LABEL,
641
795
  checkReadyLabelExists,
@@ -651,4 +805,8 @@ export default {
651
805
  mergePullRequest,
652
806
  waitForCI,
653
807
  parseRepositoryUrl,
808
+ // Issue #1307: New exports for target branch CI waiting
809
+ getActiveBranchRuns,
810
+ waitForBranchCI,
811
+ getDefaultBranch,
654
812
  };
@@ -16,7 +16,7 @@
16
16
  * @see https://github.com/link-assistant/hive-mind/issues/1143
17
17
  */
18
18
 
19
- import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel } from './github-merge.lib.mjs';
19
+ import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel, waitForBranchCI, getDefaultBranch } from './github-merge.lib.mjs';
20
20
  import { mergeQueue as mergeQueueConfig } from './config.lib.mjs';
21
21
  import { getProgressBar } from './limits.lib.mjs';
22
22
 
@@ -74,6 +74,11 @@ export const MERGE_QUEUE_CONFIG = {
74
74
  // Merge method: 'merge', 'squash', or 'rebase' (Issue #1269)
75
75
  // gh pr merge requires explicit method when running non-interactively
76
76
  MERGE_METHOD: mergeQueueConfig.mergeMethod,
77
+
78
+ // Issue #1307: Wait for target branch CI before first merge
79
+ WAIT_FOR_TARGET_BRANCH_CI: mergeQueueConfig.waitForTargetBranchCI,
80
+ TARGET_BRANCH_CI_TIMEOUT_MS: mergeQueueConfig.targetBranchCITimeoutMs,
81
+ TARGET_BRANCH_CI_POLL_INTERVAL_MS: mergeQueueConfig.targetBranchCIPollIntervalMs,
77
82
  };
78
83
 
79
84
  /**
@@ -229,6 +234,12 @@ export class MergeQueueProcessor {
229
234
  this.isCancelled = false;
230
235
 
231
236
  try {
237
+ // Issue #1307: Wait for any active CI runs on the target branch before processing
238
+ // This prevents merging while post-merge CI from previous merges is still running
239
+ if (MERGE_QUEUE_CONFIG.WAIT_FOR_TARGET_BRANCH_CI) {
240
+ await this.waitForTargetBranchCI();
241
+ }
242
+
232
243
  // Process each PR sequentially
233
244
  for (this.currentIndex = 0; this.currentIndex < this.items.length; this.currentIndex++) {
234
245
  if (this.isCancelled) {
@@ -383,6 +394,54 @@ export class MergeQueueProcessor {
383
394
  this.log('Cancellation requested');
384
395
  }
385
396
 
397
+ /**
398
+ * Wait for any active CI runs on the target branch to complete
399
+ * Issue #1307: Prevents merging while post-merge CI from previous merges is still running
400
+ * @returns {Promise<void>}
401
+ */
402
+ async waitForTargetBranchCI() {
403
+ // Track if we're waiting for CI (for progress updates)
404
+ this.waitingForTargetBranchCI = true;
405
+ this.targetBranchCIStatus = null;
406
+
407
+ try {
408
+ // Get the default branch (usually 'main' or 'master')
409
+ const targetBranch = await getDefaultBranch(this.owner, this.repo, this.verbose);
410
+ this.log(`Checking for active CI runs on ${targetBranch} branch before processing queue...`);
411
+
412
+ const waitResult = await waitForBranchCI(
413
+ this.owner,
414
+ this.repo,
415
+ targetBranch,
416
+ {
417
+ timeout: MERGE_QUEUE_CONFIG.TARGET_BRANCH_CI_TIMEOUT_MS,
418
+ pollInterval: MERGE_QUEUE_CONFIG.TARGET_BRANCH_CI_POLL_INTERVAL_MS,
419
+ onStatusUpdate: async status => {
420
+ this.targetBranchCIStatus = status;
421
+
422
+ // Report progress while waiting
423
+ if (this.onProgress) {
424
+ await this.onProgress(this.getProgressUpdate());
425
+ }
426
+ },
427
+ },
428
+ this.verbose
429
+ );
430
+
431
+ if (!waitResult.success) {
432
+ // Log warning but don't fail - proceed with merge anyway
433
+ console.warn(`[WARN] /merge-queue: ${waitResult.error}. Proceeding with merge anyway.`);
434
+ } else if (waitResult.waitedForRuns) {
435
+ this.log(`Waited for ${waitResult.completedRuns} CI runs to complete on ${targetBranch} branch`);
436
+ } else {
437
+ this.log(`No active CI runs on ${targetBranch} branch. Ready to proceed.`);
438
+ }
439
+ } finally {
440
+ this.waitingForTargetBranchCI = false;
441
+ this.targetBranchCIStatus = null;
442
+ }
443
+ }
444
+
386
445
  /**
387
446
  * Get current progress update
388
447
  */
@@ -456,8 +515,18 @@ export class MergeQueueProcessor {
456
515
  message += `⏭️ Skipped: ${update.stats.skipped} `;
457
516
  message += `⏳ Pending: ${update.stats.total - update.progress.processed}\n\n`;
458
517
 
518
+ // Issue #1307: Show waiting status for target branch CI
519
+ if (this.waitingForTargetBranchCI && this.targetBranchCIStatus) {
520
+ const elapsedSec = Math.round(this.targetBranchCIStatus.elapsedMs / 1000);
521
+ const elapsedMin = Math.floor(elapsedSec / 60);
522
+ const elapsedSecRemainder = elapsedSec % 60;
523
+ message += `⏱️ Waiting for ${this.targetBranchCIStatus.count} CI run\\(s\\) on target branch to complete \\(${elapsedMin}m ${elapsedSecRemainder}s\\)\\.\\.\\.\\n\\n`;
524
+ } else if (this.waitingForTargetBranchCI) {
525
+ message += `⏱️ Checking for active CI runs on target branch\\.\\.\\.\n\n`;
526
+ }
527
+
459
528
  // Current item being processed
460
- if (update.current) {
529
+ if (update.current && !this.waitingForTargetBranchCI) {
461
530
  const statusEmoji = update.currentStatus === MergeItemStatus.WAITING_CI ? '⏱️' : '🔄';
462
531
  message += `${statusEmoji} ${update.current}\n\n`;
463
532
  }