@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,276 @@
1
+ #!/usr/bin/env node
2
+ import { QUIET_PROBE } from './quiet-probe.lib.mjs';
3
+ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
4
+
5
+ /**
6
+ * `--ensure-all-sub-issues-addressed` restart loop for solve.mjs (issue #2212).
7
+ *
8
+ * After the main solve completes, this module lists the GitHub native
9
+ * sub-issues of the issue being solved and checks that the pull request
10
+ * description closes every one of them with a reference GitHub actually
11
+ * recognizes. When references are missing it restarts the AI tool, asking it to
12
+ * double check that each of those sub-issues was really addressed in this single
13
+ * pull request and to add the missing closing references. It keeps restarting
14
+ * until nothing is missing or the configured restart limit is reached.
15
+ *
16
+ * This is what makes `/solve <repository-url>` safe: the combined issue's
17
+ * sub-issues are exactly the repository's open issues, so the check guarantees
18
+ * the single pull request lists all of them and closes them on merge.
19
+ *
20
+ * The pure detection helpers live in `solve.ensure-sub-issues.detect.lib.mjs`.
21
+ *
22
+ * @see https://github.com/link-assistant/hive-mind/issues/2212
23
+ */
24
+
25
+ // Check if use is already defined globally (when imported from solve.mjs)
26
+ // If not, fetch it (when running standalone)
27
+ if (typeof globalThis.use === 'undefined') {
28
+ await ensureUseM();
29
+ }
30
+ const use = globalThis.use;
31
+
32
+ // Use command-stream for consistent $ behavior across runtimes
33
+ const { $: __rawDollar$ } = await use('command-stream');
34
+ const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
35
+ const $ = wrapDollarWithGhRetry(__rawDollar$);
36
+
37
+ const lib = await import('./lib.mjs');
38
+ const { log, cleanErrorMessage } = lib;
39
+
40
+ const restartShared = await import('./solve.restart-shared.lib.mjs');
41
+ const { executeToolIteration, isApiError, isUsageLimitReached } = restartShared;
42
+
43
+ const sentryLib = await import('./sentry.lib.mjs');
44
+ const { reportError } = sentryLib;
45
+
46
+ const detectLib = await import('./solve.ensure-sub-issues.detect.lib.mjs');
47
+ const { DEFAULT_ENSURE_SUB_ISSUES_LIMIT, ENSURE_SUB_ISSUES_PROMPT, buildEnsureSubIssuesFeedback, buildMissingReferenceBlock, findMissingSubIssueReferences, formatEnsureSubIssuesLimit, normalizeEnsureSubIssuesLimit, normalizeSubIssueEntry } = detectLib;
48
+
49
+ // Re-export the pure helpers so importers only need this module.
50
+ export { DEFAULT_ENSURE_SUB_ISSUES_LIMIT, ENSURE_SUB_ISSUES_PROMPT, buildEnsureSubIssuesFeedback, buildMissingReferenceBlock, findMissingSubIssueReferences, formatEnsureSubIssuesLimit, normalizeEnsureSubIssuesLimit, normalizeSubIssueEntry };
51
+
52
+ /**
53
+ * Hard cap on consecutive AI errors, so "unlimited" cannot spin forever.
54
+ * Mirrors the keep-working loop (issue #1883).
55
+ */
56
+ const MAX_CONSECUTIVE_ERRORS = 3;
57
+
58
+ /**
59
+ * List the GitHub native sub-issues of an issue.
60
+ *
61
+ * `--paginate` is required: the endpoint returns 30 entries per page by default
62
+ * and a parent may have up to 100 sub-issues.
63
+ *
64
+ * @param {object} params
65
+ * @returns {Promise<Array<object>>}
66
+ */
67
+ export const fetchSubIssues = async ({ owner, repo, issueNumber }) => {
68
+ // Issue #2135: `mirror: false` — the payload is inspected here, not shown.
69
+ const result = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/issues/${issueNumber}/sub_issues --paginate`;
70
+ if (result.code !== 0) {
71
+ const output = (result.stderr || result.stdout || '').toString().trim();
72
+ throw new Error(output || `gh api sub_issues exited with code ${result.code}`);
73
+ }
74
+ const parsed = JSON.parse(result.stdout.toString() || '[]');
75
+ return Array.isArray(parsed) ? parsed : [];
76
+ };
77
+
78
+ /**
79
+ * Fetch the pull request description (and title, so a closing reference placed
80
+ * in the title is honored too).
81
+ *
82
+ * @param {object} params
83
+ * @returns {Promise<string>}
84
+ */
85
+ export const fetchPullRequestText = async ({ owner, repo, prNumber }) => {
86
+ // Issue #2135: `mirror: false` — the body can be very large.
87
+ const result = await $(QUIET_PROBE)`gh api repos/${owner}/${repo}/pulls/${prNumber}`;
88
+ if (result.code !== 0) {
89
+ const output = (result.stderr || result.stdout || '').toString().trim();
90
+ throw new Error(output || `gh api pulls exited with code ${result.code}`);
91
+ }
92
+ const pr = JSON.parse(result.stdout.toString() || '{}');
93
+ // The title is included on purpose: a closing reference is recognized by
94
+ // GitHub in the pull request title as well as in its description.
95
+ return [pr.title || '', pr.body || ''].join('\n\n');
96
+ };
97
+
98
+ /**
99
+ * Runs the `--ensure-all-sub-issues-addressed` restart iterations.
100
+ *
101
+ * @param {object} params
102
+ * @param {string} params.issueUrl
103
+ * @param {string} params.owner
104
+ * @param {string} params.repo
105
+ * @param {string|number} params.issueNumber
106
+ * @param {string|number} params.prNumber
107
+ * @param {string} params.branchName
108
+ * @param {string} params.tempDir
109
+ * @param {string} [params.workspaceTmpDir]
110
+ * @param {object} params.argv
111
+ * @param {function} params.cleanupClaudeFile
112
+ * @returns {Promise<{sessionId, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo}|null>}
113
+ */
114
+ export const runEnsureAllSubIssuesAddressed = async ({ issueUrl, owner, repo, issueNumber, prNumber, branchName, tempDir, workspaceTmpDir, argv, cleanupClaudeFile }) => {
115
+ const limit = normalizeEnsureSubIssuesLimit(argv.ensureAllSubIssuesAddressed ?? argv['ensure-all-sub-issues-addressed']);
116
+ if (!limit || !prNumber || !issueNumber) {
117
+ return null;
118
+ }
119
+
120
+ await log('');
121
+ await log(`🧩 ENSURE-SUB-ISSUES: Verifying the pull request description closes every sub-issue of #${issueNumber} (limit: ${formatEnsureSubIssuesLimit(limit)} restart(s))`);
122
+
123
+ let subIssues;
124
+ try {
125
+ subIssues = await fetchSubIssues({ owner, repo, issueNumber });
126
+ } catch (error) {
127
+ reportError(error, { context: 'ensure_sub_issues_fetch', owner, repo, issueNumber, operation: 'fetch_sub_issues' });
128
+ await log(`⚠️ ENSURE-SUB-ISSUES: Could not list sub-issues: ${cleanErrorMessage(error)}`, { level: 'warning' });
129
+ return null;
130
+ }
131
+
132
+ if (subIssues.length === 0) {
133
+ await log(`✅ ENSURE-SUB-ISSUES: Issue #${issueNumber} has no sub-issues. Nothing to verify.`);
134
+ return null;
135
+ }
136
+
137
+ await log(` Sub-issues to verify: ${subIssues.length}`);
138
+
139
+ // Merge state is only used to enrich the restart prompt; a failure here is
140
+ // never a reason to skip the check.
141
+ let currentMergeStateStatus = null;
142
+ try {
143
+ const prStateResult = await $`gh api repos/${owner}/${repo}/pulls/${prNumber} --jq '.mergeStateStatus'`;
144
+ if (prStateResult.code === 0) {
145
+ currentMergeStateStatus = prStateResult.stdout.toString().trim();
146
+ }
147
+ } catch {
148
+ // Ignore errors getting merge state
149
+ }
150
+
151
+ let sessionId;
152
+ let anthropicTotalCostUSD;
153
+ let publicPricingEstimate;
154
+ let pricingInfo;
155
+ let consecutiveErrors = 0;
156
+ let iteration = 0;
157
+
158
+ while (true) {
159
+ let prText;
160
+ try {
161
+ prText = await fetchPullRequestText({ owner, repo, prNumber });
162
+ } catch (error) {
163
+ reportError(error, { context: 'ensure_sub_issues_fetch_pr', owner, repo, prNumber, operation: 'fetch_pr_body' });
164
+ await log(`⚠️ ENSURE-SUB-ISSUES: Could not read the pull request description: ${cleanErrorMessage(error)}`, { level: 'warning' });
165
+ break;
166
+ }
167
+
168
+ const { missing, total } = findMissingSubIssueReferences({ text: prText, subIssues, owner, repo });
169
+
170
+ if (missing.length === 0) {
171
+ if (iteration === 0) {
172
+ await log(`✅ ENSURE-SUB-ISSUES: All ${total} sub-issue(s) are closed by the pull request description.`);
173
+ } else {
174
+ await log(`✅ ENSURE-SUB-ISSUES: All ${total} sub-issue(s) are closed by the pull request description after ${iteration} restart(s).`);
175
+ }
176
+ break;
177
+ }
178
+
179
+ if (iteration >= limit) {
180
+ await log(`🛑 ENSURE-SUB-ISSUES: Reached the restart limit (${formatEnsureSubIssuesLimit(limit)}) with ${missing.length}/${total} sub-issue(s) still missing a closing reference.`);
181
+ for (const subIssue of missing.slice(0, 20)) {
182
+ await log(` • #${subIssue.number}${subIssue.title ? ` — ${subIssue.title}` : ''}`);
183
+ }
184
+ await log(' Add these lines to the pull request description to close them on merge:');
185
+ await log(buildMissingReferenceBlock(missing, { owner, repo }));
186
+ break;
187
+ }
188
+
189
+ iteration++;
190
+ await log('');
191
+ await log(`🔁 ENSURE-SUB-ISSUES iteration ${iteration}/${formatEnsureSubIssuesLimit(limit)}: ${missing.length}/${total} sub-issue(s) missing a closing reference, restarting...`);
192
+ for (const subIssue of missing.slice(0, 20)) {
193
+ await log(` • #${subIssue.number}${subIssue.title ? ` — ${subIssue.title}` : ''}`);
194
+ }
195
+
196
+ // Issue #1572 pattern: sync the local branch with remote before restarting.
197
+ try {
198
+ const pullResult = await $({ cwd: tempDir })`git pull origin ${branchName} 2>&1`;
199
+ if (pullResult.code === 0) {
200
+ await log(` Synced local branch ${branchName} from remote`, { verbose: true });
201
+ } else {
202
+ await log(` Warning: git pull failed (code ${pullResult.code}); continuing with local state`, { level: 'warning' });
203
+ }
204
+ } catch (error) {
205
+ reportError(error, { context: 'ensure_sub_issues_git_pull', branchName, operation: 'git_pull' });
206
+ await log(` Warning: git pull error: ${cleanErrorMessage(error)}`, { level: 'warning' });
207
+ }
208
+
209
+ const feedbackLines = buildEnsureSubIssuesFeedback({ missing, total, iteration, limit, owner, repo, issueNumber });
210
+
211
+ const iterationResult = await executeToolIteration({
212
+ issueUrl,
213
+ owner,
214
+ repo,
215
+ issueNumber,
216
+ prNumber,
217
+ branchName,
218
+ tempDir,
219
+ workspaceTmpDir,
220
+ mergeStateStatus: currentMergeStateStatus,
221
+ feedbackLines,
222
+ argv: {
223
+ ...argv,
224
+ promptEnsureAllRequirementsAreMet: true,
225
+ // Prevent recursion inside the restart iteration.
226
+ ensureAllSubIssuesAddressed: 0,
227
+ 'ensure-all-sub-issues-addressed': 0,
228
+ },
229
+ });
230
+
231
+ if (iterationResult) {
232
+ if (iterationResult.sessionId) sessionId = iterationResult.sessionId;
233
+ if (iterationResult.anthropicTotalCostUSD) anthropicTotalCostUSD = iterationResult.anthropicTotalCostUSD;
234
+ if (iterationResult.publicPricingEstimate) publicPricingEstimate = iterationResult.publicPricingEstimate;
235
+ if (iterationResult.pricingInfo) pricingInfo = iterationResult.pricingInfo;
236
+ }
237
+
238
+ if (isUsageLimitReached(iterationResult)) {
239
+ await log('🛑 ENSURE-SUB-ISSUES: Usage limit reached during restart. Stopping.');
240
+ break;
241
+ }
242
+ if (isApiError(iterationResult)) {
243
+ consecutiveErrors++;
244
+ await log(`⚠️ ENSURE-SUB-ISSUES: API error during restart (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS} consecutive).`, { level: 'warning' });
245
+ if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
246
+ await log('🛑 ENSURE-SUB-ISSUES: Too many consecutive errors. Stopping.');
247
+ break;
248
+ }
249
+ } else {
250
+ consecutiveErrors = 0;
251
+ }
252
+
253
+ await log(`✅ ENSURE-SUB-ISSUES iteration ${iteration}/${formatEnsureSubIssuesLimit(limit)} complete`);
254
+ await log('');
255
+ }
256
+
257
+ try {
258
+ await cleanupClaudeFile?.(tempDir, branchName, null, argv);
259
+ } catch (error) {
260
+ reportError(error, { context: 'ensure_sub_issues_cleanup', branchName, operation: 'cleanup_claude_file' });
261
+ }
262
+
263
+ if (iteration === 0) return null;
264
+ return { sessionId, anthropicTotalCostUSD, publicPricingEstimate, pricingInfo };
265
+ };
266
+
267
+ export default {
268
+ DEFAULT_ENSURE_SUB_ISSUES_LIMIT,
269
+ ENSURE_SUB_ISSUES_PROMPT,
270
+ fetchSubIssues,
271
+ fetchPullRequestText,
272
+ findMissingSubIssueReferences,
273
+ normalizeEnsureSubIssuesLimit,
274
+ formatEnsureSubIssuesLimit,
275
+ runEnsureAllSubIssuesAddressed,
276
+ };
package/src/solve.mjs CHANGED
@@ -47,6 +47,7 @@ const { startAutoRestartUntilMergeable } = await import('./solve.auto-merge.lib.
47
47
  const { runAutoEnsureRequirements } = await import('./solve.auto-ensure.lib.mjs');
48
48
  const { runKeepWorkingUntilDone } = await import('./solve.keep-working.lib.mjs');
49
49
  const { runEscalation } = await import('./solve.escalate.lib.mjs');
50
+ const { runEnsureAllSubIssuesAddressed } = await import('./solve.ensure-sub-issues.lib.mjs');
50
51
  const { finalizeSolveProcess } = await import('./solve.finalize.lib.mjs');
51
52
  const exitHandler = await import('./exit-handler.lib.mjs');
52
53
  const { initializeExitHandler, installGlobalExitHandlers, safeExit: baseSafeExit, logActiveHandles } = exitHandler;
@@ -152,6 +153,25 @@ if (!issueUrl) {
152
153
  await log('Run "solve.mjs --help" for more information', { level: 'error' });
153
154
  await safeExit(1, 'Missing required GitHub URL');
154
155
  }
156
+ // Issue #2212: repository mode. When a repository URL is given instead of an
157
+ // issue/pull request URL, collect every open issue of that repository, create a
158
+ // single combined issue that lists them as GitHub native sub-issues, and solve
159
+ // that issue instead — so one pull request can close all of them at once.
160
+ {
161
+ const { resolveRepositoryModeTarget } = await import('./solve.repository-mode.run.lib.mjs');
162
+ const repositoryMode = await resolveRepositoryModeTarget({ url: issueUrl, log });
163
+ if (repositoryMode.handled) {
164
+ if (repositoryMode.error) {
165
+ await log(`Error: ${repositoryMode.error}`, { level: 'error' });
166
+ await safeExit(1, 'Repository mode failed');
167
+ }
168
+ issueUrl = repositoryMode.issueUrl;
169
+ argv['issue-url'] = repositoryMode.issueUrl;
170
+ // Repository mode always asks for deep analysis and always double checks
171
+ // that the pull request lists every issue it is supposed to close.
172
+ Object.assign(argv, repositoryMode.argvOverrides);
173
+ }
174
+ }
155
175
  // Validate GitHub URL using validation module (more thorough check)
156
176
  const urlValidation = validateGitHubUrl(issueUrl);
157
177
  if (!urlValidation.isValid) {
@@ -1081,6 +1101,10 @@ try {
1081
1101
  applyRestartResult(await runEscalation({ issueUrl, owner, repo, issueNumber, prNumber, branchName, tempDir, workspaceTmpDir, argv, cleanupClaudeFile, resultSummary }));
1082
1102
  applyRestartResult(await runAutoEnsureRequirements({ issueUrl, owner, repo, issueNumber, prNumber, branchName, tempDir, argv, cleanupClaudeFile }));
1083
1103
  applyRestartResult(await runKeepWorkingUntilDone({ issueUrl, owner, repo, issueNumber, prNumber, branchName, tempDir, workspaceTmpDir, argv, cleanupClaudeFile, resultSummary }));
1104
+ // Issue #2212: runs last on purpose — the earlier loops may still rewrite the
1105
+ // pull request description, so the closing references are verified against its
1106
+ // final state.
1107
+ applyRestartResult(await runEnsureAllSubIssuesAddressed({ issueUrl, owner, repo, issueNumber, prNumber, branchName, tempDir, workspaceTmpDir, argv, cleanupClaudeFile }));
1084
1108
  // Start watch mode if enabled OR if we need to handle uncommitted changes
1085
1109
  if (argv.verbose) {
1086
1110
  await log('');
@@ -1197,6 +1221,20 @@ try {
1197
1221
  logsAttached = true;
1198
1222
  }
1199
1223
  }
1224
+ // Issue #1516: Cleanup after all completion signals (it was before verifyResults, which
1225
+ // caused premature commits). Issue #2211: but strictly BEFORE the auto-merge watch loop.
1226
+ // It used to run after it, and `--auto-merge` therefore merged the placeholder into the
1227
+ // default branch and only then reverted it on a branch nobody would look at again:
1228
+ //
1229
+ // 19:20:07 Initial commit with task details (.gitkeep touched)
1230
+ // 19:28:09 Merge pull request #3 (.gitkeep leaked into main)
1231
+ // 19:28:13 Revert "Initial commit with task details" <- 4 seconds too late
1232
+ //
1233
+ // https://github.com/konard/audio-decomposer/pull/3, docs/case-studies/issue-2211.
1234
+ // Reverting first also lets the loop see the pull request as it really is: with the
1235
+ // placeholder gone, a pull request that implemented nothing has an empty diff and the
1236
+ // loop restarts the AI instead of merging an empty change.
1237
+ await cleanupClaudeFile(tempDir, branchName, claudeCommitHash, argv);
1200
1238
  // Issue #2182: the AI working session is over at this point — everything below is
1201
1239
  // monitoring and merging, not working. The pull request must therefore be back in
1202
1240
  // "ready for review" BEFORE the auto-merge watch loop starts, because that loop can
@@ -1237,8 +1275,6 @@ try {
1237
1275
  }
1238
1276
  // Issue #1952: Final --attach-logs safety net + logsAttached reconciliation. See attach-logs-guarantee.lib.mjs.
1239
1277
  logsAttached = (await attachFinalLogIfMissing({ shouldAttachLogs, prNumber, owner, repo, $, log, sanitizeLogContent, getLogFile, attachLogToGitHub, argv, sessionId, tempDir, anthropicTotalCostUSD, resultModelUsage })) || logsAttached;
1240
- // Issue #1516: Cleanup after all signals (was before verifyResults, caused premature commits)
1241
- await cleanupClaudeFile(tempDir, branchName, claudeCommitHash, argv);
1242
1278
  await finalizeDevelopmentLog(); // Issue #1596/#2048: idempotent no-op on the success path (already committed before readiness signal); still preserves late/error work.
1243
1279
  await endWorkSession({ isContinueMode, prNumber, argv, log, formatAligned, $, logsAttached });
1244
1280
  } catch (error) {
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pure helpers for `/solve <github-repository-url>` — "repository mode"
5
+ * (issue #2212).
6
+ *
7
+ * When `/solve` is given a repository URL instead of an issue or pull request
8
+ * URL, it behaves similarly to `/fix --ci-cd`: it collects data about the
9
+ * repository, creates a single combined issue that lists every open issue in
10
+ * the repository as a GitHub native sub-issue, and then solves that combined
11
+ * issue as it normally would — producing one pull request that closes all of
12
+ * them at once.
13
+ *
14
+ * GitHub allows at most 100 sub-issues per parent issue
15
+ * (https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/adding-sub-issues),
16
+ * so when a repository has more open issues only the oldest 100 are attached
17
+ * and the rest are intentionally left out of this run.
18
+ *
19
+ * Everything in this module is network- and filesystem-free so it can be unit
20
+ * tested without GitHub access. The network side lives in
21
+ * `solve.repository-mode.run.lib.mjs`.
22
+ */
23
+
24
+ /**
25
+ * GitHub's documented maximum number of sub-issues per parent issue.
26
+ * @see https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/adding-sub-issues
27
+ */
28
+ export const MAX_SUB_ISSUES_PER_PARENT = 100;
29
+
30
+ /** Marker that identifies an issue generated by `/solve <repository-url>`. */
31
+ export const REPOSITORY_MODE_MARKER = '<!-- hive-mind-solve-repository-mode -->';
32
+
33
+ /**
34
+ * Build the `gh api` arguments that list every open issue of a repository,
35
+ * oldest first.
36
+ *
37
+ * The REST `/issues` endpoint also returns pull requests; they are filtered out
38
+ * by {@link isPullRequestEntry}. `--paginate` is required because the endpoint
39
+ * returns at most 100 entries per page.
40
+ *
41
+ * @param {object} params
42
+ * @param {string} params.owner
43
+ * @param {string} params.repo
44
+ * @param {number} [params.perPage=100]
45
+ * @returns {string[]} argument list for `gh`
46
+ */
47
+ export function buildOpenIssuesApiArgs({ owner, repo, perPage = MAX_SUB_ISSUES_PER_PARENT }) {
48
+ if (!owner || !repo) throw new Error('buildOpenIssuesApiArgs requires owner and repo');
49
+ const size = Number(perPage);
50
+ const safePerPage = Number.isInteger(size) && size >= 1 && size <= 100 ? size : MAX_SUB_ISSUES_PER_PARENT;
51
+ // Query parameters must be part of the endpoint string: `gh api -f key=value`
52
+ // switches the request to POST, which makes this endpoint fail with
53
+ // HTTP 422 ("title wasn't supplied").
54
+ return ['api', `repos/${owner}/${repo}/issues?state=open&sort=created&direction=asc&per_page=${safePerPage}`, '--paginate'];
55
+ }
56
+
57
+ /**
58
+ * The REST issues endpoint returns pull requests alongside issues; a pull
59
+ * request is identified by the presence of a `pull_request` object.
60
+ *
61
+ * @param {object} entry - raw REST issue entry
62
+ * @returns {boolean}
63
+ */
64
+ export function isPullRequestEntry(entry) {
65
+ return Boolean(entry && typeof entry === 'object' && entry.pull_request);
66
+ }
67
+
68
+ /**
69
+ * Normalize a raw REST issue entry into the minimal shape the rest of
70
+ * repository mode needs.
71
+ *
72
+ * `id` is the REST database id (not the GraphQL node id): the sub-issues API
73
+ * takes `sub_issue_id` as a REST id.
74
+ *
75
+ * @param {object} entry
76
+ * @returns {{number: number, id: number, title: string, url: string, createdAt: string}|null}
77
+ */
78
+ export function normalizeOpenIssueEntry(entry) {
79
+ if (!entry || typeof entry !== 'object') return null;
80
+ const number = Number(entry.number);
81
+ const id = Number(entry.id);
82
+ if (!Number.isInteger(number) || number <= 0) return null;
83
+ return {
84
+ number,
85
+ id: Number.isInteger(id) && id > 0 ? id : null,
86
+ title: String(entry.title || '').trim(),
87
+ url: String(entry.html_url || '').trim(),
88
+ createdAt: String(entry.created_at || '').trim(),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Filter out pull requests, normalize, sort oldest-first and cap the list at
94
+ * `limit` entries.
95
+ *
96
+ * Sorting is done here (not only server-side) so the selection is deterministic
97
+ * even when the caller passes an unsorted list. Ties fall back to the issue
98
+ * number, which is monotonic per repository.
99
+ *
100
+ * @param {Array<object>} entries - raw REST issue entries
101
+ * @param {object} [options]
102
+ * @param {number} [options.limit=MAX_SUB_ISSUES_PER_PARENT]
103
+ * @param {Array<number>} [options.exclude] - issue numbers to skip
104
+ * @returns {{selected: Array<object>, totalOpen: number, skipped: number}}
105
+ */
106
+ export function selectOldestOpenIssues(entries, { limit = MAX_SUB_ISSUES_PER_PARENT, exclude = [] } = {}) {
107
+ const excluded = new Set((exclude || []).map(Number).filter(Number.isInteger));
108
+ const issues = (Array.isArray(entries) ? entries : [])
109
+ .filter(entry => !isPullRequestEntry(entry))
110
+ .map(normalizeOpenIssueEntry)
111
+ .filter(Boolean)
112
+ .filter(issue => !excluded.has(issue.number));
113
+
114
+ issues.sort((a, b) => {
115
+ const aTime = Date.parse(a.createdAt);
116
+ const bTime = Date.parse(b.createdAt);
117
+ if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
118
+ return a.number - b.number;
119
+ });
120
+
121
+ const cap = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : MAX_SUB_ISSUES_PER_PARENT;
122
+ const selected = issues.slice(0, cap);
123
+
124
+ return {
125
+ selected,
126
+ totalOpen: issues.length,
127
+ skipped: Math.max(0, issues.length - selected.length),
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Title for the combined issue.
133
+ *
134
+ * @param {object} params
135
+ * @param {string} params.owner
136
+ * @param {string} params.repo
137
+ * @param {number} params.count - number of attached sub-issues
138
+ * @param {number} params.totalOpen - total open issues found in the repository
139
+ * @returns {string}
140
+ */
141
+ export function buildCombinedIssueTitle({ owner, repo, count, totalOpen }) {
142
+ const repository = `${owner}/${repo}`;
143
+ if (totalOpen > count) {
144
+ return `Address the ${count} oldest open issues in ${repository} (of ${totalOpen} open)`;
145
+ }
146
+ if (count === 1) {
147
+ return `Address the single open issue in ${repository}`;
148
+ }
149
+ return `Address all ${count} open issues in ${repository}`;
150
+ }
151
+
152
+ function formatIssueLine(issue) {
153
+ const created = issue.createdAt ? ` — opened ${issue.createdAt.slice(0, 10)}` : '';
154
+ const title = issue.title ? ` ${issue.title}` : '';
155
+ return `- [ ] #${issue.number}${title}${created}`;
156
+ }
157
+
158
+ /**
159
+ * Build the closing-keyword block the pull request description must contain.
160
+ *
161
+ * GitHub requires the full syntax for *each* issue: a single keyword does not
162
+ * apply to a comma separated list of issues
163
+ * (https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue).
164
+ *
165
+ * @param {Array<{number: number}>} issues
166
+ * @param {string} [keyword='Fixes']
167
+ * @returns {string}
168
+ */
169
+ export function buildClosingKeywordBlock(issues, keyword = 'Fixes') {
170
+ return (Array.isArray(issues) ? issues : []).map(issue => `${keyword} #${issue.number}`).join('\n');
171
+ }
172
+
173
+ /**
174
+ * Body of the combined issue.
175
+ *
176
+ * @param {object} params
177
+ * @param {{owner: string, repo: string, url: string}} params.repository
178
+ * @param {Array<object>} params.issues - selected sub-issues (oldest first)
179
+ * @param {number} params.totalOpen
180
+ * @param {number} [params.limit=MAX_SUB_ISSUES_PER_PARENT]
181
+ * @param {string} [params.sourceIssueUrl] - issue that introduced repository mode
182
+ * @returns {string}
183
+ */
184
+ export function buildCombinedIssueBody({ repository, issues, totalOpen, limit = MAX_SUB_ISSUES_PER_PARENT, sourceIssueUrl = 'https://github.com/link-assistant/hive-mind/issues/2212' }) {
185
+ const list = Array.isArray(issues) ? issues : [];
186
+ const repositoryUrl = repository.url || `https://github.com/${repository.owner}/${repository.repo}`;
187
+ const skipped = Math.max(0, Number(totalOpen || 0) - list.length);
188
+
189
+ const lines = [REPOSITORY_MODE_MARKER, '', '## Objective', '', `Address every open issue listed below in [${repository.owner}/${repository.repo}](${repositoryUrl}) with a **single pull request**.`, '', `This issue was generated automatically by \`/solve ${repositoryUrl}\` (repository mode, see ${sourceIssueUrl}). Every issue below is attached to this issue as a GitHub native sub-issue.`, '', '## Scope', '', `- Open issues found in the repository: ${totalOpen}`, `- Issues attached as sub-issues of this issue: ${list.length}`, `- GitHub sub-issue limit per parent issue: ${limit}`];
190
+
191
+ if (skipped > 0) {
192
+ lines.push(`- Open issues intentionally left out of this run (newest ${skipped}, over the sub-issue limit): ${skipped}`);
193
+ }
194
+
195
+ lines.push('', '## Issues to address', '');
196
+
197
+ if (list.length === 0) {
198
+ lines.push('_No open issues were found._');
199
+ } else {
200
+ lines.push(...list.map(formatIssueLine));
201
+ }
202
+
203
+ lines.push('', '## Requirements', '', '1. Read every issue listed above (including its comments) and fully implement what it asks for.', '2. Do all of the work in this single pull request. Do not defer any listed issue to a follow-up pull request.', '3. The pull request description **must** close this issue and **every** issue listed above, so that merging the pull request closes all of them at once.', '4. GitHub requires the full closing syntax for each issue: one keyword per issue. `Fixes #1, #2` only closes `#1`. Use the block below verbatim (plus `Fixes #<this issue>` for this issue).', '5. If an issue turns out to be already resolved or not reproducible, say so explicitly in the pull request description — but still keep its closing reference so it is closed on merge.', '', '## Required closing references in the pull request description', '', '```', buildClosingKeywordBlock(list) || '(no issues)', '```', '');
204
+
205
+ return lines.join('\n');
206
+ }
207
+
208
+ /**
209
+ * Human-readable summary of what repository mode is about to do, for logs.
210
+ *
211
+ * @param {object} params
212
+ * @returns {string[]}
213
+ */
214
+ export function buildRepositoryModeSummaryLines({ totalOpen, selectedCount, skipped, limit = MAX_SUB_ISSUES_PER_PARENT }) {
215
+ const lines = [` Open issues found: ${totalOpen}`, ` Attached as sub-issues: ${selectedCount} (limit ${limit})`];
216
+ if (skipped > 0) {
217
+ lines.push(` Left out (newest, over limit): ${skipped}`);
218
+ }
219
+ return lines;
220
+ }
221
+
222
+ export default {
223
+ MAX_SUB_ISSUES_PER_PARENT,
224
+ REPOSITORY_MODE_MARKER,
225
+ buildOpenIssuesApiArgs,
226
+ isPullRequestEntry,
227
+ normalizeOpenIssueEntry,
228
+ selectOldestOpenIssues,
229
+ buildCombinedIssueTitle,
230
+ buildClosingKeywordBlock,
231
+ buildCombinedIssueBody,
232
+ buildRepositoryModeSummaryLines,
233
+ };