@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,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
|
+
};
|