@link-assistant/hive-mind 2.19.1 → 2.20.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 +28 -0
- package/README.hi.md +5 -0
- package/README.md +5 -0
- package/README.ru.md +5 -0
- package/README.zh.md +5 -0
- package/package.json +1 -1
- package/src/pull-request-changes.lib.mjs +147 -18
- package/src/solve.auto-merge-guards.lib.mjs +28 -0
- package/src/solve.auto-merge.lib.mjs +13 -1
- package/src/solve.config.lib.mjs +26 -0
- package/src/solve.ensure-sub-issues.detect.lib.mjs +175 -0
- package/src/solve.ensure-sub-issues.lib.mjs +276 -0
- package/src/solve.mjs +38 -2
- package/src/solve.repository-mode.lib.mjs +233 -0
- package/src/solve.repository-mode.run.lib.mjs +326 -0
- package/src/telegram-bot.mjs +5 -2
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub-backed orchestration for `/solve <github-repository-url>` — repository
|
|
5
|
+
* mode (issue #2212).
|
|
6
|
+
*
|
|
7
|
+
* Flow, mirroring how `/fix --ci-cd` turns a repository into a solvable issue:
|
|
8
|
+
* 1. list every open issue of the repository (oldest first, pull requests
|
|
9
|
+
* excluded),
|
|
10
|
+
* 2. create one combined issue that lists them,
|
|
11
|
+
* 3. attach each of them as a GitHub native sub-issue of the combined issue
|
|
12
|
+
* (at most 100 — GitHub's per-parent limit),
|
|
13
|
+
* 4. hand the combined issue back to `/solve`, which then runs its normal
|
|
14
|
+
* single-issue flow with `--deep-analysis` and
|
|
15
|
+
* `--ensure-all-sub-issues-addressed` enabled.
|
|
16
|
+
*
|
|
17
|
+
* The pure helpers live in `solve.repository-mode.lib.mjs`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from 'child_process';
|
|
21
|
+
import { describeChildExit } from './child-exit.lib.mjs';
|
|
22
|
+
import { parseGitHubUrl } from './github-url-parser.lib.mjs';
|
|
23
|
+
import { createTaskIssue } from './task.issue-creation.lib.mjs';
|
|
24
|
+
import { buildAddSubIssueApiArgs } from './task.split.lib.mjs';
|
|
25
|
+
import { MAX_SUB_ISSUES_PER_PARENT, buildCombinedIssueBody, buildCombinedIssueTitle, buildOpenIssuesApiArgs, buildRepositoryModeSummaryLines, selectOldestOpenIssues } from './solve.repository-mode.lib.mjs';
|
|
26
|
+
import { isRateLimitError } from './github-rate-limit.lib.mjs';
|
|
27
|
+
|
|
28
|
+
/** Labels applied best-effort to the generated combined issue. */
|
|
29
|
+
export const REPOSITORY_MODE_ISSUE_LABELS = Object.freeze(['enhancement']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Pause between two sub-issue POSTs.
|
|
33
|
+
*
|
|
34
|
+
* GitHub's own documentation warns that "creating content too quickly using
|
|
35
|
+
* this endpoint may result in secondary rate limiting"
|
|
36
|
+
* (https://docs.github.com/en/rest/issues/sub-issues), and its best-practice
|
|
37
|
+
* guide asks for at least one second between mutative requests. Attaching 100
|
|
38
|
+
* sub-issues therefore costs about a minute — negligible next to a solve run,
|
|
39
|
+
* and much cheaper than being throttled halfway through.
|
|
40
|
+
*/
|
|
41
|
+
export const SUB_ISSUE_ATTACH_DELAY_MS = 1000;
|
|
42
|
+
|
|
43
|
+
/** Attempts per sub-issue when GitHub answers with a rate-limit error. */
|
|
44
|
+
export const SUB_ISSUE_ATTACH_MAX_ATTEMPTS = 3;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Backoff before retrying a rate-limited sub-issue attachment.
|
|
48
|
+
*
|
|
49
|
+
* GitHub's best-practice guide asks to "wait for at least one minute before
|
|
50
|
+
* retrying" a secondary rate-limit error, then "an exponentially increasing
|
|
51
|
+
* amount of time between retries"
|
|
52
|
+
* (https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#handle-rate-limit-errors-appropriately).
|
|
53
|
+
*/
|
|
54
|
+
export const SUB_ISSUE_ATTACH_BACKOFF_MS = Object.freeze([60000, 120000]);
|
|
55
|
+
|
|
56
|
+
const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
57
|
+
|
|
58
|
+
function runCommand(command, args, options = {}) {
|
|
59
|
+
return new Promise(resolve => {
|
|
60
|
+
const child = spawn(command, args, {
|
|
61
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
62
|
+
env: process.env,
|
|
63
|
+
...options,
|
|
64
|
+
});
|
|
65
|
+
let stdout = '';
|
|
66
|
+
let stderr = '';
|
|
67
|
+
child.stdout.on('data', data => {
|
|
68
|
+
stdout += data.toString();
|
|
69
|
+
});
|
|
70
|
+
child.stderr.on('data', data => {
|
|
71
|
+
stderr += data.toString();
|
|
72
|
+
});
|
|
73
|
+
child.on('error', error => {
|
|
74
|
+
resolve({ code: 1, stdout, stderr: stderr || error.message });
|
|
75
|
+
});
|
|
76
|
+
child.on('close', (code, signal) => {
|
|
77
|
+
resolve({ code, stdout, stderr, signal });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function commandOutput(run, command, args) {
|
|
83
|
+
const result = await run(command, args);
|
|
84
|
+
if (result.code !== 0) {
|
|
85
|
+
const output = `${result.stderr || ''}${result.stdout || ''}`.trim();
|
|
86
|
+
// Issue #2135: `describeChildExit` names a signal instead of "code null".
|
|
87
|
+
throw new Error(output || describeChildExit({ command, code: result.code, signal: result.signal }));
|
|
88
|
+
}
|
|
89
|
+
return result.stdout.trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse a repository URL into the `{owner, repo, fullName, url}` shape the rest
|
|
94
|
+
* of this module (and `createTaskIssue`) expects.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} url
|
|
97
|
+
* @returns {{owner: string, repo: string, fullName: string, url: string}|null}
|
|
98
|
+
*/
|
|
99
|
+
export function parseRepositoryModeUrl(url) {
|
|
100
|
+
const parsed = parseGitHubUrl(url);
|
|
101
|
+
if (!parsed.valid || parsed.type !== 'repo') return null;
|
|
102
|
+
return {
|
|
103
|
+
owner: parsed.owner,
|
|
104
|
+
repo: parsed.repo,
|
|
105
|
+
fullName: `${parsed.owner}/${parsed.repo}`,
|
|
106
|
+
url: parsed.normalized || `https://github.com/${parsed.owner}/${parsed.repo}`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Fetch every open issue of a repository (pull requests included — the caller
|
|
112
|
+
* filters them out via `selectOldestOpenIssues`).
|
|
113
|
+
*
|
|
114
|
+
* @param {object} params
|
|
115
|
+
* @param {{owner: string, repo: string}} params.repository
|
|
116
|
+
* @param {Function} [params.run]
|
|
117
|
+
* @returns {Promise<Array<object>>}
|
|
118
|
+
*/
|
|
119
|
+
export async function fetchOpenIssues({ repository, run = runCommand }) {
|
|
120
|
+
const output = await commandOutput(run, 'gh', buildOpenIssuesApiArgs({ owner: repository.owner, repo: repository.repo }));
|
|
121
|
+
const parsed = JSON.parse(output || '[]');
|
|
122
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Collect the data for the combined issue without creating anything.
|
|
127
|
+
*
|
|
128
|
+
* @param {object} params
|
|
129
|
+
* @param {{owner: string, repo: string, fullName: string, url: string}} params.repository
|
|
130
|
+
* @param {number} [params.limit=MAX_SUB_ISSUES_PER_PARENT]
|
|
131
|
+
* @param {Function} [params.run]
|
|
132
|
+
* @returns {Promise<{repository, selected, totalOpen, skipped, title, body}>}
|
|
133
|
+
*/
|
|
134
|
+
export async function prepareRepositoryModeIssue({ repository, limit = MAX_SUB_ISSUES_PER_PARENT, run = runCommand }) {
|
|
135
|
+
const entries = await fetchOpenIssues({ repository, run });
|
|
136
|
+
const { selected, totalOpen, skipped } = selectOldestOpenIssues(entries, { limit });
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
repository,
|
|
140
|
+
selected,
|
|
141
|
+
totalOpen,
|
|
142
|
+
skipped,
|
|
143
|
+
limit,
|
|
144
|
+
title: buildCombinedIssueTitle({ owner: repository.owner, repo: repository.repo, count: selected.length, totalOpen }),
|
|
145
|
+
body: buildCombinedIssueBody({ repository, issues: selected, totalOpen, limit }),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Attach the selected issues to the combined issue as GitHub native sub-issues.
|
|
151
|
+
*
|
|
152
|
+
* Failures are non-fatal and reported: an issue that already has a different
|
|
153
|
+
* parent is rejected by the API, and losing the whole run over one such issue
|
|
154
|
+
* would be worse than solving the rest (the issue is still listed in the
|
|
155
|
+
* combined issue body either way).
|
|
156
|
+
*
|
|
157
|
+
* A rate-limited attachment is retried with a bounded backoff, and the requests
|
|
158
|
+
* are spaced out, because this endpoint is explicitly documented as prone to
|
|
159
|
+
* secondary rate limiting when content is created quickly.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} params
|
|
162
|
+
* @param {{owner: string, repo: string, number: number}} params.parentIssue
|
|
163
|
+
* @param {Array<{number: number, id: number}>} params.issues
|
|
164
|
+
* @param {Function} [params.run]
|
|
165
|
+
* @param {Function} [params.log]
|
|
166
|
+
* @param {number} [params.delayMs] - pause between requests (0 disables it)
|
|
167
|
+
* @param {number} [params.maxAttempts] - attempts per sub-issue on rate limits
|
|
168
|
+
* @param {Function} [params.sleep] - test override for the waiting
|
|
169
|
+
* @returns {Promise<{attached: Array<object>, failed: Array<{issue: object, error: string}>}>}
|
|
170
|
+
*/
|
|
171
|
+
export async function attachSubIssues({ parentIssue, issues, run = runCommand, log = null, delayMs = SUB_ISSUE_ATTACH_DELAY_MS, maxAttempts = SUB_ISSUE_ATTACH_MAX_ATTEMPTS, sleep = defaultSleep }) {
|
|
172
|
+
const attached = [];
|
|
173
|
+
const failed = [];
|
|
174
|
+
const list = Array.isArray(issues) ? issues : [];
|
|
175
|
+
|
|
176
|
+
for (let index = 0; index < list.length; index++) {
|
|
177
|
+
const issue = list[index];
|
|
178
|
+
if (index > 0 && delayMs > 0) await sleep(delayMs);
|
|
179
|
+
|
|
180
|
+
let lastError = null;
|
|
181
|
+
for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt++) {
|
|
182
|
+
try {
|
|
183
|
+
if (!Number.isInteger(issue.id) || issue.id <= 0) {
|
|
184
|
+
throw new Error(`missing REST id for issue #${issue.number}`);
|
|
185
|
+
}
|
|
186
|
+
await commandOutput(run, 'gh', buildAddSubIssueApiArgs({ parentIssue, subIssueId: issue.id }));
|
|
187
|
+
lastError = null;
|
|
188
|
+
break;
|
|
189
|
+
} catch (error) {
|
|
190
|
+
lastError = error;
|
|
191
|
+
// Only rate limits are worth retrying: "already has a parent" and the
|
|
192
|
+
// like would fail identically however long we wait.
|
|
193
|
+
if (attempt >= Math.max(1, maxAttempts) || !isRateLimitError(error)) break;
|
|
194
|
+
const waitMs = SUB_ISSUE_ATTACH_BACKOFF_MS[Math.min(attempt - 1, SUB_ISSUE_ATTACH_BACKOFF_MS.length - 1)];
|
|
195
|
+
await log?.(` ⏳ Rate limited while attaching #${issue.number}; retrying in ${Math.round(waitMs / 1000)}s (attempt ${attempt + 1}/${maxAttempts})...`);
|
|
196
|
+
await sleep(waitMs);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (lastError) {
|
|
201
|
+
const message = lastError?.message ? String(lastError.message).split('\n')[0] : String(lastError);
|
|
202
|
+
failed.push({ issue, error: message });
|
|
203
|
+
await log?.(` ⚠️ Could not attach #${issue.number} as a sub-issue: ${message}`);
|
|
204
|
+
} else {
|
|
205
|
+
attached.push(issue);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { attached, failed };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Create the combined issue and attach the sub-issues.
|
|
214
|
+
*
|
|
215
|
+
* @param {object} params
|
|
216
|
+
* @returns {Promise<{owner, repo, number, url, prepared, attached, failed}>}
|
|
217
|
+
*/
|
|
218
|
+
export async function createRepositoryModeIssue({ repository, prepared, run = runCommand, log = null, attachOptions = {} }) {
|
|
219
|
+
const issue = await createTaskIssue({
|
|
220
|
+
repository,
|
|
221
|
+
title: prepared.title,
|
|
222
|
+
body: prepared.body,
|
|
223
|
+
labels: [...REPOSITORY_MODE_ISSUE_LABELS],
|
|
224
|
+
run,
|
|
225
|
+
log,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const { attached, failed } = await attachSubIssues({
|
|
229
|
+
parentIssue: { owner: issue.owner, repo: issue.repo, number: issue.number },
|
|
230
|
+
issues: prepared.selected,
|
|
231
|
+
run,
|
|
232
|
+
log,
|
|
233
|
+
...attachOptions,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
return { ...issue, prepared, attached, failed };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Entry point used by solve.mjs.
|
|
241
|
+
*
|
|
242
|
+
* Returns `{ handled: false }` when the URL is not a repository URL so the
|
|
243
|
+
* caller can continue with its normal issue/pull-request validation.
|
|
244
|
+
*
|
|
245
|
+
* @param {object} params
|
|
246
|
+
* @param {string} params.url
|
|
247
|
+
* @param {Function} [params.log]
|
|
248
|
+
* @param {Function} [params.run]
|
|
249
|
+
* @param {number} [params.limit]
|
|
250
|
+
* @param {boolean} [params.dryRun] - prepare only; do not create anything
|
|
251
|
+
* @param {object} [params.attachOptions] - forwarded to {@link attachSubIssues}
|
|
252
|
+
* @returns {Promise<{handled: boolean, issueUrl?: string, issue?: object, prepared?: object, argvOverrides?: object, error?: string}>}
|
|
253
|
+
*/
|
|
254
|
+
export async function resolveRepositoryModeTarget({ url, log = null, run = runCommand, limit = MAX_SUB_ISSUES_PER_PARENT, dryRun = false, attachOptions = {} }) {
|
|
255
|
+
const repository = parseRepositoryModeUrl(url);
|
|
256
|
+
if (!repository) return { handled: false };
|
|
257
|
+
|
|
258
|
+
const emit = async message => {
|
|
259
|
+
if (typeof log === 'function') await log(message);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
await emit('');
|
|
263
|
+
await emit(`📦 REPOSITORY MODE: ${repository.url}`);
|
|
264
|
+
await emit(' Collecting all open issues to combine them into a single issue...');
|
|
265
|
+
|
|
266
|
+
let prepared;
|
|
267
|
+
try {
|
|
268
|
+
prepared = await prepareRepositoryModeIssue({ repository, limit, run });
|
|
269
|
+
} catch (error) {
|
|
270
|
+
return { handled: true, error: `Could not list open issues of ${repository.fullName}: ${error.message}` };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const line of buildRepositoryModeSummaryLines({ totalOpen: prepared.totalOpen, selectedCount: prepared.selected.length, skipped: prepared.skipped, limit })) {
|
|
274
|
+
await emit(line);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (prepared.selected.length === 0) {
|
|
278
|
+
return { handled: true, error: `${repository.fullName} has no open issues to solve.` };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (dryRun) {
|
|
282
|
+
return { handled: true, dryRun: true, prepared };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await emit('');
|
|
286
|
+
await emit('📝 Creating the combined issue...');
|
|
287
|
+
await emit(` Then attaching ${prepared.selected.length} issue(s) as sub-issues, one request per second to stay clear of GitHub's secondary rate limit...`);
|
|
288
|
+
|
|
289
|
+
let issue;
|
|
290
|
+
try {
|
|
291
|
+
issue = await createRepositoryModeIssue({ repository, prepared, run, log: emit, attachOptions });
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return { handled: true, error: `Could not create the combined issue in ${repository.fullName}: ${error.message}` };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await emit(`✅ Created combined issue: ${issue.url}`);
|
|
297
|
+
await emit(` Sub-issues attached: ${issue.attached.length}/${prepared.selected.length}${issue.failed.length > 0 ? ` (${issue.failed.length} could not be attached)` : ''}`);
|
|
298
|
+
await emit(' Continuing with the normal /solve flow for that issue.');
|
|
299
|
+
await emit('');
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
handled: true,
|
|
303
|
+
issueUrl: issue.url,
|
|
304
|
+
issue,
|
|
305
|
+
prepared,
|
|
306
|
+
// Repository mode always asks for deep analysis (like /fix) and always
|
|
307
|
+
// double checks that the pull request description lists every issue.
|
|
308
|
+
argvOverrides: {
|
|
309
|
+
'deep-analysis': true,
|
|
310
|
+
deepAnalysis: true,
|
|
311
|
+
'ensure-all-sub-issues-addressed': true,
|
|
312
|
+
ensureAllSubIssuesAddressed: true,
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export default {
|
|
318
|
+
REPOSITORY_MODE_ISSUE_LABELS,
|
|
319
|
+
SUB_ISSUE_ATTACH_DELAY_MS,
|
|
320
|
+
parseRepositoryModeUrl,
|
|
321
|
+
fetchOpenIssues,
|
|
322
|
+
prepareRepositoryModeIssue,
|
|
323
|
+
attachSubIssues,
|
|
324
|
+
createRepositoryModeIssue,
|
|
325
|
+
resolveRepositoryModeTarget,
|
|
326
|
+
};
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -612,7 +612,10 @@ async function handleSolveCommand(ctx) {
|
|
|
612
612
|
return;
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
-
|
|
615
|
+
// Issue #2212: a repository URL is accepted too — solve then collects every open
|
|
616
|
+
// issue of that repository into one combined issue (with GitHub native sub-issues)
|
|
617
|
+
// and solves that issue, so a single pull request can close all of them.
|
|
618
|
+
const validation = await validateGitHubUrl(userArgs, { allowedTypes: ['issue', 'pull', 'repo'], createYargsConfig: createSolveYargsConfig, positionalNames: ['issue-url'], locale: solveLocale });
|
|
616
619
|
if (!validation.valid) {
|
|
617
620
|
let errorMsg = `❌ ${validation.error}`;
|
|
618
621
|
if (validation.suggestion) {
|
|
@@ -705,7 +708,7 @@ async function handleSolveCommand(ctx) {
|
|
|
705
708
|
let infoBlock = buildTelegramInfoBlock({
|
|
706
709
|
locale: solveLocale,
|
|
707
710
|
requester,
|
|
708
|
-
urlKind: validation.parsed?.type === 'pull' ? 'pullRequest' : 'issue',
|
|
711
|
+
urlKind: validation.parsed?.type === 'pull' ? 'pullRequest' : validation.parsed?.type === 'repo' ? 'url' : 'issue', // #2212: a repository URL is neither an issue nor a pull request
|
|
709
712
|
url: escapeMarkdown(normalizedUrl),
|
|
710
713
|
optionsRaw: userOptionsRaw ? escapeMarkdown(userOptionsRaw) : '',
|
|
711
714
|
lockedOptions: solveOverrides.length > 0 ? escapeMarkdown(solveOverrides.join(' ')) : '',
|