@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.
@@ -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
+ };
@@ -612,7 +612,10 @@ async function handleSolveCommand(ctx) {
612
612
  return;
613
613
  }
614
614
 
615
- const validation = await validateGitHubUrl(userArgs, { createYargsConfig: createSolveYargsConfig, positionalNames: ['issue-url'], locale: solveLocale });
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(' ')) : '',