@link-assistant/hive-mind 2.7.3 → 2.7.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 +6 -0
- package/package.json +1 -1
- package/src/github-merge-ci-wait.lib.mjs +217 -0
- package/src/github-merge-ci.lib.mjs +10 -3
- package/src/github-merge-repo-actions.lib.mjs +14 -3
- package/src/github-merge.lib.mjs +13 -195
- package/src/interruptible-sleep.lib.mjs +34 -1
- package/src/telegram-merge-queue.lib.mjs +23 -20
- package/src/telegram-merge-wait.lib.mjs +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.7.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 4bf1734: Cancel `/merge` immediately at every stage. Poll delays were uninterruptible `setTimeout` calls, so a cancel was only noticed once the delay expired — up to 5 minutes. Every merge wait now sleeps in short steps and aborts within ~100ms of the Cancel button, including the repo-wide actions wait, which previously had no cancellation support at all.
|
|
8
|
+
|
|
3
9
|
## 2.7.3
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Merge CI Wait Loops
|
|
4
|
+
*
|
|
5
|
+
* The long-running CI polling loops used by the /merge command.
|
|
6
|
+
* Split from github-merge.lib.mjs to maintain file size limits.
|
|
7
|
+
*
|
|
8
|
+
* Every wait here is cancellable: the poll delay is slept in short steps via
|
|
9
|
+
* `cancellableSleep`, so pressing the Cancel button takes effect within ~100ms
|
|
10
|
+
* instead of after a full poll interval.
|
|
11
|
+
*
|
|
12
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2072
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { checkPRCIStatus, getActiveBranchRuns } from './github-merge.lib.mjs';
|
|
16
|
+
import { cancellableSleep } from './interruptible-sleep.lib.mjs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wait for CI/CD to complete with polling
|
|
20
|
+
* @param {string} owner - Repository owner
|
|
21
|
+
* @param {string} repo - Repository name
|
|
22
|
+
* @param {number} prNumber - Pull request number
|
|
23
|
+
* @param {Object} options - Wait options
|
|
24
|
+
* @param {number} options.timeout - Maximum wait time in ms (default: 30 minutes)
|
|
25
|
+
* @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
|
|
26
|
+
* @param {Function} options.onStatusUpdate - Callback for status updates
|
|
27
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
28
|
+
* @returns {Promise<{success: boolean, status: string, error: string|null}>}
|
|
29
|
+
*/
|
|
30
|
+
export async function waitForCI(owner, repo, prNumber, options = {}, verbose = false) {
|
|
31
|
+
const {
|
|
32
|
+
timeout = 30 * 60 * 1000,
|
|
33
|
+
pollInterval = 30 * 1000,
|
|
34
|
+
onStatusUpdate = null,
|
|
35
|
+
// Issue #1269: Add timeout for callback to prevent infinite blocking
|
|
36
|
+
callbackTimeout = 60 * 1000, // 1 minute max for callback
|
|
37
|
+
isCancelled = null, // Issue #1407: Support early exit when cancellation is requested
|
|
38
|
+
} = options;
|
|
39
|
+
|
|
40
|
+
const startTime = Date.now();
|
|
41
|
+
|
|
42
|
+
while (Date.now() - startTime < timeout) {
|
|
43
|
+
// Issue #1407: Check for cancellation before each poll to allow early exit
|
|
44
|
+
if (isCancelled?.()) return { success: false, status: 'cancelled', error: 'Operation was cancelled' };
|
|
45
|
+
|
|
46
|
+
let ciStatus;
|
|
47
|
+
try {
|
|
48
|
+
ciStatus = await checkPRCIStatus(owner, repo, prNumber, verbose);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// Issue #1269: Log and continue on CI check errors instead of crashing
|
|
51
|
+
console.error(`[ERROR] /merge: Error checking CI status for PR #${prNumber}: ${error.message}`);
|
|
52
|
+
verbose && console.error(`[VERBOSE] /merge: CI check error details:`, error);
|
|
53
|
+
// Wait and retry
|
|
54
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (onStatusUpdate) {
|
|
59
|
+
// Issue #1269: Wrap callback with timeout to prevent infinite blocking; #1346: capture and clear timeout handle to prevent dangling timer
|
|
60
|
+
try {
|
|
61
|
+
let callbackTimeoutId;
|
|
62
|
+
await Promise.race([
|
|
63
|
+
onStatusUpdate(ciStatus),
|
|
64
|
+
new Promise((_, reject) => {
|
|
65
|
+
callbackTimeoutId = setTimeout(() => reject(new Error(`Callback timeout after ${callbackTimeout}ms`)), callbackTimeout);
|
|
66
|
+
}),
|
|
67
|
+
]).finally(() => clearTimeout(callbackTimeoutId));
|
|
68
|
+
} catch (callbackError) {
|
|
69
|
+
// Issue #1269: Log callback errors but continue processing
|
|
70
|
+
console.error(`[ERROR] /merge: Status update callback failed for PR #${prNumber}: ${callbackError.message}`);
|
|
71
|
+
verbose && console.error(`[VERBOSE] /merge: Callback error details:`, callbackError);
|
|
72
|
+
// Continue processing even if callback fails - don't let UI issues block merging
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (ciStatus.status === 'success') {
|
|
77
|
+
return { success: true, status: 'success', error: null };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (ciStatus.status === 'failure') {
|
|
81
|
+
return { success: false, status: 'failure', error: 'CI checks failed' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (ciStatus.status === 'terminal_github_entity_error') {
|
|
85
|
+
return {
|
|
86
|
+
success: false,
|
|
87
|
+
status: 'terminal_github_entity_error',
|
|
88
|
+
error: ciStatus.error || 'GitHub repository, pull request, issue, or branch is no longer accessible',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (ciStatus.status === 'pending') {
|
|
93
|
+
if (verbose) {
|
|
94
|
+
console.log(`[VERBOSE] /merge: Waiting for CI... (${Math.round((Date.now() - startTime) / 1000)}s elapsed)`);
|
|
95
|
+
}
|
|
96
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Unknown status - wait and retry
|
|
101
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { success: false, status: 'timeout', error: 'CI check timeout exceeded' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Wait for all active workflow runs on a branch to complete
|
|
109
|
+
* Issue #1307: Ensures all CI runs on target branch are complete before merging
|
|
110
|
+
* @param {string} owner - Repository owner
|
|
111
|
+
* @param {string} repo - Repository name
|
|
112
|
+
* @param {string} branch - Branch name (default: main)
|
|
113
|
+
* @param {Object} options - Wait options
|
|
114
|
+
* @param {number} options.timeout - Maximum wait time in ms (default: 45 minutes)
|
|
115
|
+
* @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
|
|
116
|
+
* @param {Function} options.onStatusUpdate - Callback for status updates
|
|
117
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
118
|
+
* @returns {Promise<{success: boolean, waitedForRuns: boolean, completedRuns: number, error: string|null}>}
|
|
119
|
+
*/
|
|
120
|
+
export async function waitForBranchCI(owner, repo, branch = 'main', options = {}, verbose = false) {
|
|
121
|
+
const { timeout = 45 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null, isCancelled = null } = options;
|
|
122
|
+
|
|
123
|
+
const startTime = Date.now();
|
|
124
|
+
let totalWaitedRuns = 0;
|
|
125
|
+
|
|
126
|
+
if (verbose) {
|
|
127
|
+
console.log(`[VERBOSE] /merge: Checking for active CI runs on ${owner}/${repo} branch ${branch}...`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
while (Date.now() - startTime < timeout) {
|
|
131
|
+
if (isCancelled?.()) return { success: false, waitedForRuns: totalWaitedRuns > 0, completedRuns: totalWaitedRuns, error: 'Operation was cancelled' };
|
|
132
|
+
let activeRuns;
|
|
133
|
+
try {
|
|
134
|
+
activeRuns = await getActiveBranchRuns(owner, repo, branch, verbose);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
// Log and continue on errors
|
|
137
|
+
console.error(`[ERROR] /merge: Error checking branch CI: ${error.message}`);
|
|
138
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (onStatusUpdate) {
|
|
143
|
+
try {
|
|
144
|
+
await onStatusUpdate({
|
|
145
|
+
hasActiveRuns: activeRuns.hasActiveRuns,
|
|
146
|
+
count: activeRuns.count,
|
|
147
|
+
runs: activeRuns.runs,
|
|
148
|
+
elapsedMs: Date.now() - startTime,
|
|
149
|
+
});
|
|
150
|
+
} catch (callbackError) {
|
|
151
|
+
// Log callback errors but continue
|
|
152
|
+
console.error(`[ERROR] /merge: Status update callback failed: ${callbackError.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!activeRuns.hasActiveRuns) {
|
|
157
|
+
if (verbose) {
|
|
158
|
+
console.log(`[VERBOSE] /merge: No active CI runs on ${branch} branch. Ready to proceed.`);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
success: true,
|
|
162
|
+
waitedForRuns: totalWaitedRuns > 0,
|
|
163
|
+
completedRuns: totalWaitedRuns,
|
|
164
|
+
error: null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
totalWaitedRuns = Math.max(totalWaitedRuns, activeRuns.count);
|
|
169
|
+
|
|
170
|
+
if (verbose) {
|
|
171
|
+
const elapsedSec = Math.round((Date.now() - startTime) / 1000);
|
|
172
|
+
console.log(`[VERBOSE] /merge: Waiting for ${activeRuns.count} active runs on ${branch}... (${elapsedSec}s elapsed)`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Issue #2072: a cancel landing as the timeout expires must still report 'cancelled'
|
|
179
|
+
// rather than falling through to the extra API round-trip below.
|
|
180
|
+
if (isCancelled?.()) return { success: false, waitedForRuns: totalWaitedRuns > 0, completedRuns: totalWaitedRuns, error: 'Operation was cancelled' };
|
|
181
|
+
|
|
182
|
+
// Timeout reached
|
|
183
|
+
// Issue #1722: if the final check throws, do NOT silently report "ready".
|
|
184
|
+
// Treat it the same as still-active (force a timeout failure), so /merge
|
|
185
|
+
// waits/retries instead of merging on top of a still-running CI run.
|
|
186
|
+
let finalCheck;
|
|
187
|
+
try {
|
|
188
|
+
finalCheck = await getActiveBranchRuns(owner, repo, branch, verbose);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return {
|
|
191
|
+
success: false,
|
|
192
|
+
waitedForRuns: true,
|
|
193
|
+
completedRuns: totalWaitedRuns,
|
|
194
|
+
error: `Timeout reached and final CI check failed on ${branch}: ${error.message}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
if (finalCheck.hasActiveRuns) {
|
|
198
|
+
return {
|
|
199
|
+
success: false,
|
|
200
|
+
waitedForRuns: true,
|
|
201
|
+
completedRuns: totalWaitedRuns - finalCheck.count,
|
|
202
|
+
error: `Timeout waiting for ${finalCheck.count} CI runs on ${branch} branch`,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
success: true,
|
|
208
|
+
waitedForRuns: totalWaitedRuns > 0,
|
|
209
|
+
completedRuns: totalWaitedRuns,
|
|
210
|
+
error: null,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default {
|
|
215
|
+
waitForCI,
|
|
216
|
+
waitForBranchCI,
|
|
217
|
+
};
|
|
@@ -12,6 +12,7 @@ import { getWorkflowRunsForSha } from './github-merge.lib.mjs';
|
|
|
12
12
|
import { promisify } from 'util';
|
|
13
13
|
import { exec as execCallback } from 'child_process';
|
|
14
14
|
import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
|
|
15
|
+
import { cancellableSleep } from './interruptible-sleep.lib.mjs';
|
|
15
16
|
|
|
16
17
|
const execRaw = promisify(execCallback);
|
|
17
18
|
// Issue #1726: every gh call must be rate-limit safe.
|
|
@@ -54,7 +55,7 @@ export async function waitForCommitCI(owner, repo, sha, options = {}, verbose =
|
|
|
54
55
|
runs = await getWorkflowRunsForSha(owner, repo, sha, verbose);
|
|
55
56
|
} catch (error) {
|
|
56
57
|
console.error(`[ERROR] /merge: Error checking commit CI: ${error.message}`);
|
|
57
|
-
await
|
|
58
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
58
59
|
continue;
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -71,7 +72,7 @@ export async function waitForCommitCI(owner, repo, sha, options = {}, verbose =
|
|
|
71
72
|
if (verbose) {
|
|
72
73
|
console.log(`[VERBOSE] /merge: No CI runs yet for commit ${sha.substring(0, 7)} (attempt ${noRunsIterations}/${MAX_NO_RUNS_ITERATIONS}). Waiting...`);
|
|
73
74
|
}
|
|
74
|
-
await
|
|
75
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
75
76
|
continue;
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -134,7 +135,13 @@ export async function waitForCommitCI(owner, repo, sha, options = {}, verbose =
|
|
|
134
135
|
console.log(`[VERBOSE] /merge: Waiting for ${inProgressRuns.length} CI run(s) to complete... (${elapsedSec}s elapsed)`);
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
await
|
|
138
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Issue #2072: a cancel that lands while the timeout is expiring must still report
|
|
142
|
+
// 'cancelled' rather than falling through to an extra API round-trip below.
|
|
143
|
+
if (isCancelled?.()) {
|
|
144
|
+
return { success: false, status: 'cancelled', runs: [], failedRuns: [], error: 'Operation was cancelled' };
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
// Timeout reached
|
|
@@ -13,6 +13,7 @@ import { promisify } from 'util';
|
|
|
13
13
|
import { exec as execCallback } from 'child_process';
|
|
14
14
|
import { githubLimits } from './config.lib.mjs';
|
|
15
15
|
import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
|
|
16
|
+
import { cancellableSleep } from './interruptible-sleep.lib.mjs';
|
|
16
17
|
const execRaw = promisify(execCallback);
|
|
17
18
|
// Issue #1722: raise exec maxBuffer above Node's 1 MB default for paginated gh
|
|
18
19
|
// API responses (workflow runs can easily exceed that on busy repos).
|
|
@@ -70,15 +71,21 @@ export async function getAllActiveRepoRuns(owner, repo, verbose = false) {
|
|
|
70
71
|
* @param {string} owner - Repository owner
|
|
71
72
|
* @param {string} repo - Repository name
|
|
72
73
|
* @param {Object} options - Wait options (timeout, pollInterval, onStatusUpdate)
|
|
74
|
+
* @param {Function} [options.isCancelled] - Issue #2072: polled during the poll delay so a
|
|
75
|
+
* cancel (or SIGINT/SIGTERM) aborts the wait instead of sleeping out a full 5-minute interval
|
|
73
76
|
* @param {boolean} verbose - Whether to log verbose output
|
|
74
77
|
* @returns {Promise<{success: boolean, waitedForRuns: boolean, timedOut: boolean, remainingRuns: Array}>}
|
|
75
78
|
*/
|
|
76
79
|
export async function waitForAllRepoActions(owner, repo, options = {}, verbose = false) {
|
|
77
|
-
const { timeout = 45 * 60 * 1000, pollInterval = 5 * 60 * 1000, onStatusUpdate = null } = options;
|
|
80
|
+
const { timeout = 45 * 60 * 1000, pollInterval = 5 * 60 * 1000, onStatusUpdate = null, isCancelled = null } = options;
|
|
78
81
|
const startTime = Date.now();
|
|
79
82
|
let peakRunCount = 0;
|
|
80
83
|
|
|
81
84
|
while (Date.now() - startTime < timeout) {
|
|
85
|
+
// Issue #2072: no stage of the merge flow may hold up a cancel.
|
|
86
|
+
if (isCancelled?.()) {
|
|
87
|
+
return { success: false, waitedForRuns: peakRunCount > 0, timedOut: false, cancelled: true, remainingRuns: [] };
|
|
88
|
+
}
|
|
82
89
|
let active;
|
|
83
90
|
try {
|
|
84
91
|
active = await getAllActiveRepoRuns(owner, repo, verbose);
|
|
@@ -86,7 +93,7 @@ export async function waitForAllRepoActions(owner, repo, options = {}, verbose =
|
|
|
86
93
|
// Issue #1722: do not silently treat fetch errors as "no active runs".
|
|
87
94
|
// Log and retry on the next poll instead.
|
|
88
95
|
console.error(`[ERROR] repo-actions: Error checking repo CI: ${error.message}`);
|
|
89
|
-
await
|
|
96
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
90
97
|
continue;
|
|
91
98
|
}
|
|
92
99
|
if (onStatusUpdate) {
|
|
@@ -100,7 +107,11 @@ export async function waitForAllRepoActions(owner, repo, options = {}, verbose =
|
|
|
100
107
|
return { success: true, waitedForRuns: peakRunCount > 0, timedOut: false, remainingRuns: [] };
|
|
101
108
|
}
|
|
102
109
|
peakRunCount = Math.max(peakRunCount, active.count);
|
|
103
|
-
await
|
|
110
|
+
await cancellableSleep(pollInterval, isCancelled);
|
|
111
|
+
}
|
|
112
|
+
// Issue #2072: a cancel landing as the timeout expires must not trigger another API round-trip.
|
|
113
|
+
if (isCancelled?.()) {
|
|
114
|
+
return { success: false, waitedForRuns: peakRunCount > 0, timedOut: false, cancelled: true, remainingRuns: [] };
|
|
104
115
|
}
|
|
105
116
|
// Issue #1722: if the timeout-final check throws, surface that as an error
|
|
106
117
|
// rather than reporting "no remaining runs".
|
package/src/github-merge.lib.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { parseGitHubUrl } from './github.lib.mjs';
|
|
|
20
20
|
import { githubLimits } from './config.lib.mjs';
|
|
21
21
|
import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
|
|
22
22
|
import { getTerminalGitHubEntityErrorMessage, isTerminalGitHubEntityError } from './github-terminal-state.lib.mjs';
|
|
23
|
+
import { cancellableSleep } from './interruptible-sleep.lib.mjs';
|
|
23
24
|
|
|
24
25
|
// Issue #1722: gh api `--paginate --slurp` responses for repos with many
|
|
25
26
|
// historical workflow runs can easily exceed Node's default 1 MB exec buffer
|
|
@@ -49,6 +50,11 @@ export { syncReadyTags, getLinkedPRsFromTimeline, READY_LABEL };
|
|
|
49
50
|
import { closeLinkedIssueIfNotAutoClosed } from './github-merge-issue-close.lib.mjs';
|
|
50
51
|
export { closeLinkedIssueIfNotAutoClosed };
|
|
51
52
|
|
|
53
|
+
// Issue #2072: the long CI polling loops live in their own module (file size limit).
|
|
54
|
+
// Re-exported here so existing importers keep working.
|
|
55
|
+
import { waitForCI, waitForBranchCI } from './github-merge-ci-wait.lib.mjs';
|
|
56
|
+
export { waitForCI, waitForBranchCI };
|
|
57
|
+
|
|
52
58
|
/**
|
|
53
59
|
* Check if 'ready' label exists in repository
|
|
54
60
|
* @param {string} owner - Repository owner
|
|
@@ -446,15 +452,19 @@ export async function checkPRCIStatus(owner, repo, prNumber, verbose = false) {
|
|
|
446
452
|
* @param {string} repo - Repository name
|
|
447
453
|
* @param {number} prNumber - Pull request number
|
|
448
454
|
* @param {boolean} verbose - Whether to log verbose output
|
|
449
|
-
* @
|
|
455
|
+
* @param {Object} [options] - Extra options
|
|
456
|
+
* @param {Function} [options.isCancelled] - Issue #2072: polled during the retry delay so a cancel aborts the wait
|
|
457
|
+
* @returns {Promise<{mergeable: boolean, mergeableState?: string|null, mergeStateStatus?: string|null, reason: string|null, terminal?: boolean, cancelled?: boolean}>}
|
|
450
458
|
*/
|
|
451
|
-
export async function checkPRMergeable(owner, repo, prNumber, verbose = false) {
|
|
459
|
+
export async function checkPRMergeable(owner, repo, prNumber, verbose = false, options = {}) {
|
|
460
|
+
const { isCancelled = null } = options;
|
|
452
461
|
// Issue #1339: GitHub computes mergeability asynchronously. When mergeStateStatus is
|
|
453
462
|
// 'UNKNOWN', it means GitHub hasn't calculated the merge state yet. Retry a few times.
|
|
454
463
|
const MAX_UNKNOWN_RETRIES = 3;
|
|
455
464
|
const UNKNOWN_RETRY_DELAY_MS = 5000;
|
|
456
465
|
|
|
457
466
|
for (let attempt = 0; attempt < MAX_UNKNOWN_RETRIES; attempt++) {
|
|
467
|
+
if (isCancelled?.()) return { mergeable: false, reason: 'Operation was cancelled', cancelled: true };
|
|
458
468
|
try {
|
|
459
469
|
const { stdout } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json mergeable,mergeStateStatus`);
|
|
460
470
|
const pr = JSON.parse(stdout.trim());
|
|
@@ -466,7 +476,7 @@ export async function checkPRMergeable(owner, repo, prNumber, verbose = false) {
|
|
|
466
476
|
if (verbose) {
|
|
467
477
|
console.log(`[VERBOSE] /merge: PR #${prNumber} mergeability is UNKNOWN (attempt ${attempt + 1}/${MAX_UNKNOWN_RETRIES}), retrying in ${UNKNOWN_RETRY_DELAY_MS / 1000}s...`);
|
|
468
478
|
}
|
|
469
|
-
await
|
|
479
|
+
await cancellableSleep(UNKNOWN_RETRY_DELAY_MS, isCancelled);
|
|
470
480
|
continue;
|
|
471
481
|
}
|
|
472
482
|
// All retries exhausted, still UNKNOWN - treat as not mergeable
|
|
@@ -604,95 +614,6 @@ export async function mergePullRequest(owner, repo, prNumber, options = {}, verb
|
|
|
604
614
|
}
|
|
605
615
|
}
|
|
606
616
|
|
|
607
|
-
/**
|
|
608
|
-
* Wait for CI/CD to complete with polling
|
|
609
|
-
* @param {string} owner - Repository owner
|
|
610
|
-
* @param {string} repo - Repository name
|
|
611
|
-
* @param {number} prNumber - Pull request number
|
|
612
|
-
* @param {Object} options - Wait options
|
|
613
|
-
* @param {number} options.timeout - Maximum wait time in ms (default: 30 minutes)
|
|
614
|
-
* @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
|
|
615
|
-
* @param {Function} options.onStatusUpdate - Callback for status updates
|
|
616
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
617
|
-
* @returns {Promise<{success: boolean, status: string, error: string|null}>}
|
|
618
|
-
*/
|
|
619
|
-
export async function waitForCI(owner, repo, prNumber, options = {}, verbose = false) {
|
|
620
|
-
const {
|
|
621
|
-
timeout = 30 * 60 * 1000,
|
|
622
|
-
pollInterval = 30 * 1000,
|
|
623
|
-
onStatusUpdate = null,
|
|
624
|
-
// Issue #1269: Add timeout for callback to prevent infinite blocking
|
|
625
|
-
callbackTimeout = 60 * 1000, // 1 minute max for callback
|
|
626
|
-
isCancelled = null, // Issue #1407: Support early exit when cancellation is requested
|
|
627
|
-
} = options;
|
|
628
|
-
|
|
629
|
-
const startTime = Date.now();
|
|
630
|
-
|
|
631
|
-
while (Date.now() - startTime < timeout) {
|
|
632
|
-
// Issue #1407: Check for cancellation before each poll to allow early exit
|
|
633
|
-
if (isCancelled?.()) return { success: false, status: 'cancelled', error: 'Operation was cancelled' };
|
|
634
|
-
|
|
635
|
-
let ciStatus;
|
|
636
|
-
try {
|
|
637
|
-
ciStatus = await checkPRCIStatus(owner, repo, prNumber, verbose);
|
|
638
|
-
} catch (error) {
|
|
639
|
-
// Issue #1269: Log and continue on CI check errors instead of crashing
|
|
640
|
-
console.error(`[ERROR] /merge: Error checking CI status for PR #${prNumber}: ${error.message}`);
|
|
641
|
-
verbose && console.error(`[VERBOSE] /merge: CI check error details:`, error);
|
|
642
|
-
// Wait and retry
|
|
643
|
-
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
644
|
-
continue;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
if (onStatusUpdate) {
|
|
648
|
-
// Issue #1269: Wrap callback with timeout to prevent infinite blocking; #1346: capture and clear timeout handle to prevent dangling timer
|
|
649
|
-
try {
|
|
650
|
-
let callbackTimeoutId;
|
|
651
|
-
await Promise.race([
|
|
652
|
-
onStatusUpdate(ciStatus),
|
|
653
|
-
new Promise((_, reject) => {
|
|
654
|
-
callbackTimeoutId = setTimeout(() => reject(new Error(`Callback timeout after ${callbackTimeout}ms`)), callbackTimeout);
|
|
655
|
-
}),
|
|
656
|
-
]).finally(() => clearTimeout(callbackTimeoutId));
|
|
657
|
-
} catch (callbackError) {
|
|
658
|
-
// Issue #1269: Log callback errors but continue processing
|
|
659
|
-
console.error(`[ERROR] /merge: Status update callback failed for PR #${prNumber}: ${callbackError.message}`);
|
|
660
|
-
verbose && console.error(`[VERBOSE] /merge: Callback error details:`, callbackError);
|
|
661
|
-
// Continue processing even if callback fails - don't let UI issues block merging
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
if (ciStatus.status === 'success') {
|
|
666
|
-
return { success: true, status: 'success', error: null };
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
if (ciStatus.status === 'failure') {
|
|
670
|
-
return { success: false, status: 'failure', error: 'CI checks failed' };
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
if (ciStatus.status === 'terminal_github_entity_error') {
|
|
674
|
-
return {
|
|
675
|
-
success: false,
|
|
676
|
-
status: 'terminal_github_entity_error',
|
|
677
|
-
error: ciStatus.error || 'GitHub repository, pull request, issue, or branch is no longer accessible',
|
|
678
|
-
};
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
if (ciStatus.status === 'pending') {
|
|
682
|
-
if (verbose) {
|
|
683
|
-
console.log(`[VERBOSE] /merge: Waiting for CI... (${Math.round((Date.now() - startTime) / 1000)}s elapsed)`);
|
|
684
|
-
}
|
|
685
|
-
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
686
|
-
continue;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
// Unknown status - wait and retry
|
|
690
|
-
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
return { success: false, status: 'timeout', error: 'CI check timeout exceeded' };
|
|
694
|
-
}
|
|
695
|
-
|
|
696
617
|
/**
|
|
697
618
|
* Parse and validate a repository URL for the merge command
|
|
698
619
|
* @param {string} url - Repository URL
|
|
@@ -782,109 +703,6 @@ export async function getActiveBranchRuns(owner, repo, branch = 'main', verbose
|
|
|
782
703
|
};
|
|
783
704
|
}
|
|
784
705
|
|
|
785
|
-
/**
|
|
786
|
-
* Wait for all active workflow runs on a branch to complete
|
|
787
|
-
* Issue #1307: Ensures all CI runs on target branch are complete before merging
|
|
788
|
-
* @param {string} owner - Repository owner
|
|
789
|
-
* @param {string} repo - Repository name
|
|
790
|
-
* @param {string} branch - Branch name (default: main)
|
|
791
|
-
* @param {Object} options - Wait options
|
|
792
|
-
* @param {number} options.timeout - Maximum wait time in ms (default: 45 minutes)
|
|
793
|
-
* @param {number} options.pollInterval - Polling interval in ms (default: 30 seconds)
|
|
794
|
-
* @param {Function} options.onStatusUpdate - Callback for status updates
|
|
795
|
-
* @param {boolean} verbose - Whether to log verbose output
|
|
796
|
-
* @returns {Promise<{success: boolean, waitedForRuns: boolean, completedRuns: number, error: string|null}>}
|
|
797
|
-
*/
|
|
798
|
-
export async function waitForBranchCI(owner, repo, branch = 'main', options = {}, verbose = false) {
|
|
799
|
-
const { timeout = 45 * 60 * 1000, pollInterval = 30 * 1000, onStatusUpdate = null, isCancelled = null } = options;
|
|
800
|
-
|
|
801
|
-
const startTime = Date.now();
|
|
802
|
-
let totalWaitedRuns = 0;
|
|
803
|
-
|
|
804
|
-
if (verbose) {
|
|
805
|
-
console.log(`[VERBOSE] /merge: Checking for active CI runs on ${owner}/${repo} branch ${branch}...`);
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
while (Date.now() - startTime < timeout) {
|
|
809
|
-
if (isCancelled?.()) return { success: false, waitedForRuns: totalWaitedRuns > 0, completedRuns: totalWaitedRuns, error: 'Operation was cancelled' };
|
|
810
|
-
let activeRuns;
|
|
811
|
-
try {
|
|
812
|
-
activeRuns = await getActiveBranchRuns(owner, repo, branch, verbose);
|
|
813
|
-
} catch (error) {
|
|
814
|
-
// Log and continue on errors
|
|
815
|
-
console.error(`[ERROR] /merge: Error checking branch CI: ${error.message}`);
|
|
816
|
-
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
817
|
-
continue;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
if (onStatusUpdate) {
|
|
821
|
-
try {
|
|
822
|
-
await onStatusUpdate({
|
|
823
|
-
hasActiveRuns: activeRuns.hasActiveRuns,
|
|
824
|
-
count: activeRuns.count,
|
|
825
|
-
runs: activeRuns.runs,
|
|
826
|
-
elapsedMs: Date.now() - startTime,
|
|
827
|
-
});
|
|
828
|
-
} catch (callbackError) {
|
|
829
|
-
// Log callback errors but continue
|
|
830
|
-
console.error(`[ERROR] /merge: Status update callback failed: ${callbackError.message}`);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
if (!activeRuns.hasActiveRuns) {
|
|
835
|
-
if (verbose) {
|
|
836
|
-
console.log(`[VERBOSE] /merge: No active CI runs on ${branch} branch. Ready to proceed.`);
|
|
837
|
-
}
|
|
838
|
-
return {
|
|
839
|
-
success: true,
|
|
840
|
-
waitedForRuns: totalWaitedRuns > 0,
|
|
841
|
-
completedRuns: totalWaitedRuns,
|
|
842
|
-
error: null,
|
|
843
|
-
};
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
totalWaitedRuns = Math.max(totalWaitedRuns, activeRuns.count);
|
|
847
|
-
|
|
848
|
-
if (verbose) {
|
|
849
|
-
const elapsedSec = Math.round((Date.now() - startTime) / 1000);
|
|
850
|
-
console.log(`[VERBOSE] /merge: Waiting for ${activeRuns.count} active runs on ${branch}... (${elapsedSec}s elapsed)`);
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
await new Promise(resolve => setTimeout(resolve, pollInterval));
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
// Timeout reached
|
|
857
|
-
// Issue #1722: if the final check throws, do NOT silently report "ready".
|
|
858
|
-
// Treat it the same as still-active (force a timeout failure), so /merge
|
|
859
|
-
// waits/retries instead of merging on top of a still-running CI run.
|
|
860
|
-
let finalCheck;
|
|
861
|
-
try {
|
|
862
|
-
finalCheck = await getActiveBranchRuns(owner, repo, branch, verbose);
|
|
863
|
-
} catch (error) {
|
|
864
|
-
return {
|
|
865
|
-
success: false,
|
|
866
|
-
waitedForRuns: true,
|
|
867
|
-
completedRuns: totalWaitedRuns,
|
|
868
|
-
error: `Timeout reached and final CI check failed on ${branch}: ${error.message}`,
|
|
869
|
-
};
|
|
870
|
-
}
|
|
871
|
-
if (finalCheck.hasActiveRuns) {
|
|
872
|
-
return {
|
|
873
|
-
success: false,
|
|
874
|
-
waitedForRuns: true,
|
|
875
|
-
completedRuns: totalWaitedRuns - finalCheck.count,
|
|
876
|
-
error: `Timeout waiting for ${finalCheck.count} CI runs on ${branch} branch`,
|
|
877
|
-
};
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
return {
|
|
881
|
-
success: true,
|
|
882
|
-
waitedForRuns: totalWaitedRuns > 0,
|
|
883
|
-
completedRuns: totalWaitedRuns,
|
|
884
|
-
error: null,
|
|
885
|
-
};
|
|
886
|
-
}
|
|
887
|
-
|
|
888
706
|
/**
|
|
889
707
|
* Get the default branch for a repository
|
|
890
708
|
* @param {string} owner - Repository owner
|
|
@@ -49,4 +49,37 @@ export function interruptibleSleep(ms) {
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Sleep for `ms` milliseconds, resolving early if SIGINT/SIGTERM arrives or if
|
|
54
|
+
* `isCancelled()` starts returning true.
|
|
55
|
+
*
|
|
56
|
+
* Issue #2072: polling loops used to `await new Promise(r => setTimeout(r, pollInterval))`
|
|
57
|
+
* and only re-check cancellation on the next iteration. With a 30s poll interval that
|
|
58
|
+
* made `/merge` keep running for up to a full interval after the Cancel button was
|
|
59
|
+
* pressed. Sleeping in short steps lets cancellation take effect within `stepMs`.
|
|
60
|
+
*
|
|
61
|
+
* @param {number} ms - Duration in milliseconds
|
|
62
|
+
* @param {Function|null} isCancelled - Predicate polled during the sleep
|
|
63
|
+
* @param {Object} [options]
|
|
64
|
+
* @param {number} [options.stepMs=100] - Granularity at which `isCancelled` is polled
|
|
65
|
+
* @returns {Promise<{interrupted: boolean, cancelled: boolean}>}
|
|
66
|
+
*/
|
|
67
|
+
export async function cancellableSleep(ms, isCancelled = null, options = {}) {
|
|
68
|
+
const { stepMs = 100 } = options;
|
|
69
|
+
|
|
70
|
+
if (!isCancelled) {
|
|
71
|
+
const { interrupted } = await interruptibleSleep(ms);
|
|
72
|
+
return { interrupted, cancelled: false };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const deadline = Date.now() + ms;
|
|
76
|
+
while (Date.now() < deadline) {
|
|
77
|
+
if (isCancelled()) return { interrupted: false, cancelled: true };
|
|
78
|
+
const { interrupted } = await interruptibleSleep(Math.min(stepMs, deadline - Date.now()));
|
|
79
|
+
if (interrupted) return { interrupted: true, cancelled: isCancelled() };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { interrupted: false, cancelled: isCancelled() };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export default { interruptibleSleep, cancellableSleep };
|
|
@@ -21,6 +21,7 @@ import { resolveMergeTargetItems } from './github-merge-targets.lib.mjs';
|
|
|
21
21
|
import { waitForPRReady as waitForPRReadyHelper } from './telegram-merge-wait.lib.mjs';
|
|
22
22
|
import { mergeQueue as mergeQueueConfig } from './config.lib.mjs';
|
|
23
23
|
import { getProgressBar } from './limits.lib.mjs';
|
|
24
|
+
import { cancellableSleep as cancellableSleepUntil } from './interruptible-sleep.lib.mjs';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Status enum for merge queue operations
|
|
@@ -495,7 +496,17 @@ export class MergeQueueProcessor {
|
|
|
495
496
|
try {
|
|
496
497
|
// Step 1: Check if PR is mergeable
|
|
497
498
|
item.status = MergeItemStatus.CHECKING_CI;
|
|
498
|
-
|
|
499
|
+
// Issue #2072: pass cancellation down so the UNKNOWN-mergeability retry delay aborts early
|
|
500
|
+
const mergeableCheck = await this.checkPRMergeable(this.owner, this.repo, item.pr.number, this.verbose, { isCancelled: () => this.isCancelled });
|
|
501
|
+
|
|
502
|
+
// Issue #2072: a cancel during the mergeability check must skip the PR, not fail it.
|
|
503
|
+
if (mergeableCheck.cancelled || this.isCancelled) {
|
|
504
|
+
item.status = MergeItemStatus.SKIPPED;
|
|
505
|
+
item.error = 'Cancelled';
|
|
506
|
+
this.stats.skipped++;
|
|
507
|
+
this.log(`Skipped PR #${item.pr.number}: cancelled during mergeability check`);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
499
510
|
|
|
500
511
|
if (mergeableCheck.terminal) {
|
|
501
512
|
item.status = MergeItemStatus.FAILED;
|
|
@@ -893,7 +904,7 @@ export class MergeQueueProcessor {
|
|
|
893
904
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
894
905
|
return { outcome: 'error', error: error.message };
|
|
895
906
|
}
|
|
896
|
-
await this.
|
|
907
|
+
await this.sleep(pollInterval);
|
|
897
908
|
continue;
|
|
898
909
|
}
|
|
899
910
|
|
|
@@ -912,27 +923,12 @@ export class MergeQueueProcessor {
|
|
|
912
923
|
}
|
|
913
924
|
}
|
|
914
925
|
|
|
915
|
-
await this.
|
|
926
|
+
await this.sleep(pollInterval);
|
|
916
927
|
}
|
|
917
928
|
|
|
918
929
|
return { outcome: 'timeout' };
|
|
919
930
|
}
|
|
920
931
|
|
|
921
|
-
/**
|
|
922
|
-
* Issue #1807: Sleep helper that bails out as soon as cancellation is
|
|
923
|
-
* requested. Used by the auto-resolve poll loop so a `cancel()` call
|
|
924
|
-
* doesn't have to wait a full polling interval before taking effect.
|
|
925
|
-
*/
|
|
926
|
-
async cancellableSleep(ms) {
|
|
927
|
-
const step = Math.min(ms, 1000);
|
|
928
|
-
const deadline = Date.now() + ms;
|
|
929
|
-
while (Date.now() < deadline) {
|
|
930
|
-
if (this.isCancelled) return;
|
|
931
|
-
const remaining = deadline - Date.now();
|
|
932
|
-
await this.sleep(Math.min(step, remaining));
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
|
|
936
932
|
/**
|
|
937
933
|
* Wait for any active CI runs on the target branch to complete
|
|
938
934
|
* Issue #1307: Prevents merging while post-merge CI from previous merges is still running
|
|
@@ -1456,10 +1452,17 @@ export class MergeQueueProcessor {
|
|
|
1456
1452
|
}
|
|
1457
1453
|
|
|
1458
1454
|
/**
|
|
1459
|
-
* Sleep helper
|
|
1455
|
+
* Sleep helper.
|
|
1456
|
+
*
|
|
1457
|
+
* Issue #2072: this is the single sleep primitive for the queue, and it is
|
|
1458
|
+
* cancellable — it returns as soon as `cancel()` is called (checked every
|
|
1459
|
+
* 100ms) or SIGINT/SIGTERM arrives, instead of sleeping out the full delay.
|
|
1460
|
+
* Every wait in the queue routes through here, so no stage of `/merge` can
|
|
1461
|
+
* hold up a cancel by more than ~100ms. This supersedes the separate
|
|
1462
|
+
* `cancellableSleep` helper added for #1807.
|
|
1460
1463
|
*/
|
|
1461
1464
|
sleep(ms) {
|
|
1462
|
-
return
|
|
1465
|
+
return cancellableSleepUntil(ms, () => this.isCancelled);
|
|
1463
1466
|
}
|
|
1464
1467
|
}
|
|
1465
1468
|
|
|
@@ -65,8 +65,14 @@ export async function waitForPRReady(processor, item, initialCheck, options) {
|
|
|
65
65
|
await processor.onProgress(processor.getProgressUpdate());
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Issue #2072: `processor.sleep` returns early on cancel. The poll interval defaults
|
|
69
|
+
// to 5 minutes, which previously kept `/merge` running long after Cancel was pressed.
|
|
68
70
|
await processor.sleep(pollIntervalMs);
|
|
69
|
-
|
|
71
|
+
if (processor.isCancelled) {
|
|
72
|
+
return { success: false, status: 'cancelled', error: 'Cancelled' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
latestCheck = await processor.checkPRMergeable(processor.owner, processor.repo, item.pr.number, processor.verbose, { isCancelled: () => processor.isCancelled });
|
|
70
76
|
}
|
|
71
77
|
}
|
|
72
78
|
|