@link-assistant/hive-mind 1.24.5 → 1.25.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,49 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.25.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cbac3dd: feat: wait for post-merge CI to complete before merging next PR (Issue #1341)
8
+
9
+ This change ensures that the /merge command waits for GitHub Actions to complete after each merge before processing the next PR in the queue.
10
+
11
+ **Problem:**
12
+ - Merge queue was merging PRs too quickly (70 seconds apart)
13
+ - Workflow runs were being cancelled (superseded by new commits)
14
+ - Only one version published instead of multiple
15
+
16
+ **Solution:**
17
+ 1. Check branch CI health before starting the queue
18
+ 2. Wait for post-merge CI after each successful merge
19
+ 3. Stop queue on CI failure (configurable)
20
+
21
+ **New configuration options:**
22
+ - `HIVE_MIND_MERGE_QUEUE_WAIT_FOR_POST_MERGE_CI` (default: true)
23
+ - `HIVE_MIND_MERGE_QUEUE_STOP_ON_CI_FAILURE` (default: true)
24
+ - `HIVE_MIND_MERGE_QUEUE_CHECK_BRANCH_HEALTH` (default: true)
25
+ - `HIVE_MIND_MERGE_QUEUE_POST_MERGE_CI_TIMEOUT_MS` (default: 60 minutes)
26
+ - `HIVE_MIND_MERGE_QUEUE_POST_MERGE_CI_POLL_INTERVAL_MS` (default: 30 seconds)
27
+
28
+ **New API functions:**
29
+ - `waitForCommitCI()` - Wait for workflow runs on a commit
30
+ - `checkBranchCIHealth()` - Check for failed CI on a branch
31
+ - `getMergeCommitSha()` - Get merge commit SHA for a PR
32
+
33
+ ## 1.24.6
34
+
35
+ ### Patch Changes
36
+
37
+ - Make `--auto-resume-on-limit-reset` enabled by default to improve user experience when hitting API rate limits. Previously defaulted to `false`, now defaults to `true` for both `solve` and `hive` commands. Users can explicitly disable with `--no-auto-resume-on-limit-reset` if needed.
38
+
39
+ Fix false positive error detection for step_finish with reason stop
40
+
41
+ When an agent encounters a timeout error during execution but successfully recovers and completes (indicated by `step_finish` with `reason: "stop"`), the error detection was incorrectly flagging this as a failure due to fallback pattern matching.
42
+
43
+ The `agentCompletedSuccessfully` flag was only being set for `session.idle` and `"exiting loop"` log messages (Issue #1276), but not for the more common `step_finish` event with `reason: "stop"`. This meant the fallback pattern matching would still trigger and detect error patterns in the full output, even when the agent had clearly completed successfully.
44
+
45
+ Fix: Add `step_finish` with `reason: "stop"` as a success marker in both stdout and stderr processing loops in `src/agent.lib.mjs`.
46
+
3
47
  ## 1.24.5
4
48
 
5
49
  ### Patch Changes
package/README.md CHANGED
@@ -268,12 +268,10 @@ See [docs/HELM.md](./docs/HELM.md) for detailed Helm configuration options.
268
268
  --attach-logs
269
269
  --verbose
270
270
  --no-tool-check
271
- --auto-resume-on-limit-reset
272
271
  TELEGRAM_SOLVE_OVERRIDES:
273
272
  --attach-logs
274
273
  --verbose
275
274
  --no-tool-check
276
- --auto-resume-on-limit-reset
277
275
  TELEGRAM_BOT_VERBOSE: true
278
276
  "
279
277
 
@@ -295,12 +293,10 @@ See [docs/HELM.md](./docs/HELM.md) for detailed Helm configuration options.
295
293
  --attach-logs
296
294
  --verbose
297
295
  --no-tool-check
298
- --auto-resume-on-limit-reset
299
296
  )" --solve-overrides "(
300
297
  --attach-logs
301
298
  --verbose
302
299
  --no-tool-check
303
- --auto-resume-on-limit-reset
304
300
  )" --verbose
305
301
 
306
302
  # Press CTRL + A + D for detach from screen
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.24.5",
3
+ "version": "1.25.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",
package/src/agent.lib.mjs CHANGED
@@ -665,6 +665,13 @@ export const executeAgentCommand = async params => {
665
665
  if (data.type === 'session.idle' || (data.type === 'log' && data.message === 'exiting loop')) {
666
666
  agentCompletedSuccessfully = true;
667
667
  }
668
+ // Issue #1296: Detect step_finish with reason "stop" as successful completion
669
+ // This is a clear marker of success - agent finished normally, not due to error or limit
670
+ // When this event appears, we should ignore any error events that appeared earlier in the stream
671
+ // (e.g., timeout errors that were recovered from via retry logic)
672
+ if (data.type === 'step_finish' && data.part?.reason === 'stop') {
673
+ agentCompletedSuccessfully = true;
674
+ }
668
675
  } catch {
669
676
  // Not JSON - log as plain text
670
677
  await log(line);
@@ -727,6 +734,11 @@ export const executeAgentCommand = async params => {
727
734
  if (stderrData.type === 'session.idle' || (stderrData.type === 'log' && stderrData.message === 'exiting loop')) {
728
735
  agentCompletedSuccessfully = true;
729
736
  }
737
+ // Issue #1296: Detect step_finish with reason "stop" as successful completion (stderr)
738
+ // This is a clear marker of success - agent finished normally, not due to error or limit
739
+ if (stderrData.type === 'step_finish' && stderrData.part?.reason === 'stop') {
740
+ agentCompletedSuccessfully = true;
741
+ }
730
742
  } catch {
731
743
  // Not JSON - log as plain text
732
744
  await log(stderrLine);
@@ -443,6 +443,31 @@ export const mergeQueue = {
443
443
  // Issue #1307: Polling interval for checking target branch CI status (in milliseconds)
444
444
  // Default: 30 seconds (30000ms) - more frequent than PR CI polling since we're blocking
445
445
  targetBranchCIPollIntervalMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_TARGET_CI_POLL_INTERVAL_MS', 30 * 1000),
446
+ // Issue #1341: Wait for post-merge CI to complete before merging next PR
447
+ // When enabled, the merge queue will wait for all CI runs triggered by a merge
448
+ // to complete before processing the next PR. This ensures each merge gets its own
449
+ // release/publish cycle.
450
+ // Default: true - ensures post-merge CI (including release workflows) completes
451
+ waitForPostMergeCI: getenv('HIVE_MIND_MERGE_QUEUE_WAIT_FOR_POST_MERGE_CI', 'true').toLowerCase() === 'true',
452
+ // Issue #1341: Stop the queue if post-merge CI fails
453
+ // When enabled, the merge queue will stop processing if any post-merge CI run fails
454
+ // This prevents cascading failures and allows humans to investigate
455
+ // Default: true - stop on failure to prevent problems from multiplying
456
+ stopOnPostMergeCIFailure: getenv('HIVE_MIND_MERGE_QUEUE_STOP_ON_CI_FAILURE', 'true').toLowerCase() === 'true',
457
+ // Issue #1341: Check for existing CI failures before starting the queue
458
+ // When enabled, the merge queue will check if there are any failed CI runs on
459
+ // the default branch before starting to process PRs. If failures exist, it will
460
+ // report them and stop.
461
+ // Default: true - ensure a healthy branch before merging
462
+ checkBranchCIHealthBeforeStart: getenv('HIVE_MIND_MERGE_QUEUE_CHECK_BRANCH_HEALTH', 'true').toLowerCase() === 'true',
463
+ // Issue #1341: Timeout for waiting on post-merge CI (in milliseconds)
464
+ // This is per-merge, not total. If a single merge's CI doesn't complete within
465
+ // this time, the queue will fail with a timeout error.
466
+ // Default: 60 minutes (3600000ms) - typical CI/CD pipelines take 15-45 minutes
467
+ postMergeCITimeoutMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_POST_MERGE_CI_TIMEOUT_MS', 60 * 60 * 1000),
468
+ // Issue #1341: Polling interval for post-merge CI status (in milliseconds)
469
+ // Default: 30 seconds (30000ms) - balance between responsiveness and API rate limits
470
+ postMergeCIPollIntervalMs: parseIntWithDefault('HIVE_MIND_MERGE_QUEUE_POST_MERGE_CI_POLL_INTERVAL_MS', 30 * 1000),
446
471
  };
447
472
 
448
473
  // Helper function to validate configuration values
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitHub Merge Queue CI Monitoring Library
4
+ *
5
+ * Provides utilities for monitoring CI/CD status on commits and branches.
6
+ * Split from github-merge.lib.mjs to maintain file size limits.
7
+ *
8
+ * @see https://github.com/link-assistant/hive-mind/issues/1341
9
+ */
10
+
11
+ import { getWorkflowRunsForSha } from './github-merge.lib.mjs';
12
+ import { promisify } from 'util';
13
+ import { exec as execCallback } from 'child_process';
14
+
15
+ const exec = promisify(execCallback);
16
+
17
+ /**
18
+ * Wait for all workflow runs triggered by a specific commit to complete
19
+ * Issue #1341: Used to wait for post-merge CI before processing the next PR
20
+ *
21
+ * @param {string} owner - Repository owner
22
+ * @param {string} repo - Repository name
23
+ * @param {string} sha - Commit SHA to monitor
24
+ * @param {Object} options - Wait options
25
+ * @param {number} options.timeout - Maximum wait time in ms (default: 60 minutes)
26
+ * @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
27
+ * @param {Function} options.onStatusUpdate - Callback for status updates
28
+ * @param {boolean} verbose - Whether to log verbose output
29
+ * @returns {Promise<{success: boolean, status: string, runs: Array, failedRuns: Array, error: string|null}>}
30
+ */
31
+ export async function waitForCommitCI(owner, repo, sha, options = {}, verbose = false) {
32
+ const { timeout = 60 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null } = options;
33
+
34
+ const startTime = Date.now();
35
+ let noRunsIterations = 0;
36
+ const MAX_NO_RUNS_ITERATIONS = 10; // Wait up to ~5 minutes for runs to appear
37
+
38
+ if (verbose) {
39
+ console.log(`[VERBOSE] /merge: Waiting for CI runs on commit ${sha.substring(0, 7)} to complete...`);
40
+ }
41
+
42
+ while (Date.now() - startTime < timeout) {
43
+ let runs;
44
+ try {
45
+ runs = await getWorkflowRunsForSha(owner, repo, sha, verbose);
46
+ } catch (error) {
47
+ console.error(`[ERROR] /merge: Error checking commit CI: ${error.message}`);
48
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
49
+ continue;
50
+ }
51
+
52
+ // Handle case where no runs exist yet (CI hasn't started)
53
+ if (runs.length === 0) {
54
+ noRunsIterations++;
55
+ if (noRunsIterations >= MAX_NO_RUNS_ITERATIONS) {
56
+ // No CI runs after waiting - assume no CI is configured or it's optional
57
+ if (verbose) {
58
+ console.log(`[VERBOSE] /merge: No CI runs found for commit ${sha.substring(0, 7)} after ${MAX_NO_RUNS_ITERATIONS} checks. Proceeding.`);
59
+ }
60
+ return { success: true, status: 'no_runs', runs: [], failedRuns: [], error: null };
61
+ }
62
+ if (verbose) {
63
+ console.log(`[VERBOSE] /merge: No CI runs yet for commit ${sha.substring(0, 7)} (attempt ${noRunsIterations}/${MAX_NO_RUNS_ITERATIONS}). Waiting...`);
64
+ }
65
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
66
+ continue;
67
+ }
68
+
69
+ // Reset counter when runs appear
70
+ noRunsIterations = 0;
71
+
72
+ // Check run statuses
73
+ const completedRuns = runs.filter(r => r.status === 'completed');
74
+ const inProgressRuns = runs.filter(r => r.status === 'in_progress' || r.status === 'queued' || r.status === 'waiting' || r.status === 'requested' || r.status === 'pending');
75
+ const failedRuns = completedRuns.filter(r => r.conclusion === 'failure' || r.conclusion === 'timed_out' || r.conclusion === 'cancelled');
76
+ const successRuns = completedRuns.filter(r => r.conclusion === 'success' || r.conclusion === 'skipped' || r.conclusion === 'neutral');
77
+
78
+ // Report status
79
+ if (onStatusUpdate) {
80
+ try {
81
+ await onStatusUpdate({
82
+ sha,
83
+ totalRuns: runs.length,
84
+ completedRuns: completedRuns.length,
85
+ inProgressRuns: inProgressRuns.length,
86
+ failedRuns: failedRuns.length,
87
+ successRuns: successRuns.length,
88
+ runs,
89
+ elapsedMs: Date.now() - startTime,
90
+ });
91
+ } catch (callbackError) {
92
+ console.error(`[ERROR] /merge: Status update callback failed: ${callbackError.message}`);
93
+ }
94
+ }
95
+
96
+ // All runs completed
97
+ if (inProgressRuns.length === 0) {
98
+ // Check for failures
99
+ if (failedRuns.length > 0) {
100
+ if (verbose) {
101
+ console.log(`[VERBOSE] /merge: CI completed with ${failedRuns.length} failure(s) for commit ${sha.substring(0, 7)}`);
102
+ for (const run of failedRuns) {
103
+ console.log(`[VERBOSE] /merge: - FAILED: ${run.name} (${run.conclusion}): ${run.html_url}`);
104
+ }
105
+ }
106
+ return {
107
+ success: false,
108
+ status: 'failure',
109
+ runs,
110
+ failedRuns,
111
+ error: `${failedRuns.length} CI run(s) failed: ${failedRuns.map(r => r.name).join(', ')}`,
112
+ };
113
+ }
114
+
115
+ // All passed
116
+ if (verbose) {
117
+ console.log(`[VERBOSE] /merge: All ${completedRuns.length} CI runs passed for commit ${sha.substring(0, 7)}`);
118
+ }
119
+ return { success: true, status: 'success', runs, failedRuns: [], error: null };
120
+ }
121
+
122
+ // Still waiting
123
+ if (verbose) {
124
+ const elapsedSec = Math.round((Date.now() - startTime) / 1000);
125
+ console.log(`[VERBOSE] /merge: Waiting for ${inProgressRuns.length} CI run(s) to complete... (${elapsedSec}s elapsed)`);
126
+ }
127
+
128
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
129
+ }
130
+
131
+ // Timeout reached
132
+ return {
133
+ success: false,
134
+ status: 'timeout',
135
+ runs: await getWorkflowRunsForSha(owner, repo, sha, verbose),
136
+ failedRuns: [],
137
+ error: `Timeout waiting for CI runs on commit ${sha.substring(0, 7)}`,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Check if the default branch has any recent failed CI runs
143
+ * Issue #1341: Used to detect pre-existing failures before starting the merge queue
144
+ *
145
+ * @param {string} owner - Repository owner
146
+ * @param {string} repo - Repository name
147
+ * @param {string} branch - Branch name (usually 'main' or 'master')
148
+ * @param {Object} options - Check options
149
+ * @param {number} options.lookbackCount - Number of recent runs to check (default: 5)
150
+ * @param {boolean} verbose - Whether to log verbose output
151
+ * @returns {Promise<{healthy: boolean, failedRuns: Array, error: string|null}>}
152
+ */
153
+ export async function checkBranchCIHealth(owner, repo, branch = 'main', options = {}, verbose = false) {
154
+ const { lookbackCount = 5 } = options;
155
+
156
+ try {
157
+ // Get recent completed workflow runs on the branch
158
+ const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?branch=${branch}&status=completed&per_page=${lookbackCount}" --jq '[.workflow_runs[] | {id: .id, name: .name, status: .status, conclusion: .conclusion, html_url: .html_url, head_sha: .head_sha, created_at: .created_at}]'`);
159
+ const runs = JSON.parse(stdout.trim() || '[]');
160
+
161
+ if (verbose) {
162
+ console.log(`[VERBOSE] /merge: Checking ${runs.length} recent CI runs on ${owner}/${repo} branch ${branch}`);
163
+ }
164
+
165
+ if (runs.length === 0) {
166
+ // No recent runs - assume healthy
167
+ return { healthy: true, failedRuns: [], error: null };
168
+ }
169
+
170
+ // Check for failures in the most recent run(s)
171
+ const latestSha = runs[0].head_sha;
172
+ const latestRuns = runs.filter(r => r.head_sha === latestSha);
173
+ const failedRuns = latestRuns.filter(r => r.conclusion === 'failure' || r.conclusion === 'timed_out');
174
+
175
+ if (failedRuns.length > 0) {
176
+ if (verbose) {
177
+ console.log(`[VERBOSE] /merge: Found ${failedRuns.length} failed CI run(s) on ${branch}:`);
178
+ for (const run of failedRuns) {
179
+ console.log(`[VERBOSE] /merge: - ${run.name}: ${run.conclusion} (${run.html_url})`);
180
+ }
181
+ }
182
+ return {
183
+ healthy: false,
184
+ failedRuns,
185
+ error: `${failedRuns.length} CI run(s) failed on ${branch}: ${failedRuns.map(r => r.name).join(', ')}`,
186
+ };
187
+ }
188
+
189
+ if (verbose) {
190
+ console.log(`[VERBOSE] /merge: Branch ${branch} CI is healthy (${latestRuns.length} runs checked)`);
191
+ }
192
+
193
+ return { healthy: true, failedRuns: [], error: null };
194
+ } catch (error) {
195
+ if (verbose) {
196
+ console.log(`[VERBOSE] /merge: Error checking branch CI health: ${error.message}`);
197
+ }
198
+ // On error, assume healthy to avoid blocking merges due to API issues
199
+ return { healthy: true, failedRuns: [], error: null };
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Get the merge commit SHA for a merged pull request
205
+ * Issue #1341: Used to track the commit that triggers post-merge CI
206
+ *
207
+ * @param {string} owner - Repository owner
208
+ * @param {string} repo - Repository name
209
+ * @param {number} prNumber - Pull request number
210
+ * @param {boolean} verbose - Whether to log verbose output
211
+ * @returns {Promise<{sha: string|null, error: string|null}>}
212
+ */
213
+ export async function getMergeCommitSha(owner, repo, prNumber, verbose = false) {
214
+ try {
215
+ const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json mergeCommit --jq '.mergeCommit.oid'`);
216
+ const sha = stdout.trim();
217
+
218
+ if (!sha || sha === 'null') {
219
+ if (verbose) {
220
+ console.log(`[VERBOSE] /merge: PR #${prNumber} has no merge commit (may not be merged yet)`);
221
+ }
222
+ return { sha: null, error: 'PR is not merged or merge commit not available' };
223
+ }
224
+
225
+ if (verbose) {
226
+ console.log(`[VERBOSE] /merge: PR #${prNumber} merge commit: ${sha.substring(0, 7)}`);
227
+ }
228
+
229
+ return { sha, error: null };
230
+ } catch (error) {
231
+ if (verbose) {
232
+ console.log(`[VERBOSE] /merge: Error getting merge commit for PR #${prNumber}: ${error.message}`);
233
+ }
234
+ return { sha: null, error: error.message };
235
+ }
236
+ }
237
+
238
+ export default {
239
+ waitForCommitCI,
240
+ checkBranchCIHealth,
241
+ getMergeCommitSha,
242
+ };
@@ -1204,11 +1204,11 @@ export async function getDetailedCIStatus(owner, repo, prNumber, verbose = false
1204
1204
  * @param {string} repo - Repository name
1205
1205
  * @param {string} sha - Commit SHA
1206
1206
  * @param {boolean} verbose - Whether to log verbose output
1207
- * @returns {Promise<Array<{id: number, status: string, conclusion: string|null, name: string}>>}
1207
+ * @returns {Promise<Array<{id: number, status: string, conclusion: string|null, name: string, html_url: string}>>}
1208
1208
  */
1209
1209
  export async function getWorkflowRunsForSha(owner, repo, sha, verbose = false) {
1210
1210
  try {
1211
- const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=20" --jq '[.workflow_runs[] | {id: .id, status: .status, conclusion: .conclusion, name: .name}]'`);
1211
+ const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?head_sha=${sha}&per_page=20" --jq '[.workflow_runs[] | {id: .id, status: .status, conclusion: .conclusion, name: .name, html_url: .html_url}]'`);
1212
1212
  const runs = JSON.parse(stdout.trim() || '[]');
1213
1213
 
1214
1214
  if (verbose) {
@@ -1227,6 +1227,11 @@ export async function getWorkflowRunsForSha(owner, repo, sha, verbose = false) {
1227
1227
  }
1228
1228
  }
1229
1229
 
1230
+ // Issue #1341: Import and re-export post-merge CI functions from separate module
1231
+ // to keep this file under the 1500 line limit
1232
+ import { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha } from './github-merge-ci.lib.mjs';
1233
+ export { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha };
1234
+
1230
1235
  export default {
1231
1236
  READY_LABEL,
1232
1237
  checkReadyLabelExists,
@@ -1256,4 +1261,8 @@ export default {
1256
1261
  rerunWorkflowRun,
1257
1262
  rerunFailedJobs,
1258
1263
  getWorkflowRunsForSha,
1264
+ // Issue #1341: Post-merge CI waiting and branch health checking
1265
+ waitForCommitCI,
1266
+ checkBranchCIHealth,
1267
+ getMergeCommitSha,
1259
1268
  };
@@ -36,7 +36,7 @@ const HIVE_CUSTOM_SOLVE_OPTIONS = {
36
36
  'auto-resume-on-limit-reset': {
37
37
  type: 'boolean',
38
38
  description: 'Automatically resume when AI tool limit resets (calculates reset time and waits). Passed to solve command.',
39
- default: false,
39
+ default: true,
40
40
  },
41
41
  'auto-cleanup': {
42
42
  type: 'boolean',
@@ -132,7 +132,7 @@ export const SOLVE_OPTION_DEFINITIONS = {
132
132
  'auto-resume-on-limit-reset': {
133
133
  type: 'boolean',
134
134
  description: 'Automatically resume when AI tool limit resets (maintains session context with --resume flag)',
135
- default: false,
135
+ default: true,
136
136
  },
137
137
  'auto-restart-on-limit-reset': {
138
138
  type: 'boolean',
@@ -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, waitForBranchCI, getDefaultBranch } from './github-merge.lib.mjs';
19
+ import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel, waitForBranchCI, getDefaultBranch, waitForCommitCI, checkBranchCIHealth, getMergeCommitSha } 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
 
@@ -79,6 +79,13 @@ export const MERGE_QUEUE_CONFIG = {
79
79
  WAIT_FOR_TARGET_BRANCH_CI: mergeQueueConfig.waitForTargetBranchCI,
80
80
  TARGET_BRANCH_CI_TIMEOUT_MS: mergeQueueConfig.targetBranchCITimeoutMs,
81
81
  TARGET_BRANCH_CI_POLL_INTERVAL_MS: mergeQueueConfig.targetBranchCIPollIntervalMs,
82
+
83
+ // Issue #1341: Wait for post-merge CI to complete before merging next PR
84
+ WAIT_FOR_POST_MERGE_CI: mergeQueueConfig.waitForPostMergeCI,
85
+ STOP_ON_POST_MERGE_CI_FAILURE: mergeQueueConfig.stopOnPostMergeCIFailure,
86
+ CHECK_BRANCH_CI_HEALTH_BEFORE_START: mergeQueueConfig.checkBranchCIHealthBeforeStart,
87
+ POST_MERGE_CI_TIMEOUT_MS: mergeQueueConfig.postMergeCITimeoutMs,
88
+ POST_MERGE_CI_POLL_INTERVAL_MS: mergeQueueConfig.postMergeCIPollIntervalMs,
82
89
  };
83
90
 
84
91
  /**
@@ -94,6 +101,8 @@ class MergeQueueItem {
94
101
  this.ciStatus = null;
95
102
  this.startedAt = null;
96
103
  this.completedAt = null;
104
+ // Issue #1341: Track merge commit SHA for post-merge CI waiting
105
+ this.mergeCommitSha = null;
97
106
  }
98
107
 
99
108
  /**
@@ -234,6 +243,29 @@ export class MergeQueueProcessor {
234
243
  this.isCancelled = false;
235
244
 
236
245
  try {
246
+ // Issue #1341: Check if the default branch has any failed CI runs before starting
247
+ // This prevents merging on top of a broken branch
248
+ if (MERGE_QUEUE_CONFIG.CHECK_BRANCH_CI_HEALTH_BEFORE_START) {
249
+ const healthCheckResult = await this.checkBranchCIHealthBeforeStart();
250
+ if (!healthCheckResult.healthy) {
251
+ this.status = MergeStatus.FAILED;
252
+ this.error = healthCheckResult.error;
253
+ this.completedAt = new Date();
254
+ // Store the failed runs for the error report
255
+ this.branchCIFailedRuns = healthCheckResult.failedRuns;
256
+
257
+ if (this.onError) {
258
+ await this.onError(new Error(healthCheckResult.error));
259
+ }
260
+
261
+ return {
262
+ success: false,
263
+ stats: this.stats,
264
+ error: healthCheckResult.error,
265
+ };
266
+ }
267
+ }
268
+
237
269
  // Issue #1307: Wait for any active CI runs on the target branch before processing
238
270
  // This prevents merging while post-merge CI from previous merges is still running
239
271
  if (MERGE_QUEUE_CONFIG.WAIT_FOR_TARGET_BRANCH_CI) {
@@ -255,10 +287,35 @@ export class MergeQueueProcessor {
255
287
  await this.onProgress(this.getProgressUpdate());
256
288
  }
257
289
 
258
- // If merged, wait before processing next PR to allow CI to stabilize
290
+ // Issue #1341: If merged successfully, wait for post-merge CI to complete
291
+ // This ensures each PR's CI completes (including releases) before merging the next
259
292
  if (item.status === MergeItemStatus.MERGED && this.currentIndex < this.items.length - 1) {
260
- this.log(`Waiting ${MERGE_QUEUE_CONFIG.POST_MERGE_WAIT_MS / 1000}s before next PR...`);
261
- await this.sleep(MERGE_QUEUE_CONFIG.POST_MERGE_WAIT_MS);
293
+ if (MERGE_QUEUE_CONFIG.WAIT_FOR_POST_MERGE_CI && item.mergeCommitSha) {
294
+ const postMergeCIResult = await this.waitForPostMergeCI(item);
295
+
296
+ // Issue #1341: Stop the queue if post-merge CI failed
297
+ if (!postMergeCIResult.success && MERGE_QUEUE_CONFIG.STOP_ON_POST_MERGE_CI_FAILURE) {
298
+ this.status = MergeStatus.FAILED;
299
+ this.error = postMergeCIResult.error;
300
+ this.completedAt = new Date();
301
+ // Store the failed runs for the error report
302
+ this.postMergeCIFailedRuns = postMergeCIResult.failedRuns;
303
+
304
+ if (this.onError) {
305
+ await this.onError(new Error(postMergeCIResult.error));
306
+ }
307
+
308
+ return {
309
+ success: false,
310
+ stats: this.stats,
311
+ error: postMergeCIResult.error,
312
+ };
313
+ }
314
+ } else {
315
+ // Fallback: short wait before processing next PR
316
+ this.log(`Waiting ${MERGE_QUEUE_CONFIG.POST_MERGE_WAIT_MS / 1000}s before next PR...`);
317
+ await this.sleep(MERGE_QUEUE_CONFIG.POST_MERGE_WAIT_MS);
318
+ }
262
319
  }
263
320
  }
264
321
 
@@ -372,6 +429,17 @@ export class MergeQueueProcessor {
372
429
  item.completedAt = new Date();
373
430
  this.stats.merged++;
374
431
  this.log(`Successfully merged PR #${item.pr.number}`);
432
+
433
+ // Issue #1341: Get the merge commit SHA for post-merge CI tracking
434
+ // Need a small delay to allow GitHub to update the PR state
435
+ await this.sleep(5000);
436
+ const mergeCommitResult = await getMergeCommitSha(this.owner, this.repo, item.pr.number, this.verbose);
437
+ if (mergeCommitResult.sha) {
438
+ item.mergeCommitSha = mergeCommitResult.sha;
439
+ this.log(`PR #${item.pr.number} merge commit: ${mergeCommitResult.sha.substring(0, 7)}`);
440
+ } else {
441
+ this.log(`Could not get merge commit SHA for PR #${item.pr.number}: ${mergeCommitResult.error}`);
442
+ }
375
443
  } catch (error) {
376
444
  item.status = MergeItemStatus.FAILED;
377
445
  item.error = error.message;
@@ -442,6 +510,105 @@ export class MergeQueueProcessor {
442
510
  }
443
511
  }
444
512
 
513
+ /**
514
+ * Check if the default branch has any failed CI runs before starting the queue
515
+ * Issue #1341: Prevents merging on top of a broken branch
516
+ * @returns {Promise<{healthy: boolean, failedRuns: Array, error: string|null}>}
517
+ */
518
+ async checkBranchCIHealthBeforeStart() {
519
+ try {
520
+ const targetBranch = await getDefaultBranch(this.owner, this.repo, this.verbose);
521
+ this.log(`Checking CI health on ${targetBranch} branch before starting queue...`);
522
+
523
+ const healthResult = await checkBranchCIHealth(this.owner, this.repo, targetBranch, {}, this.verbose);
524
+
525
+ if (!healthResult.healthy) {
526
+ this.log(`Branch ${targetBranch} has ${healthResult.failedRuns.length} failed CI run(s)`);
527
+ return {
528
+ healthy: false,
529
+ failedRuns: healthResult.failedRuns,
530
+ error: `Cannot start merge queue: ${healthResult.error}. Please fix the CI failures first.`,
531
+ };
532
+ }
533
+
534
+ this.log(`Branch ${targetBranch} CI is healthy. Ready to proceed.`);
535
+ return {
536
+ healthy: true,
537
+ failedRuns: [],
538
+ error: null,
539
+ };
540
+ } catch (error) {
541
+ // On error, assume healthy to avoid blocking merges due to API issues
542
+ console.warn(`[WARN] /merge-queue: Error checking branch CI health: ${error.message}. Proceeding anyway.`);
543
+ return {
544
+ healthy: true,
545
+ failedRuns: [],
546
+ error: null,
547
+ };
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Wait for post-merge CI to complete for a merged PR
553
+ * Issue #1341: Ensures each PR's CI completes (including releases) before merging the next
554
+ * @param {MergeQueueItem} item - The merged PR item
555
+ * @returns {Promise<{success: boolean, failedRuns: Array, error: string|null}>}
556
+ */
557
+ async waitForPostMergeCI(item) {
558
+ if (!item.mergeCommitSha) {
559
+ this.log(`No merge commit SHA available for PR #${item.pr.number}, skipping post-merge CI wait`);
560
+ return { success: true, failedRuns: [], error: null };
561
+ }
562
+
563
+ // Track that we're waiting for post-merge CI (for progress updates)
564
+ this.waitingForPostMergeCI = true;
565
+ this.postMergeCIStatus = null;
566
+ this.currentPostMergePR = item.pr.number;
567
+
568
+ try {
569
+ this.log(`Waiting for post-merge CI on commit ${item.mergeCommitSha.substring(0, 7)} (PR #${item.pr.number})...`);
570
+
571
+ const waitResult = await waitForCommitCI(
572
+ this.owner,
573
+ this.repo,
574
+ item.mergeCommitSha,
575
+ {
576
+ timeout: MERGE_QUEUE_CONFIG.POST_MERGE_CI_TIMEOUT_MS,
577
+ pollInterval: MERGE_QUEUE_CONFIG.POST_MERGE_CI_POLL_INTERVAL_MS,
578
+ onStatusUpdate: async status => {
579
+ this.postMergeCIStatus = status;
580
+
581
+ // Report progress while waiting
582
+ if (this.onProgress) {
583
+ await this.onProgress(this.getProgressUpdate());
584
+ }
585
+ },
586
+ },
587
+ this.verbose
588
+ );
589
+
590
+ if (waitResult.success) {
591
+ if (waitResult.status === 'no_runs') {
592
+ this.log(`No CI runs found for merge commit ${item.mergeCommitSha.substring(0, 7)}. Proceeding.`);
593
+ } else {
594
+ this.log(`Post-merge CI completed successfully for PR #${item.pr.number}`);
595
+ }
596
+ return { success: true, failedRuns: [], error: null };
597
+ } else {
598
+ this.log(`Post-merge CI failed for PR #${item.pr.number}: ${waitResult.error}`);
599
+ return {
600
+ success: false,
601
+ failedRuns: waitResult.failedRuns || [],
602
+ error: waitResult.error,
603
+ };
604
+ }
605
+ } finally {
606
+ this.waitingForPostMergeCI = false;
607
+ this.postMergeCIStatus = null;
608
+ this.currentPostMergePR = null;
609
+ }
610
+ }
611
+
445
612
  /**
446
613
  * Get current progress update
447
614
  */
@@ -525,8 +692,21 @@ export class MergeQueueProcessor {
525
692
  message += `⏱️ Checking for active CI runs on target branch\\.\\.\\.\n\n`;
526
693
  }
527
694
 
695
+ // Issue #1341: Show waiting status for post-merge CI
696
+ if (this.waitingForPostMergeCI && this.postMergeCIStatus) {
697
+ const elapsedSec = Math.round(this.postMergeCIStatus.elapsedMs / 1000);
698
+ const elapsedMin = Math.floor(elapsedSec / 60);
699
+ const elapsedSecRemainder = elapsedSec % 60;
700
+ const completed = this.postMergeCIStatus.completedRuns || 0;
701
+ const total = this.postMergeCIStatus.totalRuns || 0;
702
+ const inProgress = total - completed;
703
+ message += `⏱️ Waiting for post\\-merge CI \\(PR \\#${this.currentPostMergePR}\\): ${inProgress} in progress, ${completed}/${total} completed \\(${elapsedMin}m ${elapsedSecRemainder}s\\)\\.\\.\\.\n\n`;
704
+ } else if (this.waitingForPostMergeCI) {
705
+ message += `⏱️ Waiting for post\\-merge CI \\(PR \\#${this.currentPostMergePR}\\)\\.\\.\\.\n\n`;
706
+ }
707
+
528
708
  // Current item being processed
529
- if (update.current && !this.waitingForTargetBranchCI) {
709
+ if (update.current && !this.waitingForTargetBranchCI && !this.waitingForPostMergeCI) {
530
710
  const statusEmoji = update.currentStatus === MergeItemStatus.WAITING_CI ? '⏱️' : '🔄';
531
711
  // Issue #1339: escape the current item description for MarkdownV2
532
712
  message += `${statusEmoji} ${this.escapeMarkdown(update.current)}\n\n`;
@@ -609,6 +789,36 @@ export class MergeQueueProcessor {
609
789
  message += `⏭️ Skipped: ${report.stats.skipped} `;
610
790
  message += `📋 Total: ${report.stats.total}\n\n`;
611
791
 
792
+ // Issue #1341: Show branch CI health failure details if applicable
793
+ if (this.branchCIFailedRuns && this.branchCIFailedRuns.length > 0) {
794
+ message += `⚠️ *Branch CI Failures \\(blocked queue\\):*\n`;
795
+ for (const run of this.branchCIFailedRuns.slice(0, 3)) {
796
+ const runName = this.escapeMarkdown(run.name);
797
+ // Format the URL for MarkdownV2 - need to escape special characters
798
+ const runUrl = run.html_url ? `[View](${run.html_url.replace(/[)]/g, '\\)')})` : '';
799
+ message += ` ❌ ${runName} ${runUrl}\n`;
800
+ }
801
+ if (this.branchCIFailedRuns.length > 3) {
802
+ message += ` _\\.\\.\\.and ${this.branchCIFailedRuns.length - 3} more_\n`;
803
+ }
804
+ message += '\n';
805
+ }
806
+
807
+ // Issue #1341: Show post-merge CI failure details if applicable
808
+ if (this.postMergeCIFailedRuns && this.postMergeCIFailedRuns.length > 0) {
809
+ message += `⚠️ *Post\\-Merge CI Failures \\(stopped queue\\):*\n`;
810
+ for (const run of this.postMergeCIFailedRuns.slice(0, 3)) {
811
+ const runName = this.escapeMarkdown(run.name);
812
+ // Format the URL for MarkdownV2 - need to escape special characters
813
+ const runUrl = run.html_url ? `[View](${run.html_url.replace(/[)]/g, '\\)')})` : '';
814
+ message += ` ❌ ${runName} ${runUrl}\n`;
815
+ }
816
+ if (this.postMergeCIFailedRuns.length > 3) {
817
+ message += ` _\\.\\.\\.and ${this.postMergeCIFailedRuns.length - 3} more_\n`;
818
+ }
819
+ message += '\n';
820
+ }
821
+
612
822
  // Details
613
823
  if (report.items.length > 0) {
614
824
  message += `*Results:*\n`;