@link-assistant/hive-mind 2.7.3 → 2.8.0
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 +40 -0
- package/README.hi.md +22 -0
- package/README.md +24 -0
- package/README.ru.md +21 -0
- package/README.zh.md +20 -0
- package/package.json +3 -2
- package/src/fix.ci-cd.lib.mjs +480 -0
- package/src/fix.mjs +242 -0
- 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/locales/en.lino +6 -1
- package/src/locales/hi.lino +6 -1
- package/src/locales/ru.lino +6 -1
- package/src/locales/zh.lino +6 -1
- package/src/task.issue-creation.lib.mjs +31 -5
- package/src/telegram-bot.mjs +10 -85
- package/src/telegram-fix-command.lib.mjs +164 -0
- package/src/telegram-merge-queue.lib.mjs +23 -20
- package/src/telegram-merge-wait.lib.mjs +7 -1
- package/src/telegram-ui-messages.lib.mjs +11 -1
- package/src/telegram.config.lib.mjs +96 -0
package/src/fix.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* `/fix` command (issue #1733).
|
|
5
|
+
*
|
|
6
|
+
* Currently implements `--ci-cd`: automatically generate a CI/CD remediation
|
|
7
|
+
* issue for a target repository and (optionally) hand it off to
|
|
8
|
+
* `/solve --development-log --deep-analysis --auto-merge`.
|
|
9
|
+
*
|
|
10
|
+
* fix.mjs <github-repository-url> --ci-cd [solve options...]
|
|
11
|
+
*
|
|
12
|
+
* Every option `/fix` does not consume itself (e.g. --tool, --model, --think)
|
|
13
|
+
* is forwarded to `/solve`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { spawn } from 'child_process';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import { CI_CD_ISSUE_LABELS, CI_CD_ISSUE_TYPE, buildCiCdIssueBody, buildCiCdIssueTitle, buildSolveArgs, partitionFixArgs, summarizeRunFailures } from './fix.ci-cd.lib.mjs';
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
function printHelp() {
|
|
24
|
+
console.log(`Usage: fix.mjs <github-repository-url> --ci-cd [options]
|
|
25
|
+
|
|
26
|
+
Automatically generate a CI/CD remediation issue for a repository and hand it
|
|
27
|
+
off to /solve --development-log --deep-analysis --auto-merge.
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--ci-cd Generate a CI/CD remediation issue (required mode)
|
|
31
|
+
--dry-run Print the issue that would be created without creating it
|
|
32
|
+
--no-solve Create the issue but do not start /solve on it
|
|
33
|
+
--version Show version number
|
|
34
|
+
--help, -h Show help
|
|
35
|
+
|
|
36
|
+
All other options (e.g. --tool, --model, --think) are forwarded to /solve.
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
fix.mjs https://github.com/owner/repo --ci-cd
|
|
40
|
+
fix.mjs https://github.com/owner/repo --ci-cd --tool codex --model gpt-5.5
|
|
41
|
+
fix.mjs owner/repo --ci-cd --think max --no-solve`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runCommand(command, args, options = {}) {
|
|
45
|
+
return new Promise(resolve => {
|
|
46
|
+
const child = spawn(command, args, {
|
|
47
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
+
env: process.env,
|
|
49
|
+
...options,
|
|
50
|
+
});
|
|
51
|
+
let stdout = '';
|
|
52
|
+
let stderr = '';
|
|
53
|
+
child.stdout.on('data', data => {
|
|
54
|
+
stdout += data.toString();
|
|
55
|
+
});
|
|
56
|
+
child.stderr.on('data', data => {
|
|
57
|
+
stderr += data.toString();
|
|
58
|
+
});
|
|
59
|
+
child.on('error', error => {
|
|
60
|
+
resolve({ code: 1, stdout, stderr: stderr || error.message });
|
|
61
|
+
});
|
|
62
|
+
child.on('close', code => {
|
|
63
|
+
resolve({ code, stdout, stderr });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function commandOutput(command, args) {
|
|
69
|
+
const result = await runCommand(command, args);
|
|
70
|
+
if (result.code !== 0) {
|
|
71
|
+
const output = `${result.stderr || ''}${result.stdout || ''}`.trim();
|
|
72
|
+
throw new Error(output || `${command} exited with code ${result.code}`);
|
|
73
|
+
}
|
|
74
|
+
return result.stdout.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function detectLanguages(repository) {
|
|
78
|
+
try {
|
|
79
|
+
const json = await commandOutput('gh', ['api', `repos/${repository.fullName}/languages`]);
|
|
80
|
+
return JSON.parse(json);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.warn(`⚠️ Could not detect languages: ${error.message}`);
|
|
83
|
+
return {};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function getDefaultBranch(repository) {
|
|
88
|
+
try {
|
|
89
|
+
return await commandOutput('gh', ['api', `repos/${repository.fullName}`, '--jq', '.default_branch']);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.warn(`⚠️ Could not determine default branch: ${error.message}`);
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function getLatestCommit(repository, branch) {
|
|
97
|
+
if (!branch) return null;
|
|
98
|
+
try {
|
|
99
|
+
const json = await commandOutput('gh', ['api', `repos/${repository.fullName}/commits/${branch}`, '--jq', '{sha: .sha, message: .commit.message, url: .html_url}']);
|
|
100
|
+
return JSON.parse(json);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.warn(`⚠️ Could not fetch latest commit: ${error.message}`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const RUNS_JQ = '[.workflow_runs[] | {name: .name, status: .status, conclusion: .conclusion, html_url: .html_url, head_sha: .head_sha}]';
|
|
108
|
+
|
|
109
|
+
async function getRunsForCommit(repository, sha) {
|
|
110
|
+
if (!sha) return [];
|
|
111
|
+
try {
|
|
112
|
+
const json = await commandOutput('gh', ['api', `repos/${repository.fullName}/actions/runs?head_sha=${sha}&per_page=100`, '--jq', RUNS_JQ]);
|
|
113
|
+
const parsed = JSON.parse(json);
|
|
114
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.warn(`⚠️ Could not fetch CI/CD runs: ${error.message}`);
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function getRecentBranchRuns(repository, branch) {
|
|
122
|
+
if (!branch) return [];
|
|
123
|
+
try {
|
|
124
|
+
const json = await commandOutput('gh', ['api', `repos/${repository.fullName}/actions/runs?branch=${encodeURIComponent(branch)}&per_page=20`, '--jq', RUNS_JQ]);
|
|
125
|
+
const parsed = JSON.parse(json);
|
|
126
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.warn(`⚠️ Could not fetch recent CI/CD runs for branch ${branch}: ${error.message}`);
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveSolveCommand() {
|
|
134
|
+
return path.join(__dirname, 'solve.mjs');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function main() {
|
|
138
|
+
const rawArgs = process.argv.slice(2);
|
|
139
|
+
|
|
140
|
+
if (rawArgs.includes('--version')) {
|
|
141
|
+
const { getVersion } = await import('./version.lib.mjs');
|
|
142
|
+
try {
|
|
143
|
+
console.log(await getVersion());
|
|
144
|
+
} catch {
|
|
145
|
+
console.error('Error: Unable to determine version');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (rawArgs.length === 0 || rawArgs.includes('--help') || rawArgs.includes('-h')) {
|
|
152
|
+
printHelp();
|
|
153
|
+
process.exit(rawArgs.length === 0 ? 1 : 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const parsed = partitionFixArgs(rawArgs);
|
|
157
|
+
|
|
158
|
+
if (!parsed.ciCd) {
|
|
159
|
+
console.error('❌ /fix currently supports only --ci-cd mode. Pass --ci-cd to continue.');
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!parsed.repository) {
|
|
164
|
+
console.error('❌ Missing or invalid GitHub repository URL. Provide it as the first argument, e.g. fix.mjs https://github.com/owner/repo --ci-cd');
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const repository = parsed.repository;
|
|
169
|
+
console.log(`🔧 /fix --ci-cd for ${repository.fullName}`);
|
|
170
|
+
|
|
171
|
+
const [languages, defaultBranch] = await Promise.all([detectLanguages(repository), getDefaultBranch(repository)]);
|
|
172
|
+
const commit = await getLatestCommit(repository, defaultBranch);
|
|
173
|
+
let runs = await getRunsForCommit(repository, commit?.sha);
|
|
174
|
+
let runsSource = 'commit';
|
|
175
|
+
|
|
176
|
+
// Release/tag commits frequently produce no runs of their own. Fall back to
|
|
177
|
+
// the most recent runs on the default branch so the issue stays actionable.
|
|
178
|
+
if (runs.length === 0) {
|
|
179
|
+
const branchRuns = await getRecentBranchRuns(repository, defaultBranch);
|
|
180
|
+
if (branchRuns.length > 0) {
|
|
181
|
+
runs = branchRuns;
|
|
182
|
+
runsSource = 'branch';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { total, failing } = summarizeRunFailures(runs);
|
|
187
|
+
console.log(` Default branch: ${defaultBranch || 'unknown'}`);
|
|
188
|
+
console.log(` Latest commit: ${commit?.sha ? commit.sha.slice(0, 7) : 'unknown'}`);
|
|
189
|
+
console.log(` CI/CD runs: ${total} (${failing} not passing)${runsSource === 'branch' ? ' [recent branch runs]' : ''}`);
|
|
190
|
+
|
|
191
|
+
const title = buildCiCdIssueTitle();
|
|
192
|
+
const body = buildCiCdIssueBody({ repository, defaultBranch, commit, runs, languages, runsSource });
|
|
193
|
+
|
|
194
|
+
if (parsed.dryRun) {
|
|
195
|
+
console.log('\n--- DRY RUN: issue that would be created ---\n');
|
|
196
|
+
console.log(`Title: ${title}\n`);
|
|
197
|
+
console.log(body);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log('\n📝 Creating remediation issue...');
|
|
202
|
+
const { createTaskIssue } = await import('./task.issue-creation.lib.mjs');
|
|
203
|
+
const issue = await createTaskIssue({
|
|
204
|
+
repository,
|
|
205
|
+
title,
|
|
206
|
+
body,
|
|
207
|
+
// The Bug type is what makes /solve --deep-analysis emit the root-cause
|
|
208
|
+
// instructions this body omits (issue #1733).
|
|
209
|
+
issueType: CI_CD_ISSUE_TYPE,
|
|
210
|
+
labels: [...CI_CD_ISSUE_LABELS],
|
|
211
|
+
run: runCommand,
|
|
212
|
+
log: message => console.log(message),
|
|
213
|
+
});
|
|
214
|
+
console.log(`✅ Created issue: ${issue.url}`);
|
|
215
|
+
|
|
216
|
+
if (!parsed.runSolve) {
|
|
217
|
+
console.log('ℹ️ --no-solve set; skipping /solve. Run it manually with:');
|
|
218
|
+
console.log(` solve ${buildSolveArgs({ issueUrl: issue.url, passthrough: parsed.passthrough }).join(' ')}`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const solveArgs = buildSolveArgs({ issueUrl: issue.url, passthrough: parsed.passthrough });
|
|
223
|
+
const solveCommand = resolveSolveCommand();
|
|
224
|
+
console.log(`\n🚀 Starting /solve: solve ${solveArgs.join(' ')}`);
|
|
225
|
+
|
|
226
|
+
await new Promise((resolve, reject) => {
|
|
227
|
+
const child = spawn(process.execPath, [solveCommand, ...solveArgs], {
|
|
228
|
+
stdio: 'inherit',
|
|
229
|
+
env: process.env,
|
|
230
|
+
});
|
|
231
|
+
child.on('error', reject);
|
|
232
|
+
child.on('close', code => {
|
|
233
|
+
if (code === 0) resolve();
|
|
234
|
+
else reject(new Error(`solve exited with code ${code}`));
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
main().catch(error => {
|
|
240
|
+
console.error(`❌ ${error.message}`);
|
|
241
|
+
process.exit(1);
|
|
242
|
+
});
|
|
@@ -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".
|