@link-assistant/hive-mind 1.50.2 → 1.50.4

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.
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Helper functions for the auto-merge module.
5
+ * Extracted from solve.auto-merge.lib.mjs to keep file sizes under the 1500-line limit.
6
+ *
7
+ * Contains:
8
+ * - checkForExistingComment: Deduplication of PR status comments
9
+ * - checkForNonBotComments: Detection of human feedback on PRs
10
+ * - getMergeBlockers: Comprehensive CI/CD status analysis
11
+ *
12
+ * @see https://github.com/link-assistant/hive-mind/issues/1190
13
+ * @see https://github.com/link-assistant/hive-mind/issues/1593
14
+ */
15
+
16
+ // Check if use is already defined globally (when imported from solve.mjs)
17
+ // If not, fetch it (when running standalone)
18
+ if (typeof globalThis.use === 'undefined') {
19
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
20
+ }
21
+ const use = globalThis.use;
22
+
23
+ // Use command-stream for consistent $ behavior across runtimes
24
+ const { $ } = await use('command-stream');
25
+
26
+ // Import shared library functions
27
+ const lib = await import('./lib.mjs');
28
+ const { log, formatAligned } = lib;
29
+
30
+ // Import Sentry integration
31
+ const sentryLib = await import('./sentry.lib.mjs');
32
+ const { reportError } = sentryLib;
33
+
34
+ // Import GitHub merge functions
35
+ const githubMergeLib = await import('./github-merge.lib.mjs');
36
+ const { checkPRMergeable, checkForBillingLimitError, getDetailedCIStatus, getWorkflowRunsForSha, getActiveRepoWorkflows, getCommitDate, checkWorkflowsHavePRTriggers, checkPreviousPRCommitsHadCI } = githubMergeLib;
37
+
38
+ /**
39
+ * Issue #1323: Check if a comment with specific content already exists on the PR
40
+ * This prevents duplicate status comments when multiple processes or restarts occur
41
+ *
42
+ * Issue #1584: Only search for duplicates AFTER the last session-ending comment.
43
+ * Previously, this searched the entire PR comment history, which caused false positives
44
+ * when a new working session was started after user feedback — the old "Ready to merge"
45
+ * comment from a previous session would suppress the new one, even though a new session-ending
46
+ * comment had been posted in between. By narrowing the search scope to only comments
47
+ * after the most recent session-ending comment, each working session gets its own deduplication
48
+ * window.
49
+ *
50
+ * Session-ending markers include:
51
+ * - "Now working session is ended" — present in all log upload comments (Solution Draft Log,
52
+ * Auto-restart Log, Auto-restart-until-mergeable Log, Solution Draft Log (Resumed/Truncated))
53
+ * - "AI Work Session Completed" — posted when logs are not attached to PR
54
+ *
55
+ * @param {string} owner - Repository owner
56
+ * @param {string} repo - Repository name
57
+ * @param {number} prNumber - Pull request number
58
+ * @param {string} commentSignature - Unique signature to search for in comment body (e.g., "✅ Ready to merge")
59
+ * @param {boolean} verbose - Enable verbose logging
60
+ * @returns {Promise<boolean>} - True if a matching comment already exists
61
+ */
62
+ export const checkForExistingComment = async (owner, repo, prNumber, commentSignature, verbose = false) => {
63
+ try {
64
+ // Fetch all PR comments as JSON to get individual comment bodies in order
65
+ const result = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --jq '[.[].body]' 2>/dev/null`;
66
+ if (result.code === 0 && result.stdout) {
67
+ const rawOutput = result.stdout.toString().trim();
68
+ if (!rawOutput) return false;
69
+
70
+ let commentBodies;
71
+ try {
72
+ commentBodies = JSON.parse(rawOutput);
73
+ } catch {
74
+ // Fallback: if JSON parsing fails, fall back to simple string search
75
+ if (verbose) {
76
+ console.log('[VERBOSE] Failed to parse comment bodies as JSON, falling back to full-history search');
77
+ }
78
+ return rawOutput.includes(commentSignature);
79
+ }
80
+
81
+ if (!Array.isArray(commentBodies) || commentBodies.length === 0) return false;
82
+
83
+ // Issue #1584: Find the index of the last session-ending comment.
84
+ // Only search for the signature in comments AFTER that index.
85
+ // Session-ending markers indicate the end of a working session,
86
+ // so any "Ready to merge" before it belongs to a previous session.
87
+ //
88
+ // Session-ending markers:
89
+ // - "Now working session is ended" — in all log upload comments
90
+ // (Solution Draft Log, Auto-restart Log, Auto-restart-until-mergeable Log, etc.)
91
+ // - "AI Work Session Completed" — posted when logs are not attached
92
+ const sessionEndingMarkers = ['Now working session is ended', 'AI Work Session Completed'];
93
+ let searchStartIndex = 0;
94
+ for (let i = commentBodies.length - 1; i >= 0; i--) {
95
+ if (commentBodies[i] && sessionEndingMarkers.some(marker => commentBodies[i].includes(marker))) {
96
+ searchStartIndex = i + 1;
97
+ if (verbose) {
98
+ console.log(`[VERBOSE] Found last session-ending comment at index ${i}, searching from index ${searchStartIndex}`);
99
+ }
100
+ break;
101
+ }
102
+ }
103
+
104
+ // Search only in comments after the last session-ending comment
105
+ for (let i = searchStartIndex; i < commentBodies.length; i++) {
106
+ if (commentBodies[i] && commentBodies[i].includes(commentSignature)) {
107
+ if (verbose) {
108
+ console.log(`[VERBOSE] Found existing comment with signature: "${commentSignature}" at index ${i} (after last session-ending comment)`);
109
+ }
110
+ return true;
111
+ }
112
+ }
113
+
114
+ if (verbose && searchStartIndex > 0) {
115
+ console.log(`[VERBOSE] No matching comment found after last session-ending comment (searched ${commentBodies.length - searchStartIndex} comments)`);
116
+ }
117
+ }
118
+ } catch (error) {
119
+ // If check fails, allow posting to avoid silent failures
120
+ if (verbose) {
121
+ console.log(`[VERBOSE] Failed to check for existing comment: ${error.message}`);
122
+ }
123
+ }
124
+ return false;
125
+ };
126
+
127
+ /**
128
+ * Check for new comments from non-bot users since last commit
129
+ * @returns {Promise<{hasNewComments: boolean, comments: Array}>}
130
+ */
131
+ export const checkForNonBotComments = async (owner, repo, prNumber, issueNumber, lastCheckTime, verbose = false) => {
132
+ try {
133
+ // Get current GitHub user to identify which comments are from the bot/hive-mind
134
+ let currentUser = null;
135
+ try {
136
+ const userResult = await $`gh api user --jq .login`;
137
+ if (userResult.code === 0) {
138
+ currentUser = userResult.stdout.toString().trim();
139
+ }
140
+ } catch {
141
+ // If we can't get the current user, continue without filtering
142
+ }
143
+
144
+ // Common bot usernames and patterns to filter out
145
+ // Note: Patterns use word boundaries or end-of-string to avoid false positives
146
+ // (e.g., "claudeuser" should NOT match as a bot)
147
+ const botPatterns = [
148
+ /\[bot\]$/i, // Any username ending with [bot]
149
+ /^github-actions$/i, // GitHub Actions
150
+ /^dependabot$/i, // Dependabot
151
+ /^renovate$/i, // Renovate
152
+ /^codecov$/i, // Codecov
153
+ /^netlify$/i, // Netlify
154
+ /^vercel$/i, // Vercel
155
+ /^hive-?mind$/i, // Hive Mind (with or without hyphen)
156
+ /^claude$/i, // Claude (exact match only)
157
+ /^copilot$/i, // GitHub Copilot
158
+ ];
159
+
160
+ const isBot = login => {
161
+ if (!login) return false;
162
+ // Check if it's the current user (the bot running hive-mind)
163
+ if (currentUser && login === currentUser) return true;
164
+ // Check against known bot patterns
165
+ return botPatterns.some(pattern => pattern.test(login));
166
+ };
167
+
168
+ // Fetch PR conversation comments
169
+ const prCommentsResult = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`;
170
+ let prComments = [];
171
+ if (prCommentsResult.code === 0 && prCommentsResult.stdout) {
172
+ prComments = JSON.parse(prCommentsResult.stdout.toString() || '[]');
173
+ }
174
+
175
+ // Fetch PR review comments (inline code comments)
176
+ const prReviewCommentsResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber}/comments --paginate`;
177
+ let prReviewComments = [];
178
+ if (prReviewCommentsResult.code === 0 && prReviewCommentsResult.stdout) {
179
+ prReviewComments = JSON.parse(prReviewCommentsResult.stdout.toString() || '[]');
180
+ }
181
+
182
+ // Fetch issue comments if we have an issue number
183
+ let issueComments = [];
184
+ if (issueNumber && issueNumber !== prNumber) {
185
+ const issueCommentsResult = await $`gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --paginate`;
186
+ if (issueCommentsResult.code === 0 && issueCommentsResult.stdout) {
187
+ issueComments = JSON.parse(issueCommentsResult.stdout.toString() || '[]');
188
+ }
189
+ }
190
+
191
+ // Combine all comments
192
+ const allComments = [...prComments, ...prReviewComments, ...issueComments];
193
+
194
+ // Filter for new comments from non-bot users
195
+ const newNonBotComments = allComments.filter(comment => {
196
+ const commentTime = new Date(comment.created_at);
197
+ const isAfterLastCheck = commentTime > lastCheckTime;
198
+ const isFromNonBot = !isBot(comment.user?.login);
199
+
200
+ if (verbose && isAfterLastCheck && isFromNonBot) {
201
+ console.log(`[VERBOSE] New non-bot comment from ${comment.user?.login} at ${comment.created_at}`);
202
+ }
203
+
204
+ return isAfterLastCheck && isFromNonBot;
205
+ });
206
+
207
+ return {
208
+ hasNewComments: newNonBotComments.length > 0,
209
+ comments: newNonBotComments,
210
+ };
211
+ } catch (error) {
212
+ reportError(error, {
213
+ context: 'check_non_bot_comments',
214
+ owner,
215
+ repo,
216
+ prNumber,
217
+ operation: 'fetch_comments',
218
+ });
219
+ return { hasNewComments: false, comments: [] };
220
+ }
221
+ };
222
+
223
+ /**
224
+ * Get the reasons why PR is not mergeable
225
+ * Issue #1314: Comprehensive CI/CD status handling covering all possible states:
226
+ * - success: All CI passed → no blocker
227
+ * - failure: Genuine code failures → restart AI
228
+ * - cancelled: Manually cancelled or workflow cancelled → re-trigger, don't restart AI
229
+ * - pending/queued: Still running or waiting for runner → wait, don't restart AI
230
+ * - billing_limit: Billing/spending limit reached → stop (private) or wait (public)
231
+ * - no_checks: No CI checks yet (race condition) → wait
232
+ */
233
+ export const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCount = 1, prBranchRef = null) => {
234
+ const blockers = [];
235
+
236
+ // Use detailed CI status to distinguish between all possible states
237
+ const ciStatus = await getDetailedCIStatus(owner, repo, prNumber, verbose);
238
+
239
+ if (ciStatus.status === 'no_checks') {
240
+ // No CI checks exist yet - this could be:
241
+ // 1. A race condition after push (checks haven't started yet) - wait
242
+ // 2. A repository with no CI/CD configured at all - should be mergeable immediately
243
+ // 3. CI workflows exist but were not triggered for this commit (fork PR, paths-ignore, etc.)
244
+ //
245
+ // Issue #1345: Distinguish by checking the PR's mergeability status.
246
+ // If GitHub says the PR is MERGEABLE (mergeStateStatus === 'CLEAN'),
247
+ // then no CI is required and we should not block indefinitely.
248
+ // Otherwise (e.g. mergeStateStatus === 'BLOCKED'), treat as pending race condition.
249
+ const earlyMergeStatus = await checkPRMergeable(owner, repo, prNumber, verbose);
250
+ if (earlyMergeStatus.mergeable) {
251
+ // Issue #1363: Before concluding "no CI configured", verify the repo actually
252
+ // has no active GitHub Actions workflows. If workflows exist but no checks have
253
+ // started yet, this is a race condition (GitHub takes ~10-30s to register checks
254
+ // after a push), NOT a "no CI configured" situation.
255
+ //
256
+ // This fixes a false positive where a repo with CI workflows but WITHOUT branch
257
+ // protection (required status checks) would be declared "no CI configured" because:
258
+ // - mergeStateStatus=CLEAN (no required checks to block it)
259
+ // - check_runs=[] (CI hasn't started yet — race condition)
260
+ const repoWorkflows = await getActiveRepoWorkflows(owner, repo, verbose);
261
+ if (repoWorkflows.hasWorkflows) {
262
+ // Repo HAS workflows — but were they triggered for this commit?
263
+ // Issue #1442: Use the GitHub Actions workflow runs API to definitively check
264
+ // if any workflow runs were triggered for this PR's HEAD SHA. This avoids
265
+ // the need for timeout-based detection:
266
+ // - workflow_runs.length > 0 → genuine race condition (CI started, check-runs not yet registered)
267
+ // - workflow_runs.length === 0 → CI was NOT triggered (fork PR, paths-ignore, etc.)
268
+ const workflowRuns = await getWorkflowRunsForSha(owner, repo, ciStatus.sha, verbose);
269
+ if (workflowRuns.length > 0) {
270
+ // Issue #1466: Check if ALL workflow runs are completed without producing check-runs.
271
+ // This happens when workflows require manual approval (first-time fork contributors,
272
+ // deployment approvals) — they complete with conclusion=action_required but never
273
+ // create check-runs. Waiting for check-runs in this case is an infinite loop.
274
+ //
275
+ // Also covers other non-executing conclusions: cancelled, stale workflows that
276
+ // completed without producing check-runs won't produce them in the future either.
277
+ const allRunsCompleted = workflowRuns.every(r => r.status === 'completed');
278
+ const allRunsNonExecuting = allRunsCompleted && workflowRuns.every(r => r.conclusion === 'action_required' || r.conclusion === 'cancelled' || r.conclusion === 'stale' || r.conclusion === 'skipped');
279
+
280
+ if (allRunsNonExecuting) {
281
+ // All workflow runs completed without executing jobs — check-runs will never appear.
282
+ // Treat the same as "CI not triggered" to avoid infinite waiting.
283
+ const conclusions = [...new Set(workflowRuns.map(r => r.conclusion))].join(', ');
284
+ if (verbose) {
285
+ await log(`[VERBOSE] /merge: PR #${prNumber} has ${workflowRuns.length} workflow run(s) for SHA ${ciStatus.sha.substring(0, 7)}, but all completed without executing (conclusions: ${conclusions}) — check-runs will never appear`);
286
+ }
287
+ await log(formatAligned('ℹ️', 'CI workflows completed without executing:', `${conclusions} (${workflowRuns.map(r => r.name).join(', ')})`, 2));
288
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true, workflowRunConclusions: conclusions };
289
+ }
290
+
291
+ // Some workflow runs are still in progress or produced results — genuine race condition
292
+ if (verbose) {
293
+ await log(`[VERBOSE] /merge: PR #${prNumber} has no CI check-runs yet, but ${workflowRuns.length} workflow run(s) were triggered for SHA ${ciStatus.sha.substring(0, 7)} - genuine race condition (waiting for check-runs to appear)`);
294
+ }
295
+ blockers.push({
296
+ type: 'ci_pending',
297
+ message: `CI/CD checks have not started yet (${workflowRuns.length} workflow run(s) triggered, waiting for check-runs to appear)`,
298
+ details: workflowRuns.map(r => r.name),
299
+ });
300
+ } else {
301
+ // No workflow runs for this SHA — but this could be a race condition!
302
+ // Issue #1480: GitHub Actions workflow runs take 30-120 seconds to appear in the
303
+ // API after a push. The previous fix (issue #1442) assumed 0 workflow runs meant
304
+ // "CI definitively NOT triggered", but this caused false positive "Ready to merge"
305
+ // when checked too soon after a push.
306
+ //
307
+ // Multi-layer defense (Issue #1480 enhanced):
308
+ // Layer 1: Grace period — check commit age
309
+ // Layer 2: Workflow file parsing — check .github/workflows for PR triggers
310
+ // Layer 3: Previous commit CI history — check if earlier PR commits had CI runs
311
+ const WORKFLOW_RUN_GRACE_PERIOD_SECONDS = 120; // 2 minutes — generous to cover slow GitHub API registration
312
+ const commitInfo = await getCommitDate(owner, repo, ciStatus.sha, verbose);
313
+
314
+ // Issue #1480: Parse workflow files for PR triggers (used in both grace period and post-grace checks)
315
+ const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose, prBranchRef);
316
+
317
+ // Issue #1480: If .github/workflows folder doesn't exist or has no workflow files,
318
+ // that's a definitive signal — no CI/CD will execute, skip grace period entirely
319
+ if (!prTriggers.hasWorkflowFiles) {
320
+ if (verbose) {
321
+ await log(`[VERBOSE] /merge: PR #${prNumber} repo has no workflow files in .github/workflows/ — CI definitively not configured at file level`);
322
+ }
323
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
324
+ }
325
+
326
+ if (prTriggers.hasPRTriggers) {
327
+ // Issue #1480 (enhanced): Workflows have PR/push triggers but no runs yet.
328
+ // This is almost certainly a race condition — GitHub takes 30-120s to register
329
+ // workflow runs after a push. We MUST wait regardless of commit age, because
330
+ // commit date reflects authoring time, NOT push time.
331
+ //
332
+ // The commit may have been authored hours ago but pushed just now (rebased branches,
333
+ // amended commits, cherry-picks). Using commit age as a proxy for push age caused
334
+ // false positives in Case 1 of Issue #1480.
335
+ //
336
+ // Safety valve: after MAX_NO_RUNS_CHECKS consecutive checks (typically 5 × 60s = 5 min),
337
+ // conclude CI was not triggered. This handles cases like paths-ignore excluding all
338
+ // changed files, conditional workflows that don't match, etc.
339
+ const MAX_NO_RUNS_CHECKS = 5;
340
+ if (checkCount >= MAX_NO_RUNS_CHECKS) {
341
+ // Issue #1503 (enhanced): Before concluding CI was not triggered, check if
342
+ // previous commits in this PR had CI runs. If they did, CI should be expected
343
+ // for the current commit too — extend waiting with a higher threshold.
344
+ const MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY = 10;
345
+ if (checkCount < MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY) {
346
+ const previousCI = await checkPreviousPRCommitsHadCI(owner, repo, prNumber, ciStatus.sha, verbose);
347
+ if (previousCI.hadPreviousCI) {
348
+ // Previous commits had CI — this commit should too, keep waiting
349
+ await log(formatAligned('⚠️', 'CI history signal:', `${previousCI.previousCommitsWithCI} previous commit(s) had CI runs — extending wait (check ${checkCount}/${MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY})`, 2));
350
+ blockers.push({
351
+ type: 'ci_pending',
352
+ message: `CI/CD workflow runs have not appeared yet — previous commits had CI runs, extending wait (check ${checkCount}/${MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY})`,
353
+ details: prTriggers.workflows.map(w => w.name),
354
+ });
355
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false };
356
+ }
357
+ }
358
+ // We've waited long enough (and no CI history signal) — CI was genuinely not triggered
359
+ if (verbose) {
360
+ await log(formatAligned('ℹ️', 'CI not triggered:', `No workflow runs after ${checkCount} consecutive checks — concluding CI was not triggered`, 2));
361
+ }
362
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
363
+ }
364
+
365
+ if (verbose) {
366
+ await log(formatAligned('⏳', 'Waiting for CI:', `No workflow runs for SHA ${ciStatus.sha.substring(0, 7)}, but workflows have PR/push triggers (${prTriggers.workflows.map(w => w.name).join(', ')}) — check ${checkCount}/${MAX_NO_RUNS_CHECKS}, commit age: ${commitInfo.ageSeconds ?? 'unknown'}s`, 2));
367
+ }
368
+ blockers.push({
369
+ type: 'ci_pending',
370
+ message: `CI/CD workflow runs have not appeared yet — workflows have PR/push triggers (${prTriggers.workflows.map(w => w.name).join(', ')}), waiting for GitHub to register workflow runs (check ${checkCount}/${MAX_NO_RUNS_CHECKS})`,
371
+ details: prTriggers.workflows.map(w => w.name),
372
+ });
373
+ } else if (commitInfo.ageSeconds !== null && commitInfo.ageSeconds < WORKFLOW_RUN_GRACE_PERIOD_SECONDS) {
374
+ // No PR triggers found in workflow files, but commit is still recent — be safe and wait
375
+ if (verbose) {
376
+ await log(`[VERBOSE] /merge: No PR/push triggers found in workflow files, but commit is only ${commitInfo.ageSeconds}s old — waiting to be safe`);
377
+ }
378
+ blockers.push({
379
+ type: 'ci_pending',
380
+ message: `CI/CD workflow runs have not appeared yet — commit is ${commitInfo.ageSeconds}s old, waiting for GitHub to register workflow runs (grace period: ${WORKFLOW_RUN_GRACE_PERIOD_SECONDS}s)`,
381
+ details: [],
382
+ });
383
+ } else {
384
+ // No PR triggers AND commit is old enough — CI was definitively NOT triggered
385
+ // Issue #1442: Fork PRs needing maintainer approval, paths-ignore filtering,
386
+ // workflow conditions not matching, etc. all result in zero workflow runs.
387
+ if (verbose) {
388
+ await log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and no workflow runs for SHA ${ciStatus.sha.substring(0, 7)} (commit age: ${commitInfo.ageSeconds ?? 'unknown'}s, no PR/push triggers in workflow files) — CI was not triggered`);
389
+ }
390
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
391
+ }
392
+ }
393
+ } else {
394
+ // Repo has NO workflows — this is truly "no CI configured"
395
+ // PR is already mergeable with no CI checks configured.
396
+ // Do NOT add a ci_pending blocker. The mergeability check below will also
397
+ // confirm this is mergeable, so blockers will be empty → PR IS MERGEABLE path.
398
+ if (verbose) {
399
+ await log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and repo has no active workflows - no CI/CD configured`);
400
+ }
401
+ // Return early with no CI blocker, mergeability already confirmed
402
+ return { blockers, ciStatus, noCiConfigured: true };
403
+ }
404
+ } else {
405
+ // PR is not yet mergeable despite no checks - treat as pending race condition
406
+ blockers.push({
407
+ type: 'ci_pending',
408
+ message: 'CI/CD checks have not started yet (waiting for checks to appear)',
409
+ details: [],
410
+ });
411
+ }
412
+ } else if (ciStatus.status === 'success') {
413
+ // Issue #1480: Cross-validate "success" with workflow runs API.
414
+ // A fast external check (e.g., CodeFactor) can register and pass before the main CI
415
+ // pipeline starts, causing getDetailedCIStatus to return 'success' prematurely.
416
+ // We must verify that all expected workflow runs have actually completed.
417
+ const workflowRuns = await getWorkflowRunsForSha(owner, repo, ciStatus.sha, verbose);
418
+
419
+ if (workflowRuns.length > 0) {
420
+ // Workflow runs exist — check if any are still running
421
+ const incompleteRuns = workflowRuns.filter(r => r.status !== 'completed');
422
+ if (incompleteRuns.length > 0) {
423
+ // Some workflow runs are still in progress — more check-runs may appear
424
+ if (verbose) {
425
+ await log(`[VERBOSE] /merge: PR #${prNumber} CI status is 'success' (${ciStatus.passedChecks.length} checks passed), but ${incompleteRuns.length} workflow run(s) still in progress — waiting for completion`);
426
+ }
427
+ blockers.push({
428
+ type: 'ci_pending',
429
+ message: `CI checks show success (${ciStatus.passedChecks.length} passed) but ${incompleteRuns.length} workflow run(s) still in progress — waiting for all to complete`,
430
+ details: incompleteRuns.map(r => r.name),
431
+ });
432
+ }
433
+ // All workflow runs completed — the check-runs we see are the final set, trust the 'success' status
434
+ } else {
435
+ // No workflow runs for this SHA — the passed checks are from external services only
436
+ // (e.g., CodeFactor, Codecov). Check if the repo has workflows that should produce runs.
437
+ const repoWorkflows = await getActiveRepoWorkflows(owner, repo, verbose);
438
+ if (repoWorkflows.hasWorkflows) {
439
+ const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose, prBranchRef);
440
+ if (prTriggers.hasPRTriggers) {
441
+ // Repo has workflows with PR triggers but no runs yet — CI hasn't started
442
+ // This is the exact scenario from Case 2 of Issue #1480
443
+ //
444
+ // Safety valve: after MAX_NO_RUNS_CHECKS consecutive checks, trust the external checks
445
+ const MAX_NO_RUNS_CHECKS = 5;
446
+ if (checkCount >= MAX_NO_RUNS_CHECKS) {
447
+ if (verbose) {
448
+ await log(`[VERBOSE] /merge: PR #${prNumber} CI 'success' with ${ciStatus.passedChecks.length} external checks, no workflow runs after ${checkCount} checks — trusting external checks`);
449
+ }
450
+ // Fall through — trust the success status from external checks
451
+ } else {
452
+ if (verbose) {
453
+ await log(`[VERBOSE] /merge: PR #${prNumber} CI status is 'success' (${ciStatus.passedChecks.length} external checks), but repo has PR-triggered workflows with 0 workflow runs — likely race condition (check ${checkCount}/${MAX_NO_RUNS_CHECKS})`);
454
+ }
455
+ // Wait for GitHub Actions to register workflow runs
456
+ blockers.push({
457
+ type: 'ci_pending',
458
+ message: `CI shows ${ciStatus.passedChecks.length} passed check(s) from external services, but repo has PR-triggered workflows that haven't started yet — waiting for GitHub Actions to register (check ${checkCount}/${MAX_NO_RUNS_CHECKS})`,
459
+ details: prTriggers.workflows.map(w => w.name),
460
+ });
461
+ }
462
+ }
463
+ }
464
+ // No repo workflows → external checks are the only CI, trust the 'success' status
465
+ }
466
+ } else if (ciStatus.status === 'pending') {
467
+ // CI is still running or queued - wait for completion
468
+ const pendingNames = [...ciStatus.pendingChecks, ...ciStatus.queuedChecks].map(c => c.name);
469
+ blockers.push({
470
+ type: 'ci_pending',
471
+ message: 'CI/CD checks are still running or queued',
472
+ details: pendingNames,
473
+ });
474
+ } else if (ciStatus.status === 'cancelled') {
475
+ // All non-passed checks are cancelled or stale (no genuine failures)
476
+ // First check if this is actually a billing limit issue (billing-limited jobs may appear as cancelled)
477
+ const billingCheck = await checkForBillingLimitError(owner, repo, prNumber, verbose);
478
+ if (billingCheck.isBillingLimitError) {
479
+ blockers.push({
480
+ type: 'billing_limit',
481
+ message: 'GitHub Actions billing/spending limit reached',
482
+ details: billingCheck.affectedJobs,
483
+ allJobsAffected: billingCheck.allJobsAffected,
484
+ billingMessage: billingCheck.message,
485
+ });
486
+ } else {
487
+ // These need to be re-triggered, NOT treated as AI-fixable failures
488
+ const cancelledOrStaleChecks = [...ciStatus.cancelledChecks, ...(ciStatus.staleChecks || [])];
489
+ blockers.push({
490
+ type: 'ci_cancelled',
491
+ message: 'CI/CD checks were cancelled or became stale',
492
+ details: cancelledOrStaleChecks.map(c => c.name),
493
+ sha: ciStatus.sha,
494
+ });
495
+ }
496
+ } else if (ciStatus.status === 'failure') {
497
+ // Some checks genuinely failed - check if it's billing limits first
498
+ const billingCheck = await checkForBillingLimitError(owner, repo, prNumber, verbose);
499
+
500
+ if (billingCheck.isBillingLimitError) {
501
+ blockers.push({
502
+ type: 'billing_limit',
503
+ message: 'GitHub Actions billing/spending limit reached',
504
+ details: billingCheck.affectedJobs,
505
+ allJobsAffected: billingCheck.allJobsAffected,
506
+ billingMessage: billingCheck.message,
507
+ });
508
+ } else {
509
+ // Check if there are also cancelled/stale checks alongside failures
510
+ const cancelledOrStaleChecks = [...(ciStatus.hasCancelled ? ciStatus.cancelledChecks : []), ...((ciStatus.hasStale && ciStatus.staleChecks) || [])];
511
+ if (cancelledOrStaleChecks.length > 0) {
512
+ blockers.push({
513
+ type: 'ci_cancelled',
514
+ message: 'Some CI/CD checks were cancelled or became stale (will be re-triggered)',
515
+ details: cancelledOrStaleChecks.map(c => c.name),
516
+ sha: ciStatus.sha,
517
+ });
518
+ }
519
+ blockers.push({
520
+ type: 'ci_failure',
521
+ message: 'CI/CD checks are failing',
522
+ details: ciStatus.failedChecks.map(c => c.name),
523
+ });
524
+ }
525
+ } else if (ciStatus.status === 'unknown') {
526
+ // Unable to determine CI status - treat as pending to be safe
527
+ // Do NOT treat as mergeable (which would be incorrect)
528
+ blockers.push({
529
+ type: 'ci_pending',
530
+ message: 'CI/CD status could not be determined (will retry)',
531
+ details: [],
532
+ });
533
+ }
534
+
535
+ // Check mergeability
536
+ const mergeStatus = await checkPRMergeable(owner, repo, prNumber, verbose);
537
+ if (!mergeStatus.mergeable) {
538
+ blockers.push({
539
+ type: 'not_mergeable',
540
+ message: mergeStatus.reason || 'PR is not mergeable',
541
+ details: [],
542
+ });
543
+ }
544
+
545
+ return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false };
546
+ };
547
+
548
+ export default {
549
+ checkForExistingComment,
550
+ checkForNonBotComments,
551
+ getMergeBlockers,
552
+ };