@link-assistant/hive-mind 1.65.0 → 1.65.2
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 +12 -0
- package/package.json +1 -1
- package/src/github-error-reporter.lib.mjs +1 -1
- package/src/github-rate-limit.lib.mjs +69 -14
- package/src/github.batch.lib.mjs +27 -25
- package/src/github.graphql.lib.mjs +10 -9
- package/src/github.lib.mjs +12 -5
- package/src/hive.mjs +6 -5
- package/src/limits.lib.mjs +3 -2
- package/src/reviewers-hive.mjs +21 -19
- package/src/solve.auto-pr.lib.mjs +42 -17
- package/src/solve.branch-divergence.lib.mjs +93 -0
- package/src/solve.config.lib.mjs +11 -0
- package/src/solve.error-handlers.lib.mjs +8 -5
- package/src/solve.mjs +9 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.65.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0214c9e: Retry transient 5xx/network errors across all `gh` exec sites. Previously a single 504 from the GitHub GraphQL endpoint could abort `solve` during `gh pr create`. The retry helper now handles HTTP 502/503/504, socket hang up, ECONNRESET, ETIMEDOUT, and TLS handshake timeouts in addition to rate-limit errors, with a separate retry budget and exponential backoff. All direct `execAsync('gh ...')` sites are routed through `execGhWithRetry`.
|
|
8
|
+
|
|
9
|
+
## 1.65.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- d5cd096: Add a solve flag to disable separate error-report issue creation while preserving original issue failure comments, and improve pre-PR branch divergence diagnostics.
|
|
14
|
+
|
|
3
15
|
## 1.65.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -271,7 +271,7 @@ export const handleErrorWithIssueCreation = async options => {
|
|
|
271
271
|
|
|
272
272
|
// --disable-report-issue takes highest precedence
|
|
273
273
|
if (disableReport) {
|
|
274
|
-
await log('ℹ️
|
|
274
|
+
await log('ℹ️ Error issue creation is disabled by CLI configuration.');
|
|
275
275
|
return null;
|
|
276
276
|
}
|
|
277
277
|
|
|
@@ -169,46 +169,98 @@ const sleepWithCountdown = async (ms, log) => {
|
|
|
169
169
|
}
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Patterns matched against an error's combined message/stderr/stdout to decide
|
|
174
|
+
* whether the failure is a transient network/edge fault that deserves a retry.
|
|
175
|
+
* Mirrors `isTransientNetworkError` in `src/lib.mjs` (issue #1536); duplicated
|
|
176
|
+
* here to avoid a circular import — `lib.mjs` already imports from this file.
|
|
177
|
+
*
|
|
178
|
+
* Issue #1756: `gh pr create` failed with `HTTP 504: 504 Gateway Timeout
|
|
179
|
+
* (https://api.github.com/graphql)`. `execGhWithRetry`/`ghWithRateLimitRetry`
|
|
180
|
+
* only handled rate-limit errors before — a single 504 was fatal.
|
|
181
|
+
*/
|
|
182
|
+
const TRANSIENT_NETWORK_PATTERNS = ['i/o timeout', 'dial tcp', 'connection refused', 'connection reset', 'econnreset', 'etimedout', 'enotfound', 'ehostunreach', 'enetunreach', 'network is unreachable', 'temporary failure', 'http 502', 'http 503', 'http 504', 'bad gateway', 'service unavailable', 'gateway timeout', 'tls handshake timeout', 'ssl_error', 'socket hang up', 'unexpected eof'];
|
|
183
|
+
|
|
184
|
+
const isTransientNetworkError = error => {
|
|
185
|
+
const text = collectErrorText(error).toLowerCase();
|
|
186
|
+
if (!text) return false;
|
|
187
|
+
return TRANSIENT_NETWORK_PATTERNS.some(pattern => text.includes(pattern));
|
|
188
|
+
};
|
|
189
|
+
|
|
172
190
|
/**
|
|
173
191
|
* Wrap `fn` so that GitHub rate-limit errors are converted into a sleep until
|
|
174
|
-
* (resetTime + bufferMs + jitterMs) followed by a retry.
|
|
175
|
-
*
|
|
192
|
+
* (resetTime + bufferMs + jitterMs) followed by a retry. Transient network
|
|
193
|
+
* errors (504/502/503, socket hang up, TLS timeouts) get exponential backoff
|
|
194
|
+
* and a separate retry budget. Other errors are rethrown immediately so we
|
|
195
|
+
* don't mask programming bugs or 404s.
|
|
196
|
+
*
|
|
197
|
+
* Issue #1726 — rate-limit retry. Issue #1756 — transient network retry.
|
|
176
198
|
*
|
|
177
199
|
* @template T
|
|
178
200
|
* @param {() => Promise<T>} fn
|
|
179
201
|
* @param {object} [options]
|
|
180
202
|
* @param {number} [options.maxAttempts] - hard cap on rate-limit retries (default `retryLimits.maxApiRetries`).
|
|
203
|
+
* @param {number} [options.transientMaxAttempts] - hard cap on transient network retries (default `retryLimits.maxApiRetries`).
|
|
204
|
+
* @param {number} [options.transientDelay] - initial transient retry delay in ms (default 1000).
|
|
205
|
+
* @param {number} [options.transientBackoff] - backoff multiplier for transient retries (default 2).
|
|
181
206
|
* @param {string} [options.label] - prefix for log messages.
|
|
182
207
|
* @param {(msg: string) => Promise<void>|void} [options.log] - logger. Defaults to console.warn.
|
|
183
208
|
* @returns {Promise<T>}
|
|
184
209
|
*/
|
|
185
210
|
export const ghWithRateLimitRetry = async (fn, options = {}) => {
|
|
186
211
|
const maxAttempts = options.maxAttempts ?? retryLimits.maxApiRetries;
|
|
212
|
+
const transientMaxAttempts = options.transientMaxAttempts ?? retryLimits.maxApiRetries;
|
|
213
|
+
const transientDelay = options.transientDelay ?? 1000;
|
|
214
|
+
const transientBackoff = options.transientBackoff ?? 2;
|
|
187
215
|
const label = options.label || 'gh';
|
|
188
216
|
const log = options.log || (msg => console.warn(msg));
|
|
189
217
|
|
|
218
|
+
// Two independent retry budgets — a long string of rate-limit responses
|
|
219
|
+
// shouldn't burn the transient-error retries, and vice versa.
|
|
220
|
+
let rateLimitAttempts = 0;
|
|
221
|
+
let transientAttempts = 0;
|
|
190
222
|
let lastError;
|
|
191
|
-
|
|
223
|
+
// Hard cap so a permanently broken endpoint can't loop forever — sum of
|
|
224
|
+
// both budgets plus a safety margin.
|
|
225
|
+
const hardCap = maxAttempts + transientMaxAttempts + 1;
|
|
226
|
+
|
|
227
|
+
for (let i = 0; i < hardCap; i++) {
|
|
192
228
|
try {
|
|
193
229
|
return await fn();
|
|
194
230
|
} catch (error) {
|
|
195
231
|
lastError = error;
|
|
196
|
-
if (!isRateLimitError(error)) throw error;
|
|
197
232
|
|
|
198
|
-
if (
|
|
199
|
-
|
|
200
|
-
|
|
233
|
+
if (isRateLimitError(error)) {
|
|
234
|
+
rateLimitAttempts++;
|
|
235
|
+
if (rateLimitAttempts >= maxAttempts) {
|
|
236
|
+
await Promise.resolve(log(`❌ ${label}: rate limit still active after ${rateLimitAttempts} attempts; giving up.`));
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
const reset = parseRateLimitReset(error) || (await fetchNextRateLimitReset());
|
|
240
|
+
const { waitMs, deadline, bufferMs, jitterMs } = computeRateLimitWait(reset);
|
|
241
|
+
const waitMinutes = Math.round(waitMs / 60_000);
|
|
242
|
+
const resetSummary = reset ? `reset at ${reset.toISOString()}` : 'reset time unknown (using buffer + jitter only)';
|
|
243
|
+
await Promise.resolve(log(`⏳ ${label}: GitHub API rate limit hit (attempt ${rateLimitAttempts}/${maxAttempts}). Waiting ${waitMinutes} min (${resetSummary}; buffer ${Math.round(bufferMs / 60_000)} min + jitter ${Math.round(jitterMs / 1000)}s) until ${deadline.toISOString()}.`));
|
|
244
|
+
await sleepWithCountdown(waitMs, log);
|
|
245
|
+
continue;
|
|
201
246
|
}
|
|
202
247
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
248
|
+
if (isTransientNetworkError(error)) {
|
|
249
|
+
transientAttempts++;
|
|
250
|
+
if (transientAttempts >= transientMaxAttempts) {
|
|
251
|
+
await Promise.resolve(log(`❌ ${label}: transient network error persisted after ${transientAttempts} attempts; giving up.`));
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
const waitMs = transientDelay * Math.pow(transientBackoff, transientAttempts - 1);
|
|
255
|
+
await Promise.resolve(log(`⚠️ ${label}: transient network error (attempt ${transientAttempts}/${transientMaxAttempts}), retrying in ${Math.round(waitMs / 1000)}s...`));
|
|
256
|
+
await sleepWithCountdown(waitMs, log);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
throw error;
|
|
209
261
|
}
|
|
210
262
|
}
|
|
211
|
-
// Unreachable — loop either returns or throws.
|
|
263
|
+
// Unreachable — loop either returns or throws via the budgets above.
|
|
212
264
|
throw lastError;
|
|
213
265
|
};
|
|
214
266
|
|
|
@@ -265,8 +317,11 @@ export const wrapDollarWithGhRetry = (dollar, options = {}) => {
|
|
|
265
317
|
return wrapped;
|
|
266
318
|
};
|
|
267
319
|
|
|
320
|
+
export { isTransientNetworkError };
|
|
321
|
+
|
|
268
322
|
export default {
|
|
269
323
|
isRateLimitError,
|
|
324
|
+
isTransientNetworkError,
|
|
270
325
|
parseRateLimitReset,
|
|
271
326
|
fetchNextRateLimitReset,
|
|
272
327
|
computeRateLimitWait,
|
package/src/github.batch.lib.mjs
CHANGED
|
@@ -11,7 +11,7 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
11
11
|
import { log, cleanErrorMessage } from './lib.mjs';
|
|
12
12
|
import { githubLimits, timeouts } from './config.lib.mjs';
|
|
13
13
|
|
|
14
|
-
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
|
|
14
|
+
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
|
|
15
15
|
/**
|
|
16
16
|
* Check if a PR body/title indicates it fixes/closes/resolves a specific issue number
|
|
17
17
|
* GitHub auto-closes issues when PR body contains keywords like "fixes #123", "closes #123", "resolves #123"
|
|
@@ -124,14 +124,14 @@ export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers)
|
|
|
124
124
|
await new Promise(resolve => setTimeout(resolve, timeouts.githubRepoDelay));
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// Execute GraphQL query
|
|
128
|
-
const {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
// Execute GraphQL query (#1756: route through execGhWithRetry for transient 5xx + rate-limit)
|
|
128
|
+
const { stdout } = await execGhWithRetry(`gh api graphql -f query='${query}'`, {
|
|
129
|
+
execOptions: {
|
|
130
|
+
encoding: 'utf8',
|
|
131
|
+
maxBuffer: githubLimits.bufferMaxSize,
|
|
132
|
+
env: process.env,
|
|
133
|
+
},
|
|
134
|
+
label: 'gh api graphql (batch PR check)',
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
const data = JSON.parse(stdout);
|
|
@@ -191,12 +191,13 @@ export async function batchCheckPullRequestsForIssues(owner, repo, issueNumbers)
|
|
|
191
191
|
|
|
192
192
|
for (const issueNum of batch) {
|
|
193
193
|
try {
|
|
194
|
-
const { exec } = await import('child_process');
|
|
195
|
-
const { promisify } = await import('util');
|
|
196
|
-
const execAsync = promisify(exec);
|
|
197
194
|
const cmd = `gh api repos/${owner}/${repo}/issues/${issueNum}/timeline --paginate --jq '[.[] | select(.event == "cross-referenced" and .source.issue.pull_request != null and .source.issue.state == "open")] | length'`;
|
|
198
195
|
|
|
199
|
-
|
|
196
|
+
// #1756: route REST fallback through execGhWithRetry for transient 5xx + rate-limit
|
|
197
|
+
const { stdout } = await execGhWithRetry(cmd, {
|
|
198
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
199
|
+
label: `gh api timeline (issue #${issueNum})`,
|
|
200
|
+
});
|
|
200
201
|
const openPrCount = parseInt(stdout.trim()) || 0;
|
|
201
202
|
|
|
202
203
|
results[issueNum] = {
|
|
@@ -271,14 +272,14 @@ export async function batchCheckArchivedRepositories(repositories) {
|
|
|
271
272
|
await new Promise(resolve => setTimeout(resolve, timeouts.githubRepoDelay));
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
// Execute GraphQL query
|
|
275
|
-
const {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
275
|
+
// Execute GraphQL query (#1756: route through execGhWithRetry for transient 5xx + rate-limit)
|
|
276
|
+
const { stdout } = await execGhWithRetry(`gh api graphql -f query='${query}'`, {
|
|
277
|
+
execOptions: {
|
|
278
|
+
encoding: 'utf8',
|
|
279
|
+
maxBuffer: githubLimits.bufferMaxSize,
|
|
280
|
+
env: process.env,
|
|
281
|
+
},
|
|
282
|
+
label: 'gh api graphql (batch archived check)',
|
|
282
283
|
});
|
|
283
284
|
|
|
284
285
|
const data = JSON.parse(stdout);
|
|
@@ -301,12 +302,13 @@ export async function batchCheckArchivedRepositories(repositories) {
|
|
|
301
302
|
|
|
302
303
|
for (const repo of batch) {
|
|
303
304
|
try {
|
|
304
|
-
const { exec } = await import('child_process');
|
|
305
|
-
const { promisify } = await import('util');
|
|
306
|
-
const execAsync = promisify(exec);
|
|
307
305
|
const cmd = `gh api repos/${repo.owner}/${repo.name} --jq .archived`;
|
|
308
306
|
|
|
309
|
-
|
|
307
|
+
// #1756: route REST fallback through execGhWithRetry for transient 5xx + rate-limit
|
|
308
|
+
const { stdout } = await execGhWithRetry(cmd, {
|
|
309
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
310
|
+
label: `gh api repos (${repo.owner}/${repo.name})`,
|
|
311
|
+
});
|
|
310
312
|
const isArchived = stdout.trim() === 'true';
|
|
311
313
|
|
|
312
314
|
const repoKey = `${repo.owner}/${repo.name}`;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* This module provides functions to fetch issues using GitHub's GraphQL API
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { execGhWithRetry } from './github-rate-limit.lib.mjs'; // #1756: route gh exec through transient + rate-limit retry wrapper
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Fetch issues from a single repository with pagination support for >100 issues
|
|
8
10
|
* @param {string} owner - Repository owner
|
|
@@ -13,9 +15,6 @@
|
|
|
13
15
|
* @returns {Promise<Array>} Array of issues
|
|
14
16
|
*/
|
|
15
17
|
async function fetchRepositoryIssuesWithPagination(owner, repoName, log, cleanErrorMessage, issueLimit = 100) {
|
|
16
|
-
const { exec } = await import('child_process');
|
|
17
|
-
const { promisify } = await import('util');
|
|
18
|
-
const execAsync = promisify(exec);
|
|
19
18
|
const allIssues = [];
|
|
20
19
|
let hasNextPage = true;
|
|
21
20
|
let cursor = null;
|
|
@@ -59,7 +58,10 @@ async function fetchRepositoryIssuesWithPagination(owner, repoName, log, cleanEr
|
|
|
59
58
|
// Add delay for rate limiting
|
|
60
59
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
61
60
|
|
|
62
|
-
const { stdout } = await
|
|
61
|
+
const { stdout } = await execGhWithRetry(graphqlCmd, {
|
|
62
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
63
|
+
label: `gh api graphql (issues page ${pageNum} of ${owner}/${repoName})`,
|
|
64
|
+
});
|
|
63
65
|
const data = JSON.parse(stdout);
|
|
64
66
|
const issuesData = data.data.repository.issues;
|
|
65
67
|
|
|
@@ -95,10 +97,6 @@ async function fetchRepositoryIssuesWithPagination(owner, repoName, log, cleanEr
|
|
|
95
97
|
* @returns {Promise<{success: boolean, issues: Array, repoCount: number}>}
|
|
96
98
|
*/
|
|
97
99
|
export async function tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMessage, repoLimit = 100, issueLimit = 100) {
|
|
98
|
-
const { exec } = await import('child_process');
|
|
99
|
-
const { promisify } = await import('util');
|
|
100
|
-
const execAsync = promisify(exec);
|
|
101
|
-
|
|
102
100
|
try {
|
|
103
101
|
await log(' 🧪 Attempting GraphQL approach with pagination support...', { verbose: true });
|
|
104
102
|
|
|
@@ -174,7 +172,10 @@ export async function tryFetchIssuesWithGraphQL(owner, scope, log, cleanErrorMes
|
|
|
174
172
|
// Add delay for rate limiting
|
|
175
173
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
176
174
|
|
|
177
|
-
const { stdout } = await
|
|
175
|
+
const { stdout } = await execGhWithRetry(graphqlCmd, {
|
|
176
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
177
|
+
label: `gh api graphql (repos page ${repoPageNum} of ${owner})`,
|
|
178
|
+
});
|
|
178
179
|
const data = JSON.parse(stdout);
|
|
179
180
|
const repos = isOrg ? data.data.organization.repositories : data.data.user.repositories;
|
|
180
181
|
|
package/src/github.lib.mjs
CHANGED
|
@@ -16,6 +16,8 @@ export { getToolDisplayName }; // Re-export for use by other modules
|
|
|
16
16
|
import { buildBudgetStatsString } from './claude.budget-stats.lib.mjs';
|
|
17
17
|
import { buildCostInfoString } from './github-cost-info.lib.mjs';
|
|
18
18
|
export { buildCostInfoString };
|
|
19
|
+
// #1756: route gh exec calls through transient + rate-limit retry wrapper
|
|
20
|
+
import { execGhWithRetry } from './github-rate-limit.lib.mjs';
|
|
19
21
|
// Issue #1625: Named marker constants (single source of truth) + in-memory
|
|
20
22
|
// tracking for tool-posted comments. See tool-comments.lib.mjs for design.
|
|
21
23
|
import { SOLUTION_DRAFT_LOG_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, postTrackedComment, postTrackedCommentFromFile } from './tool-comments.lib.mjs';
|
|
@@ -858,9 +860,6 @@ export function isRateLimitError(error) {
|
|
|
858
860
|
* @returns {Promise<Array>} Array of issues
|
|
859
861
|
*/
|
|
860
862
|
export async function fetchAllIssuesWithPagination(baseCommand) {
|
|
861
|
-
const { exec } = await import('child_process');
|
|
862
|
-
const { promisify } = await import('util');
|
|
863
|
-
const execAsync = promisify(exec);
|
|
864
863
|
// Import log and cleanErrorMessage from lib.mjs
|
|
865
864
|
const { log, cleanErrorMessage } = await import('./lib.mjs');
|
|
866
865
|
try {
|
|
@@ -876,7 +875,11 @@ export async function fetchAllIssuesWithPagination(baseCommand) {
|
|
|
876
875
|
const maxPageSize = isSearchCommand ? 100 : 1000;
|
|
877
876
|
const improvedCommand = `${commandWithoutLimit} --limit ${maxPageSize}`;
|
|
878
877
|
await log(` 🔎 Executing: ${improvedCommand}`, { verbose: true });
|
|
879
|
-
|
|
878
|
+
// #1756: use execGhWithRetry so transient 5xx (e.g., 504) auto-retry
|
|
879
|
+
const { stdout } = await execGhWithRetry(improvedCommand, {
|
|
880
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
881
|
+
label: 'gh search/list issues (paginated)',
|
|
882
|
+
});
|
|
880
883
|
const endTime = Date.now();
|
|
881
884
|
const issues = JSON.parse(stdout || '[]');
|
|
882
885
|
await log(` ✅ Fetched ${issues.length} issues in ${Math.round((endTime - startTime) / 1000)}s`);
|
|
@@ -913,7 +916,11 @@ export async function fetchAllIssuesWithPagination(baseCommand) {
|
|
|
913
916
|
await log(' 🔄 Falling back to default behavior...', { verbose: true });
|
|
914
917
|
const fallbackCommand = baseCommand.includes('--limit') ? baseCommand : `${baseCommand} --limit 100`;
|
|
915
918
|
await new Promise(resolve => setTimeout(resolve, timeouts.githubRepoDelay)); // Shorter delay for fallback
|
|
916
|
-
|
|
919
|
+
// #1756: use execGhWithRetry on fallback too
|
|
920
|
+
const { stdout } = await execGhWithRetry(fallbackCommand, {
|
|
921
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
922
|
+
label: 'gh search/list issues (fallback)',
|
|
923
|
+
});
|
|
917
924
|
const issues = JSON.parse(stdout || '[]');
|
|
918
925
|
await log(` ⚠️ Fallback: fetched ${issues.length} issues (limited to 100)`, { level: 'warning' });
|
|
919
926
|
return issues;
|
package/src/hive.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Import Sentry instrumentation first (must be before other imports)
|
|
3
3
|
import './instrument.mjs';
|
|
4
|
-
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
|
|
4
|
+
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
|
|
5
5
|
const earlyArgs = process.argv.slice(2);
|
|
6
6
|
if (earlyArgs.includes('--version')) {
|
|
7
7
|
const { getVersion } = await import('./version.lib.mjs');
|
|
@@ -112,9 +112,6 @@ if (isRunningDirectly) {
|
|
|
112
112
|
* @returns {Promise<Array>} Array of issues
|
|
113
113
|
*/
|
|
114
114
|
async function fetchIssuesFromRepositories(owner, scope, monitorTag, fetchAllIssues = false) {
|
|
115
|
-
const { exec } = await import('child_process');
|
|
116
|
-
const { promisify } = await import('util');
|
|
117
|
-
const execAsync = promisify(exec);
|
|
118
115
|
try {
|
|
119
116
|
await log(` 🔄 Using repository-by-repository fallback for ${scope}: ${owner}`);
|
|
120
117
|
// Strategy 1: Try GraphQL approach first (faster but has limitations)
|
|
@@ -141,7 +138,11 @@ if (isRunningDirectly) {
|
|
|
141
138
|
|
|
142
139
|
// Add delay for rate limiting
|
|
143
140
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
144
|
-
|
|
141
|
+
// #1756: route through execGhWithRetry for transient 5xx + rate-limit
|
|
142
|
+
const { stdout: repoOutput } = await execGhWithRetry(repoListCmd, {
|
|
143
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
144
|
+
label: `gh api ${scope} repos (paginated)`,
|
|
145
|
+
});
|
|
145
146
|
// Parse the output line by line, as gh api with --jq outputs one JSON object per line
|
|
146
147
|
const repoLines = repoOutput
|
|
147
148
|
.trim()
|
package/src/limits.lib.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { promisify } from 'node:util';
|
|
|
12
12
|
import dayjs from 'dayjs';
|
|
13
13
|
import utc from 'dayjs/plugin/utc.js';
|
|
14
14
|
|
|
15
|
-
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
|
|
15
|
+
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. execGhWithRetry adds transient-network retry (#1756).
|
|
16
16
|
// Initialize dayjs plugins
|
|
17
17
|
dayjs.extend(utc);
|
|
18
18
|
|
|
@@ -316,7 +316,8 @@ function getDisplayCpuCoresUsed(loadAvg5, cpuCount) {
|
|
|
316
316
|
*/
|
|
317
317
|
export async function getGitHubRateLimits(verbose = false) {
|
|
318
318
|
try {
|
|
319
|
-
|
|
319
|
+
// #1756: route through execGhWithRetry for transient 5xx; skip rate-limit retry budget (this is the endpoint we'd consult to know about rate limits).
|
|
320
|
+
const { stdout } = await execGhWithRetry('gh api rate_limit 2>/dev/null', { label: 'gh api rate_limit', maxAttempts: 1 });
|
|
320
321
|
const data = JSON.parse(stdout);
|
|
321
322
|
|
|
322
323
|
if (verbose) {
|
package/src/reviewers-hive.mjs
CHANGED
|
@@ -5,7 +5,7 @@ const { use } = eval(await (await fetch('https://unpkg.com/use-m/use.js')).text(
|
|
|
5
5
|
|
|
6
6
|
// Use command-stream for consistent $ behavior across runtimes
|
|
7
7
|
const { $: __rawDollar$ } = await use('command-stream');
|
|
8
|
-
const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
|
|
8
|
+
const { wrapDollarWithGhRetry, execGhWithRetry } = await import('./github-rate-limit.lib.mjs');
|
|
9
9
|
const $ = wrapDollarWithGhRetry(__rawDollar$);
|
|
10
10
|
const { getLinoYargsFactory, hideBin, parseCliArgumentsWithLino } = await import('./cli-arguments.lib.mjs');
|
|
11
11
|
const path = (await use('path')).default;
|
|
@@ -378,20 +378,19 @@ async function reviewer(reviewerId) {
|
|
|
378
378
|
// Function to check if a PR already has approvals
|
|
379
379
|
async function hasApprovals(prUrl) {
|
|
380
380
|
try {
|
|
381
|
-
const { exec } = await import('child_process');
|
|
382
|
-
const { promisify } = await import('util');
|
|
383
|
-
const execAsync = promisify(exec);
|
|
384
|
-
|
|
385
381
|
// Extract owner, repo, and PR number from URL
|
|
386
382
|
const urlMatch = prUrl.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
|
|
387
383
|
if (!urlMatch) return false;
|
|
388
384
|
|
|
389
385
|
const [, prOwner, prRepo, prNumber] = urlMatch;
|
|
390
386
|
|
|
391
|
-
// Check for reviews using GitHub API
|
|
387
|
+
// Check for reviews using GitHub API (#1756: retry on transient 5xx + rate-limit)
|
|
392
388
|
const cmd = `gh api repos/${prOwner}/${prRepo}/pulls/${prNumber}/reviews --paginate --jq '[.[] | select(.state == "APPROVED")] | length'`;
|
|
393
389
|
|
|
394
|
-
const { stdout } = await
|
|
390
|
+
const { stdout } = await execGhWithRetry(cmd, {
|
|
391
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
392
|
+
label: `gh api reviews (PR #${prNumber})`,
|
|
393
|
+
});
|
|
395
394
|
const approvalCount = parseInt(stdout.trim()) || 0;
|
|
396
395
|
|
|
397
396
|
if (approvalCount > 0) {
|
|
@@ -432,25 +431,24 @@ async function fetchPullRequests() {
|
|
|
432
431
|
|
|
433
432
|
await log(` 🔎 Command: ${searchCmd}`, { verbose: true });
|
|
434
433
|
|
|
435
|
-
//
|
|
436
|
-
const {
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
434
|
+
// #1756: route through execGhWithRetry to retry transient 5xx + rate-limit
|
|
435
|
+
const { stdout } = await execGhWithRetry(searchCmd, {
|
|
436
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
437
|
+
label: 'gh search prs (all PRs)',
|
|
438
|
+
});
|
|
440
439
|
prs = JSON.parse(stdout || '[]');
|
|
441
440
|
} else {
|
|
442
|
-
// Use label filter
|
|
443
|
-
const { exec } = await import('child_process');
|
|
444
|
-
const { promisify } = await import('util');
|
|
445
|
-
const execAsync = promisify(exec);
|
|
446
|
-
|
|
447
441
|
// For repositories, use gh pr list which works better
|
|
448
442
|
if (scope === 'repository') {
|
|
449
443
|
const listCmd = `gh pr list --repo ${owner}/${repo} --state open --label "${argv.reviewLabel}" --limit 100 --json url,title,number,isDraft`;
|
|
450
444
|
await log(` 🔎 Command: ${listCmd}`, { verbose: true });
|
|
451
445
|
|
|
452
446
|
try {
|
|
453
|
-
|
|
447
|
+
// #1756: retry on transient 5xx + rate-limit
|
|
448
|
+
const { stdout } = await execGhWithRetry(listCmd, {
|
|
449
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
450
|
+
label: 'gh pr list (label filter)',
|
|
451
|
+
});
|
|
454
452
|
prs = JSON.parse(stdout || '[]');
|
|
455
453
|
} catch (listError) {
|
|
456
454
|
await log(` ⚠️ List failed: ${listError.message.split('\n')[0]}`, { verbose: true });
|
|
@@ -481,7 +479,11 @@ async function fetchPullRequests() {
|
|
|
481
479
|
await log(` 🔎 Command: ${searchCmd}`, { verbose: true });
|
|
482
480
|
|
|
483
481
|
try {
|
|
484
|
-
|
|
482
|
+
// #1756: retry on transient 5xx + rate-limit
|
|
483
|
+
const { stdout } = await execGhWithRetry(searchCmd, {
|
|
484
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
485
|
+
label: 'gh search prs (label filter)',
|
|
486
|
+
});
|
|
485
487
|
prs = JSON.parse(stdout || '[]');
|
|
486
488
|
} catch (searchError) {
|
|
487
489
|
await log(` ⚠️ Search failed: ${searchError.message.split('\n')[0]}`, { verbose: true });
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { closingIssueNumbersContain, parseClosingIssueNumbers } from './pr-issue-linking.lib.mjs';
|
|
7
|
+
import { buildPushRejectionExplanation, getRemoteBranchDivergenceSnapshot, synchronizeExistingIssueBranchBeforeAutoPrCreation } from './solve.branch-divergence.lib.mjs';
|
|
8
|
+
|
|
9
|
+
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry, execGhWithRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller. Issue #1756: execGhWithRetry retries on transient 5xx (504) too.
|
|
7
10
|
|
|
8
|
-
import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
|
|
9
11
|
export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNumber, owner, repo, defaultBranch, forkedRepo, isContinueMode, prNumber, log, formatAligned, $, reportError, path, fs }) {
|
|
10
12
|
// Skip auto-PR creation if:
|
|
11
13
|
// 1. Auto-PR creation is disabled AND we're not in continue mode with no PR
|
|
@@ -33,6 +35,16 @@ export async function handleAutoPrCreation({ argv, tempDir, branchName, issueNum
|
|
|
33
35
|
const issueUrl = argv['issue-url'] || argv._[0];
|
|
34
36
|
|
|
35
37
|
try {
|
|
38
|
+
await synchronizeExistingIssueBranchBeforeAutoPrCreation({
|
|
39
|
+
tempDir,
|
|
40
|
+
branchName,
|
|
41
|
+
isContinueMode,
|
|
42
|
+
prNumber,
|
|
43
|
+
log,
|
|
44
|
+
formatAligned,
|
|
45
|
+
$,
|
|
46
|
+
});
|
|
47
|
+
|
|
36
48
|
// Determine which file to create based on CLI flags
|
|
37
49
|
let useClaudeFile = argv.claudeFile !== false;
|
|
38
50
|
const useAutoGitkeepFile = argv.autoGitkeepFile !== false;
|
|
@@ -527,6 +539,7 @@ Proceed.
|
|
|
527
539
|
await log('');
|
|
528
540
|
throw new Error('Permission denied - need fork or collaborator access');
|
|
529
541
|
} else if (errorOutput.includes('non-fast-forward') || errorOutput.includes('rejected') || errorOutput.includes('! [rejected]')) {
|
|
542
|
+
const divergence = await getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName });
|
|
530
543
|
// Push rejected due to conflicts or diverged history
|
|
531
544
|
await log('');
|
|
532
545
|
await log(formatAligned('❌', 'PUSH REJECTED:', 'Branch has diverged from remote'), { level: 'error' });
|
|
@@ -534,6 +547,9 @@ Proceed.
|
|
|
534
547
|
await log(' 🔍 What happened:');
|
|
535
548
|
await log(' The remote branch has changes that conflict with your local changes.');
|
|
536
549
|
await log(' This typically means someone else has pushed to this branch.');
|
|
550
|
+
for (const line of buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence })) {
|
|
551
|
+
await log(line);
|
|
552
|
+
}
|
|
537
553
|
await log('');
|
|
538
554
|
await log(' 💡 Why we cannot fix this automatically:');
|
|
539
555
|
await log(' • We never use force push to preserve history');
|
|
@@ -887,16 +903,16 @@ Proceed.
|
|
|
887
903
|
await log(` Current user: ${currentUser}`, { verbose: true });
|
|
888
904
|
|
|
889
905
|
// Check if user has push access (is a collaborator or owner)
|
|
890
|
-
// IMPORTANT: We need to completely suppress the JSON error output
|
|
891
|
-
//
|
|
906
|
+
// IMPORTANT: We need to completely suppress the JSON error output.
|
|
907
|
+
// Issue #1756: route through execGhWithRetry so transient 5xx
|
|
908
|
+
// (504) and rate-limit responses are retried instead of being
|
|
909
|
+
// mistaken for "user is not a collaborator".
|
|
892
910
|
try {
|
|
893
|
-
const { exec } = await import('child_process');
|
|
894
|
-
const { promisify } = await import('util');
|
|
895
|
-
const execAsync = promisify(exec);
|
|
896
911
|
// This will throw if user doesn't have access, but won't print anything
|
|
897
|
-
await
|
|
898
|
-
encoding: 'utf8',
|
|
899
|
-
|
|
912
|
+
await execGhWithRetry(`gh api repos/${owner}/${repo}/collaborators/${currentUser} 2>/dev/null`, {
|
|
913
|
+
execOptions: { encoding: 'utf8', env: process.env },
|
|
914
|
+
label: `gh api collaborators (${owner}/${repo}/${currentUser})`,
|
|
915
|
+
log: msg => log(msg, { level: 'warn' }),
|
|
900
916
|
});
|
|
901
917
|
canAssign = true;
|
|
902
918
|
await log(' User has collaborator access', { verbose: true });
|
|
@@ -1077,13 +1093,11 @@ ${prBody}`,
|
|
|
1077
1093
|
);
|
|
1078
1094
|
}
|
|
1079
1095
|
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1096
|
+
// Issue #1756: route `gh pr create` through execGhWithRetry so a
|
|
1097
|
+
// single transient 5xx (e.g. `HTTP 504: 504 Gateway Timeout
|
|
1098
|
+
// (https://api.github.com/graphql)`) or rate-limit response retries
|
|
1099
|
+
// instead of aborting the whole solve session.
|
|
1082
1100
|
try {
|
|
1083
|
-
const { exec } = await import('child_process');
|
|
1084
|
-
const { promisify } = await import('util');
|
|
1085
|
-
const execAsync = promisify(exec);
|
|
1086
|
-
|
|
1087
1101
|
// Write PR body to temp file to avoid shell escaping issues
|
|
1088
1102
|
const prBodyFile = `/tmp/pr-body-${Date.now()}.md`;
|
|
1089
1103
|
await fs.writeFile(prBodyFile, prBody);
|
|
@@ -1119,9 +1133,16 @@ ${prBody}`,
|
|
|
1119
1133
|
let prCreateStderr = '';
|
|
1120
1134
|
let assigneeFailed = false;
|
|
1121
1135
|
|
|
1136
|
+
const prCreateExecOptions = { encoding: 'utf8', cwd: tempDir, env: process.env };
|
|
1137
|
+
const prCreateRetryLogger = msg => log(msg, { level: 'warn' });
|
|
1138
|
+
|
|
1122
1139
|
// Try to create PR with assignee first (if specified)
|
|
1123
1140
|
try {
|
|
1124
|
-
const result = await
|
|
1141
|
+
const result = await execGhWithRetry(command, {
|
|
1142
|
+
execOptions: prCreateExecOptions,
|
|
1143
|
+
label: 'gh pr create',
|
|
1144
|
+
log: prCreateRetryLogger,
|
|
1145
|
+
});
|
|
1125
1146
|
output = result.stdout;
|
|
1126
1147
|
prCreateStderr = result.stderr || '';
|
|
1127
1148
|
} catch (firstError) {
|
|
@@ -1149,7 +1170,11 @@ ${prBody}`,
|
|
|
1149
1170
|
}
|
|
1150
1171
|
|
|
1151
1172
|
// Retry without assignee - if this fails, let the error propagate to outer catch
|
|
1152
|
-
const retryResult = await
|
|
1173
|
+
const retryResult = await execGhWithRetry(command, {
|
|
1174
|
+
execOptions: prCreateExecOptions,
|
|
1175
|
+
label: 'gh pr create (no assignee)',
|
|
1176
|
+
log: prCreateRetryLogger,
|
|
1177
|
+
});
|
|
1153
1178
|
output = retryResult.stdout;
|
|
1154
1179
|
prCreateStderr = retryResult.stderr || '';
|
|
1155
1180
|
} else {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const toCount = value => {
|
|
2
|
+
const parsed = Number.parseInt(String(value || '').trim(), 10);
|
|
3
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const outputOf = result => {
|
|
7
|
+
const stdout = result?.stdout ? result.stdout.toString().trim() : '';
|
|
8
|
+
const stderr = result?.stderr ? result.stderr.toString().trim() : '';
|
|
9
|
+
return stdout || stderr;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function buildPushRejectionExplanation({ branchName, isContinueMode, prNumber, divergence = null }) {
|
|
13
|
+
const lines = [];
|
|
14
|
+
|
|
15
|
+
if (isContinueMode && !prNumber) {
|
|
16
|
+
lines.push(' This run reused an existing issue branch because auto-continue found a matching branch with no PR.');
|
|
17
|
+
lines.push(' It is not a fresh branch created by this run, even though auto-PR creation is running now.');
|
|
18
|
+
} else {
|
|
19
|
+
lines.push(' The remote branch changed after the local branch state used for this push.');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (divergence?.remoteExists && divergence.ahead !== null && divergence.behind !== null) {
|
|
23
|
+
lines.push(` Current branch state for ${branchName}: ${divergence.ahead} commit(s) ahead, ${divergence.behind} commit(s) behind origin/${branchName}.`);
|
|
24
|
+
} else if (divergence?.fetchError) {
|
|
25
|
+
lines.push(` Could not inspect origin/${branchName}: ${divergence.fetchError}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName }) {
|
|
32
|
+
const fetchResult = await $({ cwd: tempDir, silent: true })`git fetch origin refs/heads/${branchName}:refs/remotes/origin/${branchName} 2>&1`;
|
|
33
|
+
if (fetchResult.code !== 0) {
|
|
34
|
+
return {
|
|
35
|
+
remoteExists: false,
|
|
36
|
+
ahead: null,
|
|
37
|
+
behind: null,
|
|
38
|
+
fetchError: outputOf(fetchResult) || 'remote branch not found',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const aheadResult = await $({ cwd: tempDir, silent: true })`git rev-list --count origin/${branchName}..HEAD 2>&1`;
|
|
43
|
+
const behindResult = await $({ cwd: tempDir, silent: true })`git rev-list --count HEAD..origin/${branchName} 2>&1`;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
remoteExists: aheadResult.code === 0 && behindResult.code === 0,
|
|
47
|
+
ahead: aheadResult.code === 0 ? toCount(aheadResult.stdout) : null,
|
|
48
|
+
behind: behindResult.code === 0 ? toCount(behindResult.stdout) : null,
|
|
49
|
+
fetchError: aheadResult.code === 0 && behindResult.code === 0 ? null : outputOf(aheadResult) || outputOf(behindResult) || 'could not compare local and remote branch',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function synchronizeExistingIssueBranchBeforeAutoPrCreation({ tempDir, branchName, isContinueMode, prNumber, log, formatAligned, $ }) {
|
|
54
|
+
if (!(isContinueMode && !prNumber)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await log(formatAligned('🔎', 'Existing branch sync:', branchName));
|
|
59
|
+
const divergence = await getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName });
|
|
60
|
+
if (!divergence.remoteExists) {
|
|
61
|
+
await log(` ⚠️ Could not inspect origin/${branchName}: ${divergence.fetchError || 'unknown error'}`, { level: 'warning' });
|
|
62
|
+
return divergence;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await log(` Branch state before PR bootstrap commit: ${divergence.ahead} commit(s) ahead, ${divergence.behind} commit(s) behind origin/${branchName}`);
|
|
66
|
+
|
|
67
|
+
if (divergence.behind > 0 && divergence.ahead === 0) {
|
|
68
|
+
await log(` Fast-forwarding ${branchName} to origin/${branchName} before creating the PR bootstrap commit...`);
|
|
69
|
+
const mergeResult = await $({ cwd: tempDir })`git merge --ff-only origin/${branchName} 2>&1`;
|
|
70
|
+
if (mergeResult.code !== 0) {
|
|
71
|
+
await log(` ⚠️ Fast-forward failed: ${outputOf(mergeResult) || 'unknown error'}`, {
|
|
72
|
+
level: 'warning',
|
|
73
|
+
});
|
|
74
|
+
throw new Error('Existing issue branch could not be fast-forwarded before PR creation');
|
|
75
|
+
}
|
|
76
|
+
await log(` ✅ Branch fast-forwarded to origin/${branchName}`);
|
|
77
|
+
return await getRemoteBranchDivergenceSnapshot({ $, tempDir, branchName });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (divergence.behind > 0 && divergence.ahead > 0) {
|
|
81
|
+
for (const line of buildPushRejectionExplanation({
|
|
82
|
+
branchName,
|
|
83
|
+
isContinueMode,
|
|
84
|
+
prNumber,
|
|
85
|
+
divergence,
|
|
86
|
+
})) {
|
|
87
|
+
await log(line);
|
|
88
|
+
}
|
|
89
|
+
throw new Error('Existing issue branch has diverged before PR creation; manual resolution required');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return divergence;
|
|
93
|
+
}
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -512,6 +512,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
512
512
|
description: 'Disable error issue creation entirely (no prompt, no automatic creation). Overrides --auto-report-issue if both are specified.',
|
|
513
513
|
default: false,
|
|
514
514
|
},
|
|
515
|
+
'disable-issue-auto-creation-on-error': {
|
|
516
|
+
type: 'boolean',
|
|
517
|
+
description: 'Disable creating a new GitHub error-report issue when solve fails, including the interactive prompt. This does not disable posting failure logs or comments to the original issue or pull request.',
|
|
518
|
+
default: false,
|
|
519
|
+
},
|
|
515
520
|
'attach-solution-summary': {
|
|
516
521
|
type: 'boolean',
|
|
517
522
|
description: 'Attach the AI working session summary (from the result field) as a comment to the PR/issue after every working session. The summary is extracted from the AI tool JSON output and posted under a "Working session summary" header. Applies to the top-level run, auto-restart-until-mergeable iterations, and watch-mode iterations.',
|
|
@@ -718,6 +723,12 @@ export const parseArguments = async (yargs = getLinoYargsFactory(), hideBinFn =
|
|
|
718
723
|
if (argv.toolCheck === false) {
|
|
719
724
|
argv.toolConnectionCheck = false;
|
|
720
725
|
}
|
|
726
|
+
// Issue #1752: new flag is the explicit user-facing switch for disabling
|
|
727
|
+
// creation of separate solver-error issues. Keep the existing internal
|
|
728
|
+
// disableReportIssue path as the single behavior flag.
|
|
729
|
+
if (argv.disableIssueAutoCreationOnError) {
|
|
730
|
+
argv.disableReportIssue = true;
|
|
731
|
+
}
|
|
721
732
|
}
|
|
722
733
|
|
|
723
734
|
// --finalize normalization
|
|
@@ -12,11 +12,14 @@ import { reportError } from './sentry.lib.mjs';
|
|
|
12
12
|
// Import GitHub error reporter
|
|
13
13
|
import { handleErrorWithIssueCreation } from './github-error-reporter.lib.mjs';
|
|
14
14
|
|
|
15
|
+
export const isErrorIssueAutoCreationDisabled = argv => !!(argv?.disableReportIssue || argv?.disableIssueAutoCreationOnError);
|
|
16
|
+
|
|
15
17
|
/**
|
|
16
18
|
* Handles log attachment and PR closing on failure
|
|
17
19
|
*/
|
|
18
20
|
export const handleFailure = async options => {
|
|
19
21
|
const { error, errorType, shouldAttachLogs, argv, global, owner, repo, log, getLogFile, attachLogToGitHub, cleanErrorMessage, sanitizeLogContent, $ } = options;
|
|
22
|
+
const disableIssueCreation = isErrorIssueAutoCreationDisabled(argv);
|
|
20
23
|
|
|
21
24
|
// Offer to create GitHub issue for the error
|
|
22
25
|
try {
|
|
@@ -30,9 +33,9 @@ export const handleFailure = async options => {
|
|
|
30
33
|
prNumber: global.createdPR?.number,
|
|
31
34
|
errorType,
|
|
32
35
|
},
|
|
33
|
-
skipPrompt: !process.stdin.isTTY || argv.noIssueCreation,
|
|
36
|
+
skipPrompt: !process.stdin.isTTY || argv.noIssueCreation || disableIssueCreation,
|
|
34
37
|
autoReport: argv.autoReportIssue,
|
|
35
|
-
disableReport:
|
|
38
|
+
disableReport: disableIssueCreation,
|
|
36
39
|
});
|
|
37
40
|
} catch (issueError) {
|
|
38
41
|
reportError(issueError, {
|
|
@@ -49,7 +52,7 @@ export const handleFailure = async options => {
|
|
|
49
52
|
const hasIssue = global.issueNumber;
|
|
50
53
|
const targetType = hasPR ? 'pr' : hasIssue ? 'issue' : null;
|
|
51
54
|
const targetNumber = hasPR ? global.createdPR.number : hasIssue ? global.issueNumber : null;
|
|
52
|
-
const targetLabel = hasPR ? 'Pull Request' :
|
|
55
|
+
const targetLabel = hasPR ? 'Pull Request' : `original issue #${targetNumber}`;
|
|
53
56
|
|
|
54
57
|
if (targetType && targetNumber) {
|
|
55
58
|
await log(`\n📄 Attempting to attach failure logs to ${targetLabel}...`);
|
|
@@ -70,7 +73,7 @@ export const handleFailure = async options => {
|
|
|
70
73
|
tool: argv.tool || 'claude',
|
|
71
74
|
});
|
|
72
75
|
if (logUploadSuccess) {
|
|
73
|
-
await log(`📎 Failure log
|
|
76
|
+
await log(`📎 Failure log posted to ${targetLabel}`);
|
|
74
77
|
if (!hasPR && hasIssue) global.prePullRequestFailureNotificationPosted = true;
|
|
75
78
|
}
|
|
76
79
|
} catch (attachError) {
|
|
@@ -81,7 +84,7 @@ export const handleFailure = async options => {
|
|
|
81
84
|
errorType,
|
|
82
85
|
operation: `attach_log_to_${targetType}`,
|
|
83
86
|
});
|
|
84
|
-
await log(`⚠️ Could not
|
|
87
|
+
await log(`⚠️ Could not post failure log to ${targetLabel}: ${attachError.message}`, { level: 'warning' });
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
}
|
package/src/solve.mjs
CHANGED
|
@@ -162,6 +162,12 @@ const { isIssueUrl, isPrUrl, normalizedUrl, owner, repo, number: urlNumber } = u
|
|
|
162
162
|
issueUrl = normalizedUrl || issueUrl;
|
|
163
163
|
global.owner = owner;
|
|
164
164
|
global.repo = repo;
|
|
165
|
+
// Issue #1752: failures before PR creation can happen during checks that run
|
|
166
|
+
// before the normal issue-mode setup below. Record the source issue as soon as
|
|
167
|
+
// the URL is validated so the pre-exit notifier can still comment on it.
|
|
168
|
+
if (isIssueUrl) {
|
|
169
|
+
global.issueNumber = urlNumber;
|
|
170
|
+
}
|
|
165
171
|
cleanupContext.owner = owner;
|
|
166
172
|
cleanupContext.repo = repo;
|
|
167
173
|
// Setup unhandled error handlers to ensure log path is always shown
|
|
@@ -331,6 +337,7 @@ if (autoContinueResult.isContinueMode) {
|
|
|
331
337
|
} else {
|
|
332
338
|
// We have a branch but no PR - we'll use the existing branch and create a PR later
|
|
333
339
|
await log(`🔄 Using existing branch: ${prBranch} (no PR yet - will create one)`);
|
|
340
|
+
await log(' This branch was created by an earlier run; this run is reusing it rather than creating a fresh branch.');
|
|
334
341
|
if (argv.verbose) {
|
|
335
342
|
await log(' Branch will be checked out and PR will be created during auto-PR creation phase', {
|
|
336
343
|
verbose: true,
|
|
@@ -1021,7 +1028,7 @@ try {
|
|
|
1021
1028
|
const hasIssue = global.issueNumber;
|
|
1022
1029
|
const logTargetType = hasPR ? 'pr' : hasIssue ? 'issue' : null;
|
|
1023
1030
|
const logTargetNumber = hasPR ? global.createdPR.number : hasIssue ? global.issueNumber : null;
|
|
1024
|
-
const logTargetLabel = hasPR ? 'Pull Request' :
|
|
1031
|
+
const logTargetLabel = hasPR ? 'Pull Request' : `original issue #${logTargetNumber}`;
|
|
1025
1032
|
|
|
1026
1033
|
if (shouldAttachLogs && logTargetType && logTargetNumber) {
|
|
1027
1034
|
await log(`\n📄 Attaching failure logs to ${logTargetLabel}...`);
|
|
@@ -1054,7 +1061,7 @@ try {
|
|
|
1054
1061
|
});
|
|
1055
1062
|
|
|
1056
1063
|
if (logUploadSuccess) {
|
|
1057
|
-
await log(` 📎 Failure logs
|
|
1064
|
+
await log(` 📎 Failure logs posted to ${logTargetLabel}`);
|
|
1058
1065
|
} else {
|
|
1059
1066
|
// Issue #1212: Always show log upload failures (not just verbose)
|
|
1060
1067
|
await log(' ⚠️ Failed to upload failure logs');
|