@link-assistant/hive-mind 1.65.1 → 1.66.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 +12 -0
- package/package.json +2 -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 +10 -5
- package/src/i18n.lib.mjs +174 -0
- package/src/limits.lib.mjs +3 -2
- package/src/locales/en.lino +93 -0
- package/src/locales/hi.lino +93 -0
- package/src/locales/ru.lino +93 -0
- package/src/locales/zh.lino +93 -0
- package/src/review.mjs +9 -0
- package/src/reviewers-hive.mjs +21 -19
- package/src/solve.auto-pr.lib.mjs +26 -17
- package/src/solve.config.lib.mjs +5 -0
- package/src/solve.mjs +4 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +4 -0
- package/src/telegram-bot.mjs +35 -21
- package/src/telegram-language-command.lib.mjs +49 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.66.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f744d5a: Add internationalisation (i18n) for user-facing terminal output and the Telegram bot. Translations are stored in `links-notation` files under `src/locales/` (`en`, `ru`, `zh`, `hi`) and loaded via `lino-objects-codec`. Adds a `--language <en|ru|zh|hi>` option to `solve`, `hive`, `task`, and `review` (defaults to detected system locale). The Telegram bot picks each user's language from `ctx.from.language_code` with a per-user override settable through a new `/language <code|default>` command (in-memory, resets on bot restart). Built-in commands `/limits`, `/version`, `/solve`, `/hive`, and `/language` now reply in the user's selected language. AI prompts are intentionally untouched - only human-facing strings are translated.
|
|
8
|
+
|
|
9
|
+
## 1.65.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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`.
|
|
14
|
+
|
|
3
15
|
## 1.65.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/hive-mind",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.66.0",
|
|
4
4
|
"description": "AI-powered issue solver and hive mind for collaborative problem solving",
|
|
5
5
|
"main": "src/hive.mjs",
|
|
6
6
|
"type": "module",
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
"dayjs": "^1.11.19",
|
|
77
77
|
"decimal.js-light": "^2.5.1",
|
|
78
78
|
"lino-arguments": "^0.3.0",
|
|
79
|
+
"lino-objects-codec": "^0.3.6",
|
|
79
80
|
"secretlint": "^11.2.5",
|
|
80
81
|
"semver": "^7.7.3"
|
|
81
82
|
},
|
|
@@ -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()
|
|
@@ -291,6 +292,10 @@ if (isRunningDirectly) {
|
|
|
291
292
|
// Set global verbose mode
|
|
292
293
|
global.verboseMode = argv.verbose;
|
|
293
294
|
|
|
295
|
+
// Initialize i18n based on --language (or detected system locale)
|
|
296
|
+
const { initI18n } = await import('./i18n.lib.mjs');
|
|
297
|
+
await initI18n(argv.language);
|
|
298
|
+
|
|
294
299
|
setupVerboseLogInterceptor(); // Issue #1466: capture [VERBOSE] output in log files
|
|
295
300
|
setupStdioLogInterceptor(); // Issue #1549: capture ALL terminal output in log file
|
|
296
301
|
|
package/src/i18n.lib.mjs
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// i18n module for hive-mind.
|
|
2
|
+
// - Translation files live in src/locales/<locale>.lino and are stored
|
|
3
|
+
// in Links Notation, parsed via lino-objects-codec.
|
|
4
|
+
// - Supported locales: en (default fallback), ru, zh, hi.
|
|
5
|
+
// - Public API: initI18n, t, getCurrentLocale, setLocale, getSupportedLocales,
|
|
6
|
+
// normalizeLocale, getUserLocale, setUserLocale, clearUserLocale,
|
|
7
|
+
// resolveLocaleFromTelegramCtx.
|
|
8
|
+
|
|
9
|
+
import { promises as fs } from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import { parseIndented } from 'lino-objects-codec';
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
15
|
+
const __dirname = path.dirname(__filename);
|
|
16
|
+
|
|
17
|
+
const DEFAULT_LOCALE = 'en';
|
|
18
|
+
const SUPPORTED_LOCALES = ['en', 'ru', 'zh', 'hi'];
|
|
19
|
+
|
|
20
|
+
const localeCache = new Map(); // locale -> { key: string }
|
|
21
|
+
const userLocales = new Map(); // userId/chatId -> locale (in-memory)
|
|
22
|
+
|
|
23
|
+
let currentLocale = DEFAULT_LOCALE;
|
|
24
|
+
let fallbackLoaded = false;
|
|
25
|
+
|
|
26
|
+
export function getSupportedLocales() {
|
|
27
|
+
return [...SUPPORTED_LOCALES];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeLocale(input) {
|
|
31
|
+
if (!input || typeof input !== 'string') return null;
|
|
32
|
+
const lower = input.toLowerCase();
|
|
33
|
+
// Take only the language part (before "_" or "-")
|
|
34
|
+
const lang = lower.split(/[_\-.]/)[0];
|
|
35
|
+
if (SUPPORTED_LOCALES.includes(lang)) return lang;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function detectLocale() {
|
|
40
|
+
const envLocale = process.env.LANG || process.env.LANGUAGE || process.env.LC_ALL || process.env.LC_MESSAGES || '';
|
|
41
|
+
return normalizeLocale(envLocale) || DEFAULT_LOCALE;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readLocaleFile(locale) {
|
|
45
|
+
const localesDir = path.join(__dirname, 'locales');
|
|
46
|
+
const linoFile = path.join(localesDir, `${locale}.lino`);
|
|
47
|
+
const data = await fs.readFile(linoFile, 'utf-8');
|
|
48
|
+
return parseIndentedToFlatMap(data);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// parseIndented returns { id, obj } where obj is the key->value map.
|
|
52
|
+
// Some keys contain dots (e.g., error.invalid_github_url). The parser
|
|
53
|
+
// supports them when the key is a plain reference (no spaces/quotes).
|
|
54
|
+
function unescapeString(s) {
|
|
55
|
+
// Convert literal escape sequences (e.g., "\n" inside a quoted string in
|
|
56
|
+
// Links Notation) into the corresponding JS characters. This keeps the
|
|
57
|
+
// .lino files single-line and human-friendly.
|
|
58
|
+
return s.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\r/g, '\r').replace(/\\\\/g, '\\');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseIndentedToFlatMap(text) {
|
|
62
|
+
const parsed = parseIndented({ text });
|
|
63
|
+
// parsed: { id: <localeName>, obj: { key: value, ... } }
|
|
64
|
+
if (!parsed || !parsed.obj) return {};
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [k, v] of Object.entries(parsed.obj)) {
|
|
67
|
+
out[k] = typeof v === 'string' ? unescapeString(v) : String(v);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function loadTranslations(locale) {
|
|
73
|
+
if (localeCache.has(locale)) return localeCache.get(locale);
|
|
74
|
+
|
|
75
|
+
let translations = {};
|
|
76
|
+
try {
|
|
77
|
+
translations = await readLocaleFile(locale);
|
|
78
|
+
} catch {
|
|
79
|
+
translations = {};
|
|
80
|
+
}
|
|
81
|
+
localeCache.set(locale, translations);
|
|
82
|
+
|
|
83
|
+
// Always have the fallback (English) ready
|
|
84
|
+
if (!fallbackLoaded && locale !== DEFAULT_LOCALE) {
|
|
85
|
+
try {
|
|
86
|
+
const fb = await readLocaleFile(DEFAULT_LOCALE);
|
|
87
|
+
localeCache.set(DEFAULT_LOCALE, fb);
|
|
88
|
+
} catch {
|
|
89
|
+
localeCache.set(DEFAULT_LOCALE, {});
|
|
90
|
+
}
|
|
91
|
+
fallbackLoaded = true;
|
|
92
|
+
} else if (locale === DEFAULT_LOCALE) {
|
|
93
|
+
fallbackLoaded = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return translations;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function initI18n(localeInput = null) {
|
|
100
|
+
const requested = localeInput ? normalizeLocale(localeInput) : null;
|
|
101
|
+
const detectedLocale = requested || detectLocale();
|
|
102
|
+
currentLocale = detectedLocale;
|
|
103
|
+
await loadTranslations(detectedLocale);
|
|
104
|
+
if (detectedLocale !== DEFAULT_LOCALE) {
|
|
105
|
+
await loadTranslations(DEFAULT_LOCALE);
|
|
106
|
+
}
|
|
107
|
+
return detectedLocale;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function applyParams(text, params) {
|
|
111
|
+
if (!params) return text;
|
|
112
|
+
let out = text;
|
|
113
|
+
for (const [k, v] of Object.entries(params)) {
|
|
114
|
+
out = out.replace(new RegExp(`{{${k}}}`, 'g'), String(v));
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function t(key, params = {}, options = {}) {
|
|
120
|
+
const locale = options.locale ? normalizeLocale(options.locale) || currentLocale : currentLocale;
|
|
121
|
+
const main = localeCache.get(locale) || {};
|
|
122
|
+
const fallback = localeCache.get(DEFAULT_LOCALE) || {};
|
|
123
|
+
const value = main[key] ?? fallback[key] ?? key;
|
|
124
|
+
return applyParams(value, params);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getCurrentLocale() {
|
|
128
|
+
return currentLocale;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function setLocale(locale) {
|
|
132
|
+
const normalized = normalizeLocale(locale);
|
|
133
|
+
if (normalized) currentLocale = normalized;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// In-memory per-user locale store (used by the Telegram bot).
|
|
137
|
+
export function getUserLocale(userId) {
|
|
138
|
+
if (userId === undefined || userId === null) return null;
|
|
139
|
+
return userLocales.get(String(userId)) || null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function setUserLocale(userId, locale) {
|
|
143
|
+
const normalized = normalizeLocale(locale);
|
|
144
|
+
if (!normalized || userId === undefined || userId === null) return false;
|
|
145
|
+
userLocales.set(String(userId), normalized);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function clearUserLocale(userId) {
|
|
150
|
+
if (userId === undefined || userId === null) return false;
|
|
151
|
+
return userLocales.delete(String(userId));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Resolve the best locale for a Telegram update context.
|
|
155
|
+
// Priority: per-user override -> ctx.from.language_code -> current default.
|
|
156
|
+
export function resolveLocaleFromTelegramCtx(ctx) {
|
|
157
|
+
const userId = ctx?.from?.id;
|
|
158
|
+
const userOverride = getUserLocale(userId);
|
|
159
|
+
if (userOverride) return userOverride;
|
|
160
|
+
const fromTelegram = normalizeLocale(ctx?.from?.language_code);
|
|
161
|
+
if (fromTelegram) return fromTelegram;
|
|
162
|
+
return currentLocale;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Pre-load every supported locale (handy for the Telegram bot at startup).
|
|
166
|
+
export async function preloadAllLocales() {
|
|
167
|
+
for (const loc of SUPPORTED_LOCALES) {
|
|
168
|
+
try {
|
|
169
|
+
await loadTranslations(loc);
|
|
170
|
+
} catch {
|
|
171
|
+
// ignore - missing files fall back to English
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
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) {
|