@link-assistant/hive-mind 1.50.2 → 1.50.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 +21 -0
- package/README.hi.md +876 -0
- package/README.md +1 -1
- package/README.ru.md +876 -0
- package/README.zh.md +876 -0
- package/package.json +1 -1
- package/src/session-monitor.lib.mjs +56 -3
- package/src/solve.auto-merge-helpers.lib.mjs +552 -0
- package/src/solve.auto-merge.lib.mjs +4 -452
- package/src/telegram-bot.mjs +4 -0
package/package.json
CHANGED
|
@@ -37,6 +37,19 @@ async function getQuerySessionStatus() {
|
|
|
37
37
|
// In-memory session store
|
|
38
38
|
const activeSessions = new Map();
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Issue #1586: Timeout for non-isolation sessions.
|
|
42
|
+
* Non-isolation (plain start-screen) sessions cannot reliably detect completion
|
|
43
|
+
* because the screen stays alive via `exec bash`. To prevent false positives
|
|
44
|
+
* that permanently block users, non-isolation sessions are auto-expired after
|
|
45
|
+
* this timeout. This still prevents accidental duplicate commands within the
|
|
46
|
+
* timeout window (5-10 minutes).
|
|
47
|
+
*
|
|
48
|
+
* Once --isolation is fully tested and becomes the default, this timeout
|
|
49
|
+
* mechanism will no longer be needed.
|
|
50
|
+
*/
|
|
51
|
+
export const NON_ISOLATION_SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
52
|
+
|
|
40
53
|
/**
|
|
41
54
|
* Check if a screen session exists
|
|
42
55
|
* @param {string} sessionName - Name of the screen session to check
|
|
@@ -190,8 +203,23 @@ export async function monitorSessions(bot, verbose = false) {
|
|
|
190
203
|
exitCode = await getIsolatedSessionExitCode(sessionInfo.sessionId, verbose);
|
|
191
204
|
}
|
|
192
205
|
} else {
|
|
193
|
-
//
|
|
194
|
-
|
|
206
|
+
// Issue #1586: Non-isolation screen sessions cannot reliably detect
|
|
207
|
+
// completion because start-screen keeps the screen alive via `exec bash`.
|
|
208
|
+
// Auto-expire after timeout; within timeout, use screen -ls as best-effort.
|
|
209
|
+
const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
|
|
210
|
+
const elapsed = Date.now() - startTime.getTime();
|
|
211
|
+
if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
|
|
212
|
+
stillRunning = false;
|
|
213
|
+
if (verbose) {
|
|
214
|
+
console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s (timeout: ${NON_ISOLATION_SESSION_TIMEOUT_MS / 1000}s)`);
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
stillRunning = await checkScreenSessionExists(sessionName);
|
|
218
|
+
if (verbose) {
|
|
219
|
+
const remainingSec = Math.round((NON_ISOLATION_SESSION_TIMEOUT_MS - elapsed) / 1000);
|
|
220
|
+
console.log(`[VERBOSE] Non-isolation session ${sessionName}: screen -ls says ${stillRunning ? 'running' : 'not found'} (timeout in ${remainingSec}s)`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
195
223
|
}
|
|
196
224
|
|
|
197
225
|
if (!stillRunning) {
|
|
@@ -250,6 +278,14 @@ export function startSessionMonitoring(bot, verbose = false, intervalMs = 30000)
|
|
|
250
278
|
* inconsistencies when two auto-restart-until-mergeable processes run
|
|
251
279
|
* simultaneously.
|
|
252
280
|
*
|
|
281
|
+
* Issue #1586: Non-isolation sessions (plain start-screen) cannot reliably
|
|
282
|
+
* detect completion because the screen stays alive via `exec bash`. To avoid
|
|
283
|
+
* permanent false positives, non-isolation sessions are auto-expired after
|
|
284
|
+
* NON_ISOLATION_SESSION_TIMEOUT_MS (10 minutes). Within that window they
|
|
285
|
+
* still block duplicate commands for the same URL, which prevents accidental
|
|
286
|
+
* re-runs. Isolation-backed sessions have no timeout since their completion
|
|
287
|
+
* is reliably detected by monitorSessions().
|
|
288
|
+
*
|
|
253
289
|
* @param {string} url - The GitHub URL to check (issue or PR URL)
|
|
254
290
|
* @param {boolean} verbose - Whether to log verbose output
|
|
255
291
|
* @returns {{isActive: boolean, sessionName: string|null}} Whether an active session exists for this URL
|
|
@@ -262,9 +298,26 @@ export function hasActiveSessionForUrl(url, verbose = false) {
|
|
|
262
298
|
const normalizedUrl = normalizeUrl(url);
|
|
263
299
|
|
|
264
300
|
for (const [sessionName, sessionInfo] of activeSessions.entries()) {
|
|
301
|
+
// Issue #1586: Auto-expire non-isolation sessions after timeout
|
|
302
|
+
if (!sessionInfo.isolationBackend) {
|
|
303
|
+
const startTime = sessionInfo.startTime instanceof Date ? sessionInfo.startTime : new Date(sessionInfo.startTime);
|
|
304
|
+
const elapsed = Date.now() - startTime.getTime();
|
|
305
|
+
if (elapsed >= NON_ISOLATION_SESSION_TIMEOUT_MS) {
|
|
306
|
+
if (verbose) {
|
|
307
|
+
console.log(`[VERBOSE] Non-isolation session ${sessionName} expired after ${Math.round(elapsed / 1000)}s (timeout: ${NON_ISOLATION_SESSION_TIMEOUT_MS / 1000}s), removing from tracking`);
|
|
308
|
+
}
|
|
309
|
+
activeSessions.delete(sessionName);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (verbose) {
|
|
313
|
+
const remainingSec = Math.round((NON_ISOLATION_SESSION_TIMEOUT_MS - elapsed) / 1000);
|
|
314
|
+
console.log(`[VERBOSE] Non-isolation session ${sessionName} still within timeout (${remainingSec}s remaining)`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
265
317
|
if (sessionInfo.url && normalizeUrl(sessionInfo.url) === normalizedUrl) {
|
|
266
318
|
if (verbose) {
|
|
267
|
-
|
|
319
|
+
const mode = sessionInfo.isolationBackend ? `isolation:${sessionInfo.isolationBackend}` : 'non-isolation (timeout-based)';
|
|
320
|
+
console.log(`[VERBOSE] Found active session for URL ${url}: ${sessionName} (${mode})`);
|
|
268
321
|
}
|
|
269
322
|
return { isActive: true, sessionName };
|
|
270
323
|
}
|
|
@@ -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
|
+
};
|