@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.
- package/CHANGELOG.md +27 -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/github-merge-ci.lib.mjs +4 -1
- package/src/github-merge.lib.mjs +2 -2
- 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/src/telegram-merge-command.lib.mjs +5 -3
- package/src/telegram-merge-queue.lib.mjs +4 -0
|
@@ -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,
|
|
36
|
+
const { checkPRMergeable, checkMergePermissions, mergePullRequest, waitForCI, getRepoVisibility, BILLING_LIMIT_ERROR_PATTERN, getDetailedCIStatus, rerunWorkflowRun, getWorkflowRunsForSha, getAllActiveRepoRuns, checkCIConsensus } = githubMergeLib;
|
|
37
37
|
|
|
38
38
|
// Import GitHub functions for log attachment
|
|
39
39
|
const githubLib = await import('./github.lib.mjs');
|
|
@@ -50,457 +50,9 @@ const { calculateWaitTime } = validation;
|
|
|
50
50
|
// Import configuration (used for limit reset buffer and jitter)
|
|
51
51
|
import { limitReset } from './config.lib.mjs';
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
* @param {string} owner - Repository owner
|
|
57
|
-
* @param {string} repo - Repository name
|
|
58
|
-
* @param {number} prNumber - Pull request number
|
|
59
|
-
* @param {string} commentSignature - Unique signature to search for in comment body (e.g., "✅ Ready to merge")
|
|
60
|
-
* @param {boolean} verbose - Enable verbose logging
|
|
61
|
-
* @returns {Promise<boolean>} - True if a matching comment already exists
|
|
62
|
-
*/
|
|
63
|
-
const checkForExistingComment = async (owner, repo, prNumber, commentSignature, verbose = false) => {
|
|
64
|
-
try {
|
|
65
|
-
// Fetch recent PR comments (last 20 to avoid fetching entire history)
|
|
66
|
-
const result = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --jq '.[].body' 2>/dev/null`;
|
|
67
|
-
if (result.code === 0 && result.stdout) {
|
|
68
|
-
const bodies = result.stdout.toString();
|
|
69
|
-
const hasMatch = bodies.includes(commentSignature);
|
|
70
|
-
if (verbose && hasMatch) {
|
|
71
|
-
console.log(`[VERBOSE] Found existing comment with signature: "${commentSignature}"`);
|
|
72
|
-
}
|
|
73
|
-
return hasMatch;
|
|
74
|
-
}
|
|
75
|
-
} catch (error) {
|
|
76
|
-
// If check fails, allow posting to avoid silent failures
|
|
77
|
-
if (verbose) {
|
|
78
|
-
console.log(`[VERBOSE] Failed to check for existing comment: ${error.message}`);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
return false;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Check for new comments from non-bot users since last commit
|
|
86
|
-
* @returns {Promise<{hasNewComments: boolean, comments: Array}>}
|
|
87
|
-
*/
|
|
88
|
-
const checkForNonBotComments = async (owner, repo, prNumber, issueNumber, lastCheckTime, verbose = false) => {
|
|
89
|
-
try {
|
|
90
|
-
// Get current GitHub user to identify which comments are from the bot/hive-mind
|
|
91
|
-
let currentUser = null;
|
|
92
|
-
try {
|
|
93
|
-
const userResult = await $`gh api user --jq .login`;
|
|
94
|
-
if (userResult.code === 0) {
|
|
95
|
-
currentUser = userResult.stdout.toString().trim();
|
|
96
|
-
}
|
|
97
|
-
} catch {
|
|
98
|
-
// If we can't get the current user, continue without filtering
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Common bot usernames and patterns to filter out
|
|
102
|
-
// Note: Patterns use word boundaries or end-of-string to avoid false positives
|
|
103
|
-
// (e.g., "claudeuser" should NOT match as a bot)
|
|
104
|
-
const botPatterns = [
|
|
105
|
-
/\[bot\]$/i, // Any username ending with [bot]
|
|
106
|
-
/^github-actions$/i, // GitHub Actions
|
|
107
|
-
/^dependabot$/i, // Dependabot
|
|
108
|
-
/^renovate$/i, // Renovate
|
|
109
|
-
/^codecov$/i, // Codecov
|
|
110
|
-
/^netlify$/i, // Netlify
|
|
111
|
-
/^vercel$/i, // Vercel
|
|
112
|
-
/^hive-?mind$/i, // Hive Mind (with or without hyphen)
|
|
113
|
-
/^claude$/i, // Claude (exact match only)
|
|
114
|
-
/^copilot$/i, // GitHub Copilot
|
|
115
|
-
];
|
|
116
|
-
|
|
117
|
-
const isBot = login => {
|
|
118
|
-
if (!login) return false;
|
|
119
|
-
// Check if it's the current user (the bot running hive-mind)
|
|
120
|
-
if (currentUser && login === currentUser) return true;
|
|
121
|
-
// Check against known bot patterns
|
|
122
|
-
return botPatterns.some(pattern => pattern.test(login));
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
// Fetch PR conversation comments
|
|
126
|
-
const prCommentsResult = await $`gh api repos/${owner}/${repo}/issues/${prNumber}/comments --paginate`;
|
|
127
|
-
let prComments = [];
|
|
128
|
-
if (prCommentsResult.code === 0 && prCommentsResult.stdout) {
|
|
129
|
-
prComments = JSON.parse(prCommentsResult.stdout.toString() || '[]');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Fetch PR review comments (inline code comments)
|
|
133
|
-
const prReviewCommentsResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber}/comments --paginate`;
|
|
134
|
-
let prReviewComments = [];
|
|
135
|
-
if (prReviewCommentsResult.code === 0 && prReviewCommentsResult.stdout) {
|
|
136
|
-
prReviewComments = JSON.parse(prReviewCommentsResult.stdout.toString() || '[]');
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Fetch issue comments if we have an issue number
|
|
140
|
-
let issueComments = [];
|
|
141
|
-
if (issueNumber && issueNumber !== prNumber) {
|
|
142
|
-
const issueCommentsResult = await $`gh api repos/${owner}/${repo}/issues/${issueNumber}/comments --paginate`;
|
|
143
|
-
if (issueCommentsResult.code === 0 && issueCommentsResult.stdout) {
|
|
144
|
-
issueComments = JSON.parse(issueCommentsResult.stdout.toString() || '[]');
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Combine all comments
|
|
149
|
-
const allComments = [...prComments, ...prReviewComments, ...issueComments];
|
|
150
|
-
|
|
151
|
-
// Filter for new comments from non-bot users
|
|
152
|
-
const newNonBotComments = allComments.filter(comment => {
|
|
153
|
-
const commentTime = new Date(comment.created_at);
|
|
154
|
-
const isAfterLastCheck = commentTime > lastCheckTime;
|
|
155
|
-
const isFromNonBot = !isBot(comment.user?.login);
|
|
156
|
-
|
|
157
|
-
if (verbose && isAfterLastCheck && isFromNonBot) {
|
|
158
|
-
console.log(`[VERBOSE] New non-bot comment from ${comment.user?.login} at ${comment.created_at}`);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return isAfterLastCheck && isFromNonBot;
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
return {
|
|
165
|
-
hasNewComments: newNonBotComments.length > 0,
|
|
166
|
-
comments: newNonBotComments,
|
|
167
|
-
};
|
|
168
|
-
} catch (error) {
|
|
169
|
-
reportError(error, {
|
|
170
|
-
context: 'check_non_bot_comments',
|
|
171
|
-
owner,
|
|
172
|
-
repo,
|
|
173
|
-
prNumber,
|
|
174
|
-
operation: 'fetch_comments',
|
|
175
|
-
});
|
|
176
|
-
return { hasNewComments: false, comments: [] };
|
|
177
|
-
}
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Get the reasons why PR is not mergeable
|
|
182
|
-
* Issue #1314: Comprehensive CI/CD status handling covering all possible states:
|
|
183
|
-
* - success: All CI passed → no blocker
|
|
184
|
-
* - failure: Genuine code failures → restart AI
|
|
185
|
-
* - cancelled: Manually cancelled or workflow cancelled → re-trigger, don't restart AI
|
|
186
|
-
* - pending/queued: Still running or waiting for runner → wait, don't restart AI
|
|
187
|
-
* - billing_limit: Billing/spending limit reached → stop (private) or wait (public)
|
|
188
|
-
* - no_checks: No CI checks yet (race condition) → wait
|
|
189
|
-
*/
|
|
190
|
-
const getMergeBlockers = async (owner, repo, prNumber, verbose = false, checkCount = 1, prBranchRef = null) => {
|
|
191
|
-
const blockers = [];
|
|
192
|
-
|
|
193
|
-
// Use detailed CI status to distinguish between all possible states
|
|
194
|
-
const ciStatus = await getDetailedCIStatus(owner, repo, prNumber, verbose);
|
|
195
|
-
|
|
196
|
-
if (ciStatus.status === 'no_checks') {
|
|
197
|
-
// No CI checks exist yet - this could be:
|
|
198
|
-
// 1. A race condition after push (checks haven't started yet) - wait
|
|
199
|
-
// 2. A repository with no CI/CD configured at all - should be mergeable immediately
|
|
200
|
-
// 3. CI workflows exist but were not triggered for this commit (fork PR, paths-ignore, etc.)
|
|
201
|
-
//
|
|
202
|
-
// Issue #1345: Distinguish by checking the PR's mergeability status.
|
|
203
|
-
// If GitHub says the PR is MERGEABLE (mergeStateStatus === 'CLEAN'),
|
|
204
|
-
// then no CI is required and we should not block indefinitely.
|
|
205
|
-
// Otherwise (e.g. mergeStateStatus === 'BLOCKED'), treat as pending race condition.
|
|
206
|
-
const earlyMergeStatus = await checkPRMergeable(owner, repo, prNumber, verbose);
|
|
207
|
-
if (earlyMergeStatus.mergeable) {
|
|
208
|
-
// Issue #1363: Before concluding "no CI configured", verify the repo actually
|
|
209
|
-
// has no active GitHub Actions workflows. If workflows exist but no checks have
|
|
210
|
-
// started yet, this is a race condition (GitHub takes ~10-30s to register checks
|
|
211
|
-
// after a push), NOT a "no CI configured" situation.
|
|
212
|
-
//
|
|
213
|
-
// This fixes a false positive where a repo with CI workflows but WITHOUT branch
|
|
214
|
-
// protection (required status checks) would be declared "no CI configured" because:
|
|
215
|
-
// - mergeStateStatus=CLEAN (no required checks to block it)
|
|
216
|
-
// - check_runs=[] (CI hasn't started yet — race condition)
|
|
217
|
-
const repoWorkflows = await getActiveRepoWorkflows(owner, repo, verbose);
|
|
218
|
-
if (repoWorkflows.hasWorkflows) {
|
|
219
|
-
// Repo HAS workflows — but were they triggered for this commit?
|
|
220
|
-
// Issue #1442: Use the GitHub Actions workflow runs API to definitively check
|
|
221
|
-
// if any workflow runs were triggered for this PR's HEAD SHA. This avoids
|
|
222
|
-
// the need for timeout-based detection:
|
|
223
|
-
// - workflow_runs.length > 0 → genuine race condition (CI started, check-runs not yet registered)
|
|
224
|
-
// - workflow_runs.length === 0 → CI was NOT triggered (fork PR, paths-ignore, etc.)
|
|
225
|
-
const workflowRuns = await getWorkflowRunsForSha(owner, repo, ciStatus.sha, verbose);
|
|
226
|
-
if (workflowRuns.length > 0) {
|
|
227
|
-
// Issue #1466: Check if ALL workflow runs are completed without producing check-runs.
|
|
228
|
-
// This happens when workflows require manual approval (first-time fork contributors,
|
|
229
|
-
// deployment approvals) — they complete with conclusion=action_required but never
|
|
230
|
-
// create check-runs. Waiting for check-runs in this case is an infinite loop.
|
|
231
|
-
//
|
|
232
|
-
// Also covers other non-executing conclusions: cancelled, stale workflows that
|
|
233
|
-
// completed without producing check-runs won't produce them in the future either.
|
|
234
|
-
const allRunsCompleted = workflowRuns.every(r => r.status === 'completed');
|
|
235
|
-
const allRunsNonExecuting = allRunsCompleted && workflowRuns.every(r => r.conclusion === 'action_required' || r.conclusion === 'cancelled' || r.conclusion === 'stale' || r.conclusion === 'skipped');
|
|
236
|
-
|
|
237
|
-
if (allRunsNonExecuting) {
|
|
238
|
-
// All workflow runs completed without executing jobs — check-runs will never appear.
|
|
239
|
-
// Treat the same as "CI not triggered" to avoid infinite waiting.
|
|
240
|
-
const conclusions = [...new Set(workflowRuns.map(r => r.conclusion))].join(', ');
|
|
241
|
-
if (verbose) {
|
|
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
|
-
}
|
|
244
|
-
await log(formatAligned('ℹ️', 'CI workflows completed without executing:', `${conclusions} (${workflowRuns.map(r => r.name).join(', ')})`, 2));
|
|
245
|
-
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true, workflowRunConclusions: conclusions };
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Some workflow runs are still in progress or produced results — genuine race condition
|
|
249
|
-
if (verbose) {
|
|
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
|
-
}
|
|
252
|
-
blockers.push({
|
|
253
|
-
type: 'ci_pending',
|
|
254
|
-
message: `CI/CD checks have not started yet (${workflowRuns.length} workflow run(s) triggered, waiting for check-runs to appear)`,
|
|
255
|
-
details: workflowRuns.map(r => r.name),
|
|
256
|
-
});
|
|
257
|
-
} else {
|
|
258
|
-
// No workflow runs for this SHA — but this could be a race condition!
|
|
259
|
-
// Issue #1480: GitHub Actions workflow runs take 30-120 seconds to appear in the
|
|
260
|
-
// API after a push. The previous fix (issue #1442) assumed 0 workflow runs meant
|
|
261
|
-
// "CI definitively NOT triggered", but this caused false positive "Ready to merge"
|
|
262
|
-
// when checked too soon after a push.
|
|
263
|
-
//
|
|
264
|
-
// Multi-layer defense (Issue #1480 enhanced):
|
|
265
|
-
// Layer 1: Grace period — check commit age
|
|
266
|
-
// Layer 2: Workflow file parsing — check .github/workflows for PR triggers
|
|
267
|
-
// Layer 3: Previous commit CI history — check if earlier PR commits had CI runs
|
|
268
|
-
const WORKFLOW_RUN_GRACE_PERIOD_SECONDS = 120; // 2 minutes — generous to cover slow GitHub API registration
|
|
269
|
-
const commitInfo = await getCommitDate(owner, repo, ciStatus.sha, verbose);
|
|
270
|
-
|
|
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, prBranchRef);
|
|
273
|
-
|
|
274
|
-
// Issue #1480: If .github/workflows folder doesn't exist or has no workflow files,
|
|
275
|
-
// that's a definitive signal — no CI/CD will execute, skip grace period entirely
|
|
276
|
-
if (!prTriggers.hasWorkflowFiles) {
|
|
277
|
-
if (verbose) {
|
|
278
|
-
await log(`[VERBOSE] /merge: PR #${prNumber} repo has no workflow files in .github/workflows/ — CI definitively not configured at file level`);
|
|
279
|
-
}
|
|
280
|
-
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (prTriggers.hasPRTriggers) {
|
|
284
|
-
// Issue #1480 (enhanced): Workflows have PR/push triggers but no runs yet.
|
|
285
|
-
// This is almost certainly a race condition — GitHub takes 30-120s to register
|
|
286
|
-
// workflow runs after a push. We MUST wait regardless of commit age, because
|
|
287
|
-
// commit date reflects authoring time, NOT push time.
|
|
288
|
-
//
|
|
289
|
-
// The commit may have been authored hours ago but pushed just now (rebased branches,
|
|
290
|
-
// amended commits, cherry-picks). Using commit age as a proxy for push age caused
|
|
291
|
-
// false positives in Case 1 of Issue #1480.
|
|
292
|
-
//
|
|
293
|
-
// Safety valve: after MAX_NO_RUNS_CHECKS consecutive checks (typically 5 × 60s = 5 min),
|
|
294
|
-
// conclude CI was not triggered. This handles cases like paths-ignore excluding all
|
|
295
|
-
// changed files, conditional workflows that don't match, etc.
|
|
296
|
-
const MAX_NO_RUNS_CHECKS = 5;
|
|
297
|
-
if (checkCount >= MAX_NO_RUNS_CHECKS) {
|
|
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
|
|
316
|
-
if (verbose) {
|
|
317
|
-
await log(formatAligned('ℹ️', 'CI not triggered:', `No workflow runs after ${checkCount} consecutive checks — concluding CI was not triggered`, 2));
|
|
318
|
-
}
|
|
319
|
-
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
if (verbose) {
|
|
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));
|
|
324
|
-
}
|
|
325
|
-
blockers.push({
|
|
326
|
-
type: 'ci_pending',
|
|
327
|
-
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})`,
|
|
328
|
-
details: prTriggers.workflows.map(w => w.name),
|
|
329
|
-
});
|
|
330
|
-
} else if (commitInfo.ageSeconds !== null && commitInfo.ageSeconds < WORKFLOW_RUN_GRACE_PERIOD_SECONDS) {
|
|
331
|
-
// No PR triggers found in workflow files, but commit is still recent — be safe and wait
|
|
332
|
-
if (verbose) {
|
|
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`);
|
|
334
|
-
}
|
|
335
|
-
blockers.push({
|
|
336
|
-
type: 'ci_pending',
|
|
337
|
-
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)`,
|
|
338
|
-
details: [],
|
|
339
|
-
});
|
|
340
|
-
} else {
|
|
341
|
-
// No PR triggers AND commit is old enough — CI was definitively NOT triggered
|
|
342
|
-
// Issue #1442: Fork PRs needing maintainer approval, paths-ignore filtering,
|
|
343
|
-
// workflow conditions not matching, etc. all result in zero workflow runs.
|
|
344
|
-
if (verbose) {
|
|
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`);
|
|
346
|
-
}
|
|
347
|
-
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: true };
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
} else {
|
|
351
|
-
// Repo has NO workflows — this is truly "no CI configured"
|
|
352
|
-
// PR is already mergeable with no CI checks configured.
|
|
353
|
-
// Do NOT add a ci_pending blocker. The mergeability check below will also
|
|
354
|
-
// confirm this is mergeable, so blockers will be empty → PR IS MERGEABLE path.
|
|
355
|
-
if (verbose) {
|
|
356
|
-
await log(`[VERBOSE] /merge: PR #${prNumber} has no CI checks and repo has no active workflows - no CI/CD configured`);
|
|
357
|
-
}
|
|
358
|
-
// Return early with no CI blocker, mergeability already confirmed
|
|
359
|
-
return { blockers, ciStatus, noCiConfigured: true };
|
|
360
|
-
}
|
|
361
|
-
} else {
|
|
362
|
-
// PR is not yet mergeable despite no checks - treat as pending race condition
|
|
363
|
-
blockers.push({
|
|
364
|
-
type: 'ci_pending',
|
|
365
|
-
message: 'CI/CD checks have not started yet (waiting for checks to appear)',
|
|
366
|
-
details: [],
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
} else if (ciStatus.status === 'success') {
|
|
370
|
-
// Issue #1480: Cross-validate "success" with workflow runs API.
|
|
371
|
-
// A fast external check (e.g., CodeFactor) can register and pass before the main CI
|
|
372
|
-
// pipeline starts, causing getDetailedCIStatus to return 'success' prematurely.
|
|
373
|
-
// We must verify that all expected workflow runs have actually completed.
|
|
374
|
-
const workflowRuns = await getWorkflowRunsForSha(owner, repo, ciStatus.sha, verbose);
|
|
375
|
-
|
|
376
|
-
if (workflowRuns.length > 0) {
|
|
377
|
-
// Workflow runs exist — check if any are still running
|
|
378
|
-
const incompleteRuns = workflowRuns.filter(r => r.status !== 'completed');
|
|
379
|
-
if (incompleteRuns.length > 0) {
|
|
380
|
-
// Some workflow runs are still in progress — more check-runs may appear
|
|
381
|
-
if (verbose) {
|
|
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`);
|
|
383
|
-
}
|
|
384
|
-
blockers.push({
|
|
385
|
-
type: 'ci_pending',
|
|
386
|
-
message: `CI checks show success (${ciStatus.passedChecks.length} passed) but ${incompleteRuns.length} workflow run(s) still in progress — waiting for all to complete`,
|
|
387
|
-
details: incompleteRuns.map(r => r.name),
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
// All workflow runs completed — the check-runs we see are the final set, trust the 'success' status
|
|
391
|
-
} else {
|
|
392
|
-
// No workflow runs for this SHA — the passed checks are from external services only
|
|
393
|
-
// (e.g., CodeFactor, Codecov). Check if the repo has workflows that should produce runs.
|
|
394
|
-
const repoWorkflows = await getActiveRepoWorkflows(owner, repo, verbose);
|
|
395
|
-
if (repoWorkflows.hasWorkflows) {
|
|
396
|
-
const prTriggers = await checkWorkflowsHavePRTriggers(owner, repo, verbose, prBranchRef);
|
|
397
|
-
if (prTriggers.hasPRTriggers) {
|
|
398
|
-
// Repo has workflows with PR triggers but no runs yet — CI hasn't started
|
|
399
|
-
// This is the exact scenario from Case 2 of Issue #1480
|
|
400
|
-
//
|
|
401
|
-
// Safety valve: after MAX_NO_RUNS_CHECKS consecutive checks, trust the external checks
|
|
402
|
-
const MAX_NO_RUNS_CHECKS = 5;
|
|
403
|
-
if (checkCount >= MAX_NO_RUNS_CHECKS) {
|
|
404
|
-
if (verbose) {
|
|
405
|
-
await log(`[VERBOSE] /merge: PR #${prNumber} CI 'success' with ${ciStatus.passedChecks.length} external checks, no workflow runs after ${checkCount} checks — trusting external checks`);
|
|
406
|
-
}
|
|
407
|
-
// Fall through — trust the success status from external checks
|
|
408
|
-
} else {
|
|
409
|
-
if (verbose) {
|
|
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})`);
|
|
411
|
-
}
|
|
412
|
-
// Wait for GitHub Actions to register workflow runs
|
|
413
|
-
blockers.push({
|
|
414
|
-
type: 'ci_pending',
|
|
415
|
-
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})`,
|
|
416
|
-
details: prTriggers.workflows.map(w => w.name),
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
// No repo workflows → external checks are the only CI, trust the 'success' status
|
|
422
|
-
}
|
|
423
|
-
} else if (ciStatus.status === 'pending') {
|
|
424
|
-
// CI is still running or queued - wait for completion
|
|
425
|
-
const pendingNames = [...ciStatus.pendingChecks, ...ciStatus.queuedChecks].map(c => c.name);
|
|
426
|
-
blockers.push({
|
|
427
|
-
type: 'ci_pending',
|
|
428
|
-
message: 'CI/CD checks are still running or queued',
|
|
429
|
-
details: pendingNames,
|
|
430
|
-
});
|
|
431
|
-
} else if (ciStatus.status === 'cancelled') {
|
|
432
|
-
// All non-passed checks are cancelled or stale (no genuine failures)
|
|
433
|
-
// First check if this is actually a billing limit issue (billing-limited jobs may appear as cancelled)
|
|
434
|
-
const billingCheck = await checkForBillingLimitError(owner, repo, prNumber, verbose);
|
|
435
|
-
if (billingCheck.isBillingLimitError) {
|
|
436
|
-
blockers.push({
|
|
437
|
-
type: 'billing_limit',
|
|
438
|
-
message: 'GitHub Actions billing/spending limit reached',
|
|
439
|
-
details: billingCheck.affectedJobs,
|
|
440
|
-
allJobsAffected: billingCheck.allJobsAffected,
|
|
441
|
-
billingMessage: billingCheck.message,
|
|
442
|
-
});
|
|
443
|
-
} else {
|
|
444
|
-
// These need to be re-triggered, NOT treated as AI-fixable failures
|
|
445
|
-
const cancelledOrStaleChecks = [...ciStatus.cancelledChecks, ...(ciStatus.staleChecks || [])];
|
|
446
|
-
blockers.push({
|
|
447
|
-
type: 'ci_cancelled',
|
|
448
|
-
message: 'CI/CD checks were cancelled or became stale',
|
|
449
|
-
details: cancelledOrStaleChecks.map(c => c.name),
|
|
450
|
-
sha: ciStatus.sha,
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
} else if (ciStatus.status === 'failure') {
|
|
454
|
-
// Some checks genuinely failed - check if it's billing limits first
|
|
455
|
-
const billingCheck = await checkForBillingLimitError(owner, repo, prNumber, verbose);
|
|
456
|
-
|
|
457
|
-
if (billingCheck.isBillingLimitError) {
|
|
458
|
-
blockers.push({
|
|
459
|
-
type: 'billing_limit',
|
|
460
|
-
message: 'GitHub Actions billing/spending limit reached',
|
|
461
|
-
details: billingCheck.affectedJobs,
|
|
462
|
-
allJobsAffected: billingCheck.allJobsAffected,
|
|
463
|
-
billingMessage: billingCheck.message,
|
|
464
|
-
});
|
|
465
|
-
} else {
|
|
466
|
-
// Check if there are also cancelled/stale checks alongside failures
|
|
467
|
-
const cancelledOrStaleChecks = [...(ciStatus.hasCancelled ? ciStatus.cancelledChecks : []), ...((ciStatus.hasStale && ciStatus.staleChecks) || [])];
|
|
468
|
-
if (cancelledOrStaleChecks.length > 0) {
|
|
469
|
-
blockers.push({
|
|
470
|
-
type: 'ci_cancelled',
|
|
471
|
-
message: 'Some CI/CD checks were cancelled or became stale (will be re-triggered)',
|
|
472
|
-
details: cancelledOrStaleChecks.map(c => c.name),
|
|
473
|
-
sha: ciStatus.sha,
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
blockers.push({
|
|
477
|
-
type: 'ci_failure',
|
|
478
|
-
message: 'CI/CD checks are failing',
|
|
479
|
-
details: ciStatus.failedChecks.map(c => c.name),
|
|
480
|
-
});
|
|
481
|
-
}
|
|
482
|
-
} else if (ciStatus.status === 'unknown') {
|
|
483
|
-
// Unable to determine CI status - treat as pending to be safe
|
|
484
|
-
// Do NOT treat as mergeable (which would be incorrect)
|
|
485
|
-
blockers.push({
|
|
486
|
-
type: 'ci_pending',
|
|
487
|
-
message: 'CI/CD status could not be determined (will retry)',
|
|
488
|
-
details: [],
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Check mergeability
|
|
493
|
-
const mergeStatus = await checkPRMergeable(owner, repo, prNumber, verbose);
|
|
494
|
-
if (!mergeStatus.mergeable) {
|
|
495
|
-
blockers.push({
|
|
496
|
-
type: 'not_mergeable',
|
|
497
|
-
message: mergeStatus.reason || 'PR is not mergeable',
|
|
498
|
-
details: [],
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
return { blockers, ciStatus, noCiConfigured: false, noCiTriggered: false };
|
|
503
|
-
};
|
|
53
|
+
// Import helper functions extracted for file size management (Issue #1593)
|
|
54
|
+
const autoMergeHelpers = await import('./solve.auto-merge-helpers.lib.mjs');
|
|
55
|
+
const { checkForExistingComment, checkForNonBotComments, getMergeBlockers } = autoMergeHelpers;
|
|
504
56
|
|
|
505
57
|
/**
|
|
506
58
|
* Main function: Watch and restart until PR becomes mergeable
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -599,6 +599,10 @@ async function executeAndUpdateMessage(ctx, startingMessage, commandName, args,
|
|
|
599
599
|
result = await executeStartScreen(commandName, args);
|
|
600
600
|
const match = result.success && (result.output.match(/session:\s*(\S+)/i) || result.output.match(/screen -R\s+(\S+)/));
|
|
601
601
|
session = match ? match[1] : 'unknown';
|
|
602
|
+
// Issue #1586: Track non-isolation sessions with timeout-based expiry.
|
|
603
|
+
// These sessions cannot reliably detect completion (screen stays alive via
|
|
604
|
+
// `exec bash`), so hasActiveSessionForUrl() auto-expires them after 10 min.
|
|
605
|
+
// This prevents accidental duplicate commands within the timeout window.
|
|
602
606
|
if (result.success && session !== 'unknown') trackSession(session, { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName }, VERBOSE);
|
|
603
607
|
}
|
|
604
608
|
if (result.warning) return safeEdit(`⚠️ ${result.warning}`);
|
|
@@ -238,11 +238,13 @@ export function registerMergeCommand(bot, options) {
|
|
|
238
238
|
// Update message with progress and cancel button
|
|
239
239
|
try {
|
|
240
240
|
const message = processor.formatProgressMessage();
|
|
241
|
+
// Issue #1588: Do not show cancel button once cancellation has been requested.
|
|
242
|
+
// Without this check, progress updates from CI wait loops would re-add
|
|
243
|
+
// the cancel button after the cancel handler had already removed it.
|
|
244
|
+
const replyMarkup = processor.isCancelled ? undefined : { inline_keyboard: [[{ text: '🛑 Cancel', callback_data: `merge_cancel_${repoKey}` }]] };
|
|
241
245
|
await ctx.telegram.editMessageText(statusMessage.chat.id, statusMessage.message_id, undefined, message, {
|
|
242
246
|
parse_mode: 'MarkdownV2',
|
|
243
|
-
reply_markup: {
|
|
244
|
-
inline_keyboard: [[{ text: '🛑 Cancel', callback_data: `merge_cancel_${repoKey}` }]],
|
|
245
|
-
},
|
|
247
|
+
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
|
246
248
|
});
|
|
247
249
|
} catch (err) {
|
|
248
250
|
// Ignore message edit errors (e.g., message not modified)
|
|
@@ -513,6 +513,8 @@ export class MergeQueueProcessor {
|
|
|
513
513
|
await this.onProgress(this.getProgressUpdate());
|
|
514
514
|
}
|
|
515
515
|
},
|
|
516
|
+
// Issue #1588: Pass cancellation check so branch CI wait can abort early
|
|
517
|
+
isCancelled: () => this.isCancelled,
|
|
516
518
|
},
|
|
517
519
|
this.verbose
|
|
518
520
|
);
|
|
@@ -617,6 +619,8 @@ export class MergeQueueProcessor {
|
|
|
617
619
|
await this.onProgress(this.getProgressUpdate());
|
|
618
620
|
}
|
|
619
621
|
},
|
|
622
|
+
// Issue #1588: Pass cancellation check so post-merge CI wait can abort early
|
|
623
|
+
isCancelled: () => this.isCancelled,
|
|
620
624
|
},
|
|
621
625
|
this.verbose
|
|
622
626
|
);
|