@link-assistant/hive-mind 1.38.2 → 1.38.3
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 +6 -0
- package/package.json +1 -1
- package/src/github-merge-repo-actions.lib.mjs +103 -0
- package/src/github-merge.lib.mjs +19 -15
- package/src/solve.auto-merge.lib.mjs +143 -19
- package/src/solve.config.lib.mjs +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.38.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- deb31bf: fix: add multi-mechanism CI consensus, repo-wide action monitoring, and 5-min minimum CI check interval to prevent false positive "Ready to merge"
|
|
8
|
+
|
|
3
9
|
## 1.38.2
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Repository-Wide Actions Monitoring
|
|
4
|
+
*
|
|
5
|
+
* Issue #1503: Functions to check and wait for ALL active GitHub Actions
|
|
6
|
+
* workflow runs across the entire repository. This is the "absolute safety
|
|
7
|
+
* mechanism" modeled after the /merge command's waitForBranchCI pattern.
|
|
8
|
+
*
|
|
9
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1503
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { promisify } from 'util';
|
|
13
|
+
import { exec as execCallback } from 'child_process';
|
|
14
|
+
const exec = promisify(execCallback);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get ALL active workflow runs across the entire repository (no branch filter).
|
|
18
|
+
* @param {string} owner - Repository owner
|
|
19
|
+
* @param {string} repo - Repository name
|
|
20
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
21
|
+
* @returns {Promise<{runs: Array, hasActiveRuns: boolean, count: number}>}
|
|
22
|
+
*/
|
|
23
|
+
export async function getAllActiveRepoRuns(owner, repo, verbose = false) {
|
|
24
|
+
try {
|
|
25
|
+
const activeFilter = '.workflow_runs[] | select(.status=="in_progress" or .status=="queued" or .status=="waiting" or .status=="requested" or .status=="pending")';
|
|
26
|
+
const fields = '{id: .id, name: .name, status: .status, head_branch: .head_branch, head_sha: (.head_sha[:7])}';
|
|
27
|
+
const { stdout } = await exec(`gh api "repos/${owner}/${repo}/actions/runs?per_page=100" --jq '[${activeFilter}] | map(${fields})'`);
|
|
28
|
+
const runs = JSON.parse(stdout.trim() || '[]');
|
|
29
|
+
if (verbose && runs.length > 0) {
|
|
30
|
+
console.log(`[VERBOSE] repo-actions: ${runs.length} active run(s) in ${owner}/${repo}`);
|
|
31
|
+
for (const r of runs) console.log(`[VERBOSE] repo-actions: ${r.name} (${r.status}) on ${r.head_branch}`);
|
|
32
|
+
}
|
|
33
|
+
return { runs, hasActiveRuns: runs.length > 0, count: runs.length };
|
|
34
|
+
} catch {
|
|
35
|
+
return { runs: [], hasActiveRuns: false, count: 0 };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Wait for ALL active workflow runs in the repository to complete.
|
|
41
|
+
* Blocks until every in-progress/queued run across ALL branches finishes.
|
|
42
|
+
* @param {string} owner - Repository owner
|
|
43
|
+
* @param {string} repo - Repository name
|
|
44
|
+
* @param {Object} options - Wait options (timeout, pollInterval, onStatusUpdate)
|
|
45
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
46
|
+
* @returns {Promise<{success: boolean, waitedForRuns: boolean, timedOut: boolean, remainingRuns: Array}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function waitForAllRepoActions(owner, repo, options = {}, verbose = false) {
|
|
49
|
+
const { timeout = 45 * 60 * 1000, pollInterval = 5 * 60 * 1000, onStatusUpdate = null } = options;
|
|
50
|
+
const startTime = Date.now();
|
|
51
|
+
let peakRunCount = 0;
|
|
52
|
+
|
|
53
|
+
while (Date.now() - startTime < timeout) {
|
|
54
|
+
const active = await getAllActiveRepoRuns(owner, repo, verbose);
|
|
55
|
+
if (onStatusUpdate) {
|
|
56
|
+
try {
|
|
57
|
+
await onStatusUpdate({ ...active, elapsedMs: Date.now() - startTime });
|
|
58
|
+
} catch {
|
|
59
|
+
// Ignore callback errors — continue monitoring
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!active.hasActiveRuns) {
|
|
63
|
+
return { success: true, waitedForRuns: peakRunCount > 0, timedOut: false, remainingRuns: [] };
|
|
64
|
+
}
|
|
65
|
+
peakRunCount = Math.max(peakRunCount, active.count);
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
67
|
+
}
|
|
68
|
+
const finalRuns = await getAllActiveRepoRuns(owner, repo, verbose);
|
|
69
|
+
return { success: false, waitedForRuns: true, timedOut: true, remainingRuns: finalRuns.runs };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Multi-mechanism CI consensus check. Requires Check Runs API, Workflow Runs API,
|
|
74
|
+
* and optionally repo-wide active runs to ALL agree before concluding CI is complete.
|
|
75
|
+
* @param {Object} params
|
|
76
|
+
* @returns {Promise<{allAgree: boolean, mechanisms: Object, ciStatus: Object, workflowRuns: Array}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function checkCIConsensus({ owner, repo, prNumber, sha, waitForAllRepoActionsFlag, verbose, getDetailedCIStatus, getWorkflowRunsForSha }) {
|
|
79
|
+
const ciStatus = await getDetailedCIStatus(owner, repo, prNumber, verbose);
|
|
80
|
+
const checkRunsOK = ciStatus.status === 'success' || ciStatus.status === 'no_checks';
|
|
81
|
+
|
|
82
|
+
const workflowRuns = await getWorkflowRunsForSha(owner, repo, sha, verbose);
|
|
83
|
+
const workflowsOK = workflowRuns.length === 0 || workflowRuns.every(r => r.status === 'completed');
|
|
84
|
+
|
|
85
|
+
let repoOK = true;
|
|
86
|
+
let repoInfo = null;
|
|
87
|
+
if (waitForAllRepoActionsFlag) {
|
|
88
|
+
repoInfo = await getAllActiveRepoRuns(owner, repo, verbose);
|
|
89
|
+
repoOK = !repoInfo.hasActiveRuns;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const allAgree = checkRunsOK && workflowsOK && repoOK;
|
|
93
|
+
const mechanisms = {
|
|
94
|
+
checkRunsAPI: { complete: checkRunsOK, status: ciStatus.status },
|
|
95
|
+
workflowRunsAPI: { complete: workflowsOK, total: workflowRuns.length, inProgress: workflowRuns.filter(r => r.status !== 'completed').length },
|
|
96
|
+
repoActions: waitForAllRepoActionsFlag ? { complete: repoOK, count: repoInfo?.count ?? 0 } : { skipped: true },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (verbose) {
|
|
100
|
+
console.log(`[VERBOSE] consensus: CheckRuns=${checkRunsOK}(${ciStatus.status}), WorkflowRuns=${workflowsOK}(${workflowRuns.length}), RepoActions=${waitForAllRepoActionsFlag ? repoOK : 'skip'} → ${allAgree ? 'AGREE' : 'DISAGREE'}`);
|
|
101
|
+
}
|
|
102
|
+
return { allAgree, mechanisms, ciStatus, workflowRuns };
|
|
103
|
+
}
|
package/src/github-merge.lib.mjs
CHANGED
|
@@ -1385,31 +1385,30 @@ export async function checkPreviousPRCommitsHadCI(owner, repo, prNumber, headSha
|
|
|
1385
1385
|
* @param {boolean} verbose - Whether to log verbose output
|
|
1386
1386
|
* @returns {Promise<{hasPRTriggers: boolean, hasWorkflowFiles: boolean, workflows: Array<{name: string, triggers: string[]}>}>}
|
|
1387
1387
|
*/
|
|
1388
|
-
export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false) {
|
|
1388
|
+
export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false, ref = null) {
|
|
1389
1389
|
try {
|
|
1390
|
-
//
|
|
1391
|
-
const
|
|
1390
|
+
// Issue #1503: Support querying workflow files from a specific branch (ref)
|
|
1391
|
+
const refParam = ref ? `?ref=${encodeURIComponent(ref)}` : '';
|
|
1392
|
+
// List workflow files in .github/workflows/ (uses ref if provided, otherwise default branch)
|
|
1393
|
+
const { stdout: listJson } = await exec(`gh api "repos/${owner}/${repo}/contents/.github/workflows${refParam}" --jq '[.[] | select(.name | test("\\\\.(yml|yaml)$")) | {name: .name, download_url: .download_url, path: .path}]' 2>/dev/null`);
|
|
1392
1394
|
const files = JSON.parse(listJson.trim() || '[]');
|
|
1393
1395
|
|
|
1394
1396
|
if (files.length === 0) {
|
|
1395
|
-
if (verbose) {
|
|
1396
|
-
console.log(`[VERBOSE] /merge: No workflow files found in ${owner}/${repo}/.github/workflows/ — no CI/CD will execute`);
|
|
1397
|
-
}
|
|
1398
|
-
// Issue #1480: hasWorkflowFiles=false is a strong signal that no CI/CD is configured at the file level
|
|
1397
|
+
if (verbose) console.log(`[VERBOSE] /merge: No workflow files in ${owner}/${repo}/.github/workflows/`);
|
|
1399
1398
|
return { hasPRTriggers: false, hasWorkflowFiles: false, workflows: [] };
|
|
1400
1399
|
}
|
|
1401
1400
|
|
|
1402
1401
|
const prTriggerPatterns = [/\bon:\s*\n\s+pull_request/m, /\bon:\s*\[.*pull_request.*\]/m, /\bon:\s*pull_request\b/m, /\bpull_request_target\b/m];
|
|
1403
|
-
|
|
1404
|
-
// Also check for push triggers (push to PR branches triggers CI)
|
|
1405
1402
|
const pushTriggerPatterns = [/\bon:\s*\n\s+push/m, /\bon:\s*\[.*push.*\]/m, /\bon:\s*push\b/m];
|
|
1403
|
+
// Issue #1503: Non-PR triggers for diagnostics (won't produce check-runs on PRs)
|
|
1404
|
+
const nonPROnlyTriggerPatterns = [/\bworkflow_dispatch\b/m, /\bschedule\b/m, /\brepository_dispatch\b/m, /\bworkflow_call\b/m];
|
|
1406
1405
|
|
|
1407
1406
|
const results = [];
|
|
1408
1407
|
|
|
1409
1408
|
for (const file of files) {
|
|
1410
1409
|
try {
|
|
1411
|
-
// Fetch file content
|
|
1412
|
-
const { stdout: contentJson } = await exec(`gh api "repos/${owner}/${repo}/contents/${file.path}" --jq '.content'`);
|
|
1410
|
+
// Issue #1503: Fetch file content using same ref parameter for branch-specific workflows
|
|
1411
|
+
const { stdout: contentJson } = await exec(`gh api "repos/${owner}/${repo}/contents/${file.path}${refParam}" --jq '.content'`);
|
|
1413
1412
|
const content = Buffer.from(contentJson.trim().replace(/"/g, ''), 'base64').toString('utf-8');
|
|
1414
1413
|
|
|
1415
1414
|
const triggers = [];
|
|
@@ -1419,13 +1418,15 @@ export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false)
|
|
|
1419
1418
|
if (pushTriggerPatterns.some(p => p.test(content))) {
|
|
1420
1419
|
triggers.push('push');
|
|
1421
1420
|
}
|
|
1421
|
+
// Issue #1503: Track non-PR triggers for diagnostics
|
|
1422
|
+
const nonPRTriggers = nonPROnlyTriggerPatterns.filter(p => p.test(content)).map(p => p.source.replace(/\\b/g, ''));
|
|
1422
1423
|
|
|
1423
1424
|
if (triggers.length > 0) {
|
|
1424
1425
|
results.push({ name: file.name, triggers });
|
|
1425
1426
|
}
|
|
1426
1427
|
|
|
1427
1428
|
if (verbose) {
|
|
1428
|
-
console.log(`[VERBOSE] /merge: Workflow ${file.name}:
|
|
1429
|
+
console.log(`[VERBOSE] /merge: Workflow ${file.name}: pr_triggers=[${triggers.join(', ')}], non_pr_triggers=[${nonPRTriggers.join(', ')}]`);
|
|
1429
1430
|
}
|
|
1430
1431
|
} catch (fileError) {
|
|
1431
1432
|
if (verbose) {
|
|
@@ -1454,6 +1455,9 @@ export async function checkWorkflowsHavePRTriggers(owner, repo, verbose = false)
|
|
|
1454
1455
|
import { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha } from './github-merge-ci.lib.mjs';
|
|
1455
1456
|
export { waitForCommitCI, checkBranchCIHealth, getMergeCommitSha };
|
|
1456
1457
|
|
|
1458
|
+
import { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus } from './github-merge-repo-actions.lib.mjs'; // Issue #1503
|
|
1459
|
+
export { getAllActiveRepoRuns, waitForAllRepoActions, checkCIConsensus };
|
|
1460
|
+
|
|
1457
1461
|
export default {
|
|
1458
1462
|
READY_LABEL,
|
|
1459
1463
|
checkReadyLabelExists,
|
|
@@ -1482,15 +1486,15 @@ export default {
|
|
|
1482
1486
|
rerunWorkflowRun,
|
|
1483
1487
|
rerunFailedJobs,
|
|
1484
1488
|
getWorkflowRunsForSha,
|
|
1485
|
-
// Issue #1341: Post-merge CI waiting; Issue #1363: Detect active workflows
|
|
1486
1489
|
waitForCommitCI,
|
|
1487
1490
|
checkBranchCIHealth,
|
|
1488
1491
|
getMergeCommitSha,
|
|
1489
1492
|
getActiveRepoWorkflows,
|
|
1490
|
-
// Issue #1480: Commit date, workflow PR triggers, and previous commit CI history for race condition detection
|
|
1491
1493
|
getCommitDate,
|
|
1492
1494
|
checkPreviousPRCommitsHadCI,
|
|
1493
1495
|
checkWorkflowsHavePRTriggers,
|
|
1494
|
-
// Issue #1413: Use issue timeline to find genuinely linked PRs (avoids false positives from text search)
|
|
1495
1496
|
getLinkedPRsFromTimeline,
|
|
1497
|
+
getAllActiveRepoRuns,
|
|
1498
|
+
waitForAllRepoActions,
|
|
1499
|
+
checkCIConsensus, // Issue #1503
|
|
1496
1500
|
};
|
|
@@ -33,7 +33,7 @@ const { reportError } = sentryLib;
|
|
|
33
33
|
|
|
34
34
|
// Import GitHub merge functions
|
|
35
35
|
const githubMergeLib = await import('./github-merge.lib.mjs');
|
|
36
|
-
const { checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI, checkForBillingLimitError, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getActiveRepoWorkflows, getCommitDate, checkWorkflowsHavePRTriggers } = githubMergeLib;
|
|
36
|
+
const { checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI, checkForBillingLimitError, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getActiveRepoWorkflows, getCommitDate, checkWorkflowsHavePRTriggers, checkPreviousPRCommitsHadCI, getAllActiveRepoRuns, checkCIConsensus } = githubMergeLib;
|
|
37
37
|
|
|
38
38
|
// Import GitHub functions for log attachment
|
|
39
39
|
const githubLib = await import('./github.lib.mjs');
|
|
@@ -187,7 +187,7 @@ const checkForNonBotComments = async (owner, repo, prNumber, issueNumber, lastCh
|
|
|
187
187
|
* - billing_limit: Billing/spending limit reached → stop (private) or wait (public)
|
|
188
188
|
* - no_checks: No CI checks yet (race condition) → wait
|
|
189
189
|
*/
|
|
190
|
-
const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCount = 1) => {
|
|
190
|
+
const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCount = 1, prBranchRef = null) => {
|
|
191
191
|
const blockers = [];
|
|
192
192
|
|
|
193
193
|
// Use detailed CI status to distinguish between all possible states
|
|
@@ -239,7 +239,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
239
239
|
// Treat the same as "CI not triggered" to avoid infinite waiting.
|
|
240
240
|
const conclusions = [...new Set(workflowRuns.map(r => r.conclusion))].join(', ');
|
|
241
241
|
if (verbose) {
|
|
242
|
-
|
|
242
|
+
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`);
|
|
243
243
|
}
|
|
244
244
|
await log(formatAligned('ℹ️', 'CI workflows completed without executing:', `${conclusions} (${workflowRuns.map(r => r.name).join(', ')})`, 2));
|
|
245
245
|
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true, workflowRunConclusions: conclusions };
|
|
@@ -247,7 +247,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
247
247
|
|
|
248
248
|
// Some workflow runs are still in progress or produced results — genuine race condition
|
|
249
249
|
if (verbose) {
|
|
250
|
-
|
|
250
|
+
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)`);
|
|
251
251
|
}
|
|
252
252
|
blockers.push({
|
|
253
253
|
type: 'ci_pending',
|
|
@@ -269,13 +269,13 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
269
269
|
const commitInfo = await getCommitDate(owner, repo, ciStatus.sha, verbose);
|
|
270
270
|
|
|
271
271
|
// Issue #1480: Parse workflow files for PR triggers (used in both grace period and post-grace checks)
|
|
272
|
-
const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose);
|
|
272
|
+
const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose, prBranchRef);
|
|
273
273
|
|
|
274
274
|
// Issue #1480: If .github/workflows folder doesn't exist or has no workflow files,
|
|
275
275
|
// that's a definitive signal — no CI/CD will execute, skip grace period entirely
|
|
276
276
|
if (!prTriggers.hasWorkflowFiles) {
|
|
277
277
|
if (verbose) {
|
|
278
|
-
|
|
278
|
+
await log(`[VERBOSE] /merge: PR #${prNumber} repo has no workflow files in .github/workflows/ — CI definitively not configured at file level`);
|
|
279
279
|
}
|
|
280
280
|
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
|
|
281
281
|
}
|
|
@@ -295,15 +295,32 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
295
295
|
// changed files, conditional workflows that don't match, etc.
|
|
296
296
|
const MAX_NO_RUNS_CHECKS = 5;
|
|
297
297
|
if (checkCount >= MAX_NO_RUNS_CHECKS) {
|
|
298
|
-
//
|
|
298
|
+
// Issue #1503 (enhanced): Before concluding CI was not triggered, check if
|
|
299
|
+
// previous commits in this PR had CI runs. If they did, CI should be expected
|
|
300
|
+
// for the current commit too — extend waiting with a higher threshold.
|
|
301
|
+
const MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY = 10;
|
|
302
|
+
if (checkCount < MAX_NO_RUNS_CHECKS_WITH_CI_HISTORY) {
|
|
303
|
+
const previousCI = await checkPreviousPRCommitsHadCI(owner, repo, prNumber, ciStatus.sha, verbose);
|
|
304
|
+
if (previousCI.hadPreviousCI) {
|
|
305
|
+
// Previous commits had CI — this commit should too, keep waiting
|
|
306
|
+
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));
|
|
307
|
+
blockers.push({
|
|
308
|
+
type: 'ci_pending',
|
|
309
|
+
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})`,
|
|
310
|
+
details: prTriggers.workflows.map(w => w.name),
|
|
311
|
+
});
|
|
312
|
+
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// We've waited long enough (and no CI history signal) — CI was genuinely not triggered
|
|
299
316
|
if (verbose) {
|
|
300
|
-
|
|
317
|
+
await log(formatAligned('ℹ️', 'CI not triggered:', `No workflow runs after ${checkCount} consecutive checks — concluding CI was not triggered`, 2));
|
|
301
318
|
}
|
|
302
319
|
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
|
|
303
320
|
}
|
|
304
321
|
|
|
305
322
|
if (verbose) {
|
|
306
|
-
|
|
323
|
+
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));
|
|
307
324
|
}
|
|
308
325
|
blockers.push({
|
|
309
326
|
type: 'ci_pending',
|
|
@@ -313,7 +330,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
313
330
|
} else if (commitInfo.ageSeconds !== null && commitInfo.ageSeconds < WORKFLOW_RUN_GRACE_PERIOD_SECONDS) {
|
|
314
331
|
// No PR triggers found in workflow files, but commit is still recent — be safe and wait
|
|
315
332
|
if (verbose) {
|
|
316
|
-
|
|
333
|
+
await log(`[VERBOSE] /merge: No PR/push triggers found in workflow files, but commit is only ${commitInfo.ageSeconds}s old — waiting to be safe`);
|
|
317
334
|
}
|
|
318
335
|
blockers.push({
|
|
319
336
|
type: 'ci_pending',
|
|
@@ -325,7 +342,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
325
342
|
// Issue #1442: Fork PRs needing maintainer approval, paths-ignore filtering,
|
|
326
343
|
// workflow conditions not matching, etc. all result in zero workflow runs.
|
|
327
344
|
if (verbose) {
|
|
328
|
-
|
|
345
|
+
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`);
|
|
329
346
|
}
|
|
330
347
|
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
|
|
331
348
|
}
|
|
@@ -336,7 +353,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
336
353
|
// Do NOT add a ci_pending blocker. The mergeability check below will also
|
|
337
354
|
// confirm this is mergeable, so blockers will be empty → PR IS MERGEABLE path.
|
|
338
355
|
if (verbose) {
|
|
339
|
-
|
|
356
|
+
await log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and repo has no active workflows - no CI/CD configured`);
|
|
340
357
|
}
|
|
341
358
|
// Return early with no CI blocker, mergeability already confirmed
|
|
342
359
|
return { blockers, ciStatus, noCiConfigured: true };
|
|
@@ -362,7 +379,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
362
379
|
if (incompleteRuns.length > 0) {
|
|
363
380
|
// Some workflow runs are still in progress — more check-runs may appear
|
|
364
381
|
if (verbose) {
|
|
365
|
-
|
|
382
|
+
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`);
|
|
366
383
|
}
|
|
367
384
|
blockers.push({
|
|
368
385
|
type: 'ci_pending',
|
|
@@ -376,7 +393,7 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
376
393
|
// (e.g., CodeFactor, Codecov). Check if the repo has workflows that should produce runs.
|
|
377
394
|
const repoWorkflows = await getActiveRepoWorkflows(owner, repo, verbose);
|
|
378
395
|
if (repoWorkflows.hasWorkflows) {
|
|
379
|
-
const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose);
|
|
396
|
+
const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose, prBranchRef);
|
|
380
397
|
if (prTriggers.hasPRTriggers) {
|
|
381
398
|
// Repo has workflows with PR triggers but no runs yet — CI hasn't started
|
|
382
399
|
// This is the exact scenario from Case 2 of Issue #1480
|
|
@@ -385,12 +402,12 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
385
402
|
const MAX_NO_RUNS_CHECKS = 5;
|
|
386
403
|
if (checkCount >= MAX_NO_RUNS_CHECKS) {
|
|
387
404
|
if (verbose) {
|
|
388
|
-
|
|
405
|
+
await log(`[VERBOSE] /merge: PR #${prNumber} CI 'success' with ${ciStatus.passedChecks.length} external checks, no workflow runs after ${checkCount} checks — trusting external checks`);
|
|
389
406
|
}
|
|
390
407
|
// Fall through — trust the success status from external checks
|
|
391
408
|
} else {
|
|
392
409
|
if (verbose) {
|
|
393
|
-
|
|
410
|
+
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})`);
|
|
394
411
|
}
|
|
395
412
|
// Wait for GitHub Actions to register workflow runs
|
|
396
413
|
blockers.push({
|
|
@@ -492,8 +509,14 @@ const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCou
|
|
|
492
509
|
export const watchUntilMergeable = async params => {
|
|
493
510
|
const { issueUrl, owner, repo, issueNumber, prNumber, prBranch, branchName, tempDir, argv } = params;
|
|
494
511
|
|
|
495
|
-
const
|
|
512
|
+
const rawWatchInterval = argv.watchInterval || 60; // seconds
|
|
513
|
+
// Issue #1503: Enforce minimum 5-minute (300s) CI check interval to conserve GitHub API rate limits.
|
|
514
|
+
// This prevents excessive API calls during long-running CI pipelines.
|
|
515
|
+
const MIN_CI_CHECK_INTERVAL_SECONDS = 300;
|
|
516
|
+
const watchInterval = Math.max(rawWatchInterval, MIN_CI_CHECK_INTERVAL_SECONDS);
|
|
496
517
|
const isAutoMerge = argv.autoMerge || false;
|
|
518
|
+
// Issue #1503: --wait-for-all-actions-in-repository-before-mergable (default: true)
|
|
519
|
+
const waitForAllRepoActionsFlag = argv.waitForAllActionsInRepositoryBeforeMergable ?? argv['wait-for-all-actions-in-repository-before-mergable'] ?? true;
|
|
497
520
|
|
|
498
521
|
// Track latest session data across all iterations for accurate pricing
|
|
499
522
|
let latestSessionId = null;
|
|
@@ -513,11 +536,25 @@ export const watchUntilMergeable = async params => {
|
|
|
513
536
|
|
|
514
537
|
let currentBackoffSeconds = watchInterval;
|
|
515
538
|
|
|
539
|
+
// Issue #1503: Track consecutive "no workflow runs" checks per-SHA separately from iteration count.
|
|
540
|
+
// The `checkCount` parameter in getMergeBlockers is a safety valve that triggers after
|
|
541
|
+
// MAX_NO_RUNS_CHECKS (5) consecutive checks with zero workflow runs, concluding CI was
|
|
542
|
+
// genuinely not triggered (paths-ignore, fork PRs, etc.). Previously, `iteration` (total
|
|
543
|
+
// loop count) was passed as `checkCount`, which meant after 5 iterations (regardless of
|
|
544
|
+
// CI state), any new push would immediately trigger the safety valve because checkCount
|
|
545
|
+
// was already >= 5. This caused false positive "Ready to merge" when a new commit was
|
|
546
|
+
// pushed and CI hadn't registered yet.
|
|
547
|
+
//
|
|
548
|
+
// Fix: Track the HEAD SHA and reset the counter when it changes (new push detected).
|
|
549
|
+
let consecutiveNoRunsChecks = 0;
|
|
550
|
+
let lastKnownHeadSha = null;
|
|
551
|
+
|
|
516
552
|
await log('');
|
|
517
553
|
await log(formatAligned('🔄', 'AUTO-RESTART-UNTIL-MERGEABLE MODE ACTIVE', ''));
|
|
518
554
|
await log(formatAligned('', 'Monitoring PR:', `#${prNumber}`, 2));
|
|
519
555
|
await log(formatAligned('', 'Mode:', isAutoMerge ? 'Auto-merge (will merge when ready)' : 'Auto-restart-until-mergeable (will NOT auto-merge)', 2));
|
|
520
|
-
await log(formatAligned('', 'Checking interval:', `${watchInterval} seconds`, 2));
|
|
556
|
+
await log(formatAligned('', 'Checking interval:', `${watchInterval} seconds (minimum: ${MIN_CI_CHECK_INTERVAL_SECONDS}s)`, 2));
|
|
557
|
+
await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (absolute safety)' : 'No', 2));
|
|
521
558
|
await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
|
|
522
559
|
await log(formatAligned('', 'Restart triggers:', 'New non-bot comments, CI failures, merge conflicts', 2));
|
|
523
560
|
await log('');
|
|
@@ -554,8 +591,47 @@ export const watchUntilMergeable = async params => {
|
|
|
554
591
|
await log(formatAligned('🔍', `Check #${iteration}:`, currentTime.toLocaleTimeString()));
|
|
555
592
|
|
|
556
593
|
try {
|
|
594
|
+
// Issue #1503: Get the current HEAD SHA to detect new pushes and reset the
|
|
595
|
+
// consecutive no-runs counter. This prevents false positives where the counter
|
|
596
|
+
// from a previous commit's checks carries over to a new commit.
|
|
597
|
+
let currentHeadSha = null;
|
|
598
|
+
try {
|
|
599
|
+
const shaResult = await $`gh pr view ${prNumber} --repo ${owner}/${repo} --json headRefOid --jq .headRefOid`;
|
|
600
|
+
if (shaResult.code === 0) {
|
|
601
|
+
currentHeadSha = shaResult.stdout.toString().trim();
|
|
602
|
+
}
|
|
603
|
+
} catch {
|
|
604
|
+
// If SHA check fails, proceed with current counter (safe: doesn't reset)
|
|
605
|
+
}
|
|
606
|
+
if (currentHeadSha && currentHeadSha !== lastKnownHeadSha) {
|
|
607
|
+
if (lastKnownHeadSha !== null) {
|
|
608
|
+
await log(formatAligned('🔄', 'New commit detected:', `${lastKnownHeadSha.substring(0, 7)} → ${currentHeadSha.substring(0, 7)} (resetting CI check counter)`, 2));
|
|
609
|
+
}
|
|
610
|
+
lastKnownHeadSha = currentHeadSha;
|
|
611
|
+
consecutiveNoRunsChecks = 0;
|
|
612
|
+
// Issue #1503: Also reset the readyToMergeCommentPosted flag when SHA changes,
|
|
613
|
+
// so a new "Ready to merge" comment can be posted for the new commit's CI results.
|
|
614
|
+
readyToMergeCommentPosted = false;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Issue #1503: Increment counter; getMergeBlockers will use it as a safety valve.
|
|
618
|
+
// If getMergeBlockers sees no workflow runs on this check, the counter stays incremented.
|
|
619
|
+
// If it sees workflow runs or checks, the counter is irrelevant (different code paths).
|
|
620
|
+
consecutiveNoRunsChecks++;
|
|
621
|
+
|
|
557
622
|
// Get merge blockers
|
|
558
|
-
const { blockers, noCiConfigured, noCiTriggered, workflowRunConclusions } = await getMergeBlockers(owner, repo, prNumber, argv.verbose,
|
|
623
|
+
const { blockers, noCiConfigured, noCiTriggered, workflowRunConclusions, ciStatus } = await getMergeBlockers(owner, repo, prNumber, argv.verbose, consecutiveNoRunsChecks, prBranch);
|
|
624
|
+
|
|
625
|
+
// Issue #1503: Reset consecutive counter when CI checks or workflow runs were found.
|
|
626
|
+
// This ensures the safety valve only fires after truly consecutive "no runs" checks,
|
|
627
|
+
// not after interleaved pending/success/failure states that happened to reach the count.
|
|
628
|
+
if (ciStatus && ciStatus.status !== 'no_checks') {
|
|
629
|
+
// CI checks exist (pending, success, failure, etc.) — the "no runs" counter is irrelevant
|
|
630
|
+
consecutiveNoRunsChecks = 0;
|
|
631
|
+
} else if (noCiConfigured || noCiTriggered) {
|
|
632
|
+
// CI was definitively determined: either not configured or not triggered.
|
|
633
|
+
// Keep the counter as-is (it reached the safety valve or wasn't needed).
|
|
634
|
+
}
|
|
559
635
|
|
|
560
636
|
// Check for new comments from non-bot users
|
|
561
637
|
const { hasNewComments, comments } = await checkForNonBotComments(owner, repo, prNumber, issueNumber, lastCheckTime, argv.verbose);
|
|
@@ -576,6 +652,54 @@ export const watchUntilMergeable = async params => {
|
|
|
576
652
|
|
|
577
653
|
// If PR is mergeable, no blockers, no new comments, and no uncommitted changes
|
|
578
654
|
if (blockers.length === 0 && !hasNewComments && !hasUncommittedChanges) {
|
|
655
|
+
// Issue #1503 (enhanced): Multi-mechanism consensus + repo-wide action check.
|
|
656
|
+
// Before declaring PR mergeable, run multiple independent CI detection mechanisms
|
|
657
|
+
// and require all to agree. This catches race conditions where CI starts between
|
|
658
|
+
// checks or where interacting CI/CD pipelines affect mergeability.
|
|
659
|
+
if (!noCiConfigured) {
|
|
660
|
+
const DOUBLE_CHECK_DELAY_MS = 10000; // 10 seconds
|
|
661
|
+
await log(formatAligned('🔍', 'Multi-mechanism CI consensus check:', `Waiting ${DOUBLE_CHECK_DELAY_MS / 1000}s then verifying...`, 2));
|
|
662
|
+
await new Promise(resolve => setTimeout(resolve, DOUBLE_CHECK_DELAY_MS));
|
|
663
|
+
|
|
664
|
+
// Run multi-mechanism consensus: Check Runs API + Workflow Runs API + Repo-wide actions
|
|
665
|
+
const consensus = await checkCIConsensus({
|
|
666
|
+
owner,
|
|
667
|
+
repo,
|
|
668
|
+
prNumber,
|
|
669
|
+
sha: currentHeadSha || ciStatus?.sha,
|
|
670
|
+
waitForAllRepoActionsFlag,
|
|
671
|
+
verbose: argv.verbose,
|
|
672
|
+
getDetailedCIStatus,
|
|
673
|
+
getWorkflowRunsForSha,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
if (!consensus.allAgree) {
|
|
677
|
+
const m = consensus.mechanisms;
|
|
678
|
+
await log(formatAligned('🔄', 'CI mechanisms DISAGREE:', `CheckRuns=${m.checkRunsAPI.status}, WorkflowRuns=${m.workflowRunsAPI.inProgress} in-progress, RepoActions=${m.repoActions.skipped ? 'skipped' : m.repoActions.count + ' active'}`, 2));
|
|
679
|
+
await log(formatAligned('⏳', 'Continuing to monitor...', 'Mechanisms must agree before declaring mergeable', 2));
|
|
680
|
+
consecutiveNoRunsChecks = 0;
|
|
681
|
+
lastCheckTime = currentTime;
|
|
682
|
+
const actualWaitSeconds = currentBackoffSeconds;
|
|
683
|
+
await log(formatAligned('⏱️', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
|
|
684
|
+
await log('');
|
|
685
|
+
await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
await log(formatAligned('✅', 'All CI mechanisms agree:', `CheckRuns=${consensus.mechanisms.checkRunsAPI.status}, WorkflowRuns=complete(${consensus.mechanisms.workflowRunsAPI.total}), RepoActions=${consensus.mechanisms.repoActions.skipped ? 'skipped' : 'clear'}`, 2));
|
|
689
|
+
} else if (waitForAllRepoActionsFlag) {
|
|
690
|
+
// Even with no CI configured, check repo-wide actions for absolute safety
|
|
691
|
+
const repoRuns = await getAllActiveRepoRuns(owner, repo, argv.verbose);
|
|
692
|
+
if (repoRuns.hasActiveRuns) {
|
|
693
|
+
await log(formatAligned('⏳', 'Waiting for repo-wide actions:', `${repoRuns.count} active run(s) in repository`, 2));
|
|
694
|
+
lastCheckTime = currentTime;
|
|
695
|
+
const actualWaitSeconds = currentBackoffSeconds;
|
|
696
|
+
await log(formatAligned('⏱️', 'Next check in:', `${actualWaitSeconds} seconds...`, 2));
|
|
697
|
+
await log('');
|
|
698
|
+
await new Promise(resolve => setTimeout(resolve, actualWaitSeconds * 1000));
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
579
703
|
await log(formatAligned('✅', 'PR IS MERGEABLE!', ''));
|
|
580
704
|
|
|
581
705
|
if (isAutoMerge) {
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -186,6 +186,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
186
186
|
description: 'Auto-restart until PR becomes mergeable (no iteration limit). Restarts on new comments from non-bot users, CI failures, merge conflicts, or other issues. Does NOT auto-merge.',
|
|
187
187
|
default: true,
|
|
188
188
|
},
|
|
189
|
+
'wait-for-all-actions-in-repository-before-mergable': {
|
|
190
|
+
type: 'boolean',
|
|
191
|
+
description: 'Wait for ALL active GitHub Actions workflow runs in the entire repository to complete before declaring PR mergeable. Provides absolute safety against interacting CI/CD pipelines. Enabled by default.',
|
|
192
|
+
default: true,
|
|
193
|
+
},
|
|
189
194
|
'auto-restart-on-non-updated-pull-request-description': {
|
|
190
195
|
type: 'boolean',
|
|
191
196
|
description: 'Automatically restart if PR title or description still contains auto-generated placeholder text after agent execution. Restarts with a hint about what was not updated.',
|