@link-assistant/hive-mind 2.19.2 → 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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 84ea8a3: Accept a repository URL in `solve` and close every open issue of that repository with one pull request, and add `--ensure-all-sub-issues-addressed` to prove it (issue #2212).
8
+
9
+ `solve https://github.com/owner/repo` used to be rejected: `validateGitHubUrl` only ever accepted an issue or a pull request URL. It now means something. Repository mode lists every open issue of the repository, creates one combined issue that lists them as GitHub **native sub-issues**, and hands that issue to the normal single-issue flow — so a single pull request can close all of them at once, and merging it closes the combined issue too.
10
+
11
+ - **The combined issue is built from what GitHub actually allows.** A parent issue takes at most 100 sub-issues, so a repository with more open issues gets the oldest 100 and the rest are reported as intentionally left out — in the run log and in the issue body, not silently. Pull requests are filtered out of the REST `/issues` response, the selection is oldest-first with the issue number as tie-breaker, and each issue is attached with its REST database id through `POST …/sub_issues`. That endpoint is documented as prone to secondary rate limiting, so attachments are spaced one second apart and a rate-limited attachment is retried with a one- then two-minute backoff; an attachment that fails for any other reason (an issue that already has a parent, say) is reported and skipped instead of aborting the run.
12
+ - **`--ensure-all-sub-issues-addressed` works on any issue.** After the main solve completes it lists the sub-issues of the issue being solved and checks the pull request title and description for a closing reference GitHub really recognizes — reusing `prClosesIssue`, so `Fixes #1, #2` correctly counts as closing only `#1`, exactly as GitHub reads it. When references are missing it restarts the AI tool with the concrete list of sub-issues and the exact lines to add, asking it to double check that each one was really addressed in this single pull request. Bare flag means 5 restarts; it also takes a count or `forever`. It runs last among the post-solve loops, because `--escalate`, `--auto-ensure-requirements` and `--keep-working` may still rewrite the description.
13
+ - **Repository mode turns on `--deep-analysis` and `--ensure-all-sub-issues-addressed` for the issue it generated**, so the run asks for the deep analysis `/fix` asks for and then verifies its own promise.
14
+ - **The Telegram bot accepts repository URLs in `/solve` too**, and labels such a run as a URL rather than as an issue.
15
+
16
+ `tests/test-solve-repository-mode-2212.mjs` (47 assertions) covers the rejection this starts from, the pure helpers, the orchestration against a fake `gh`, the CLI flag and the `solve.mjs` wiring; `tests/test-telegram-solve-repository-url-2212.mjs` (25 assertions) covers the Telegram entry point. `examples/solve-repository-mode-preview.mjs` previews a real repository read-only, creating nothing. The analysis, the cited GitHub documentation and the run evidence are in `docs/case-studies/issue-2212/`.
17
+
3
18
  ## 2.19.2
4
19
 
5
20
  ### Patch Changes
package/README.hi.md CHANGED
@@ -322,6 +322,9 @@ solve https://github.com/owner/repo/issues/123 --base-branch develop --fork
322
322
  # Continue working on existing PR
323
323
  solve https://github.com/owner/repo/pull/456 --model opus
324
324
 
325
+ # Solve every open issue of a repository with one pull request (repository mode)
326
+ solve https://github.com/owner/repo
327
+
325
328
  # Resume from Claude session when limit is reached
326
329
  solve https://github.com/owner/repo/issues/123 --resume session-id
327
330
 
@@ -354,6 +357,8 @@ review --repo owner/repo --pr 456
354
357
  solve <issue-url> [options]
355
358
  ```
356
359
 
360
+ > **📦 Repository mode**: issue URL की जगह repository URL दें — solve उस repository के सभी open issues इकट्ठा करता है (सबसे पुराने पहले, अधिकतम 100 — GitHub की प्रति parent sub-issue सीमा), उन्हें GitHub native sub-issues के रूप में सूचीबद्ध करने वाला एक संयुक्त issue बनाता है, और उसी issue को हल करता है — ताकि एक ही pull request उन सबको एक साथ बंद कर सके। यह `--deep-analysis` और `--ensure-all-sub-issues-addressed` को भी चालू कर देता है। देखें [docs/CONFIGURATION.md](./docs/CONFIGURATION.md#solve-options)।
361
+
357
362
  **सबसे अधिक उपयोग किए जाने वाले विकल्प:**
358
363
 
359
364
  | विकल्प | संक्षिप्त | विवरण | डिफ़ॉल्ट |
package/README.md CHANGED
@@ -328,6 +328,9 @@ solve https://github.com/owner/repo/issues/123 --base-branch develop --fork
328
328
  # Continue working on existing PR
329
329
  solve https://github.com/owner/repo/pull/456 --model opus
330
330
 
331
+ # Solve every open issue of a repository with one pull request (repository mode)
332
+ solve https://github.com/owner/repo
333
+
331
334
  # Resume from Claude session when limit is reached
332
335
  solve https://github.com/owner/repo/issues/123 --resume session-id
333
336
 
@@ -360,6 +363,8 @@ review --repo owner/repo --pr 456
360
363
  solve <issue-url> [options]
361
364
  ```
362
365
 
366
+ > **📦 Repository mode**: pass a repository URL instead of an issue URL and solve collects every open issue of that repository (oldest first, at most 100 — GitHub's sub-issue limit per parent), creates one combined issue that lists them as GitHub native sub-issues, and solves that issue — so a single pull request can close all of them at once. It also turns on `--deep-analysis` and `--ensure-all-sub-issues-addressed`. See [docs/CONFIGURATION.md](./docs/CONFIGURATION.md#solve-options).
367
+
363
368
  **Most frequently used options:**
364
369
 
365
370
  | Option | Alias | Description | Default |
package/README.ru.md CHANGED
@@ -322,6 +322,9 @@ solve https://github.com/owner/repo/issues/123 --base-branch develop --fork
322
322
  # Continue working on existing PR
323
323
  solve https://github.com/owner/repo/pull/456 --model opus
324
324
 
325
+ # Solve every open issue of a repository with one pull request (repository mode)
326
+ solve https://github.com/owner/repo
327
+
325
328
  # Resume from Claude session when limit is reached
326
329
  solve https://github.com/owner/repo/issues/123 --resume session-id
327
330
 
@@ -354,6 +357,8 @@ review --repo owner/repo --pr 456
354
357
  solve <issue-url> [options]
355
358
  ```
356
359
 
360
+ > **📦 Режим репозитория**: передайте URL репозитория вместо URL задачи — solve соберёт все открытые задачи этого репозитория (сначала самые старые, не более 100 — это лимит GitHub на количество под-задач у одной родительской задачи), создаст одну сводную задачу, где они перечислены как нативные под-задачи GitHub, и будет решать её — так что один pull request сможет закрыть их все сразу. Режим также включает `--deep-analysis` и `--ensure-all-sub-issues-addressed`. См. [docs/CONFIGURATION.md](./docs/CONFIGURATION.md#solve-options).
361
+
357
362
  **Наиболее часто используемые параметры:**
358
363
 
359
364
  | Параметр | Сокр. | Описание | По умолчанию |
package/README.zh.md CHANGED
@@ -322,6 +322,9 @@ solve https://github.com/owner/repo/issues/123 --base-branch develop --fork
322
322
  # Continue working on existing PR
323
323
  solve https://github.com/owner/repo/pull/456 --model opus
324
324
 
325
+ # Solve every open issue of a repository with one pull request (repository mode)
326
+ solve https://github.com/owner/repo
327
+
325
328
  # Resume from Claude session when limit is reached
326
329
  solve https://github.com/owner/repo/issues/123 --resume session-id
327
330
 
@@ -354,6 +357,8 @@ review --repo owner/repo --pr 456
354
357
  solve <issue-url> [options]
355
358
  ```
356
359
 
360
+ > **📦 仓库模式**:传入仓库 URL(而不是 issue URL),solve 会收集该仓库的所有开放 issue(最旧优先,最多 100 个 —— GitHub 每个父 issue 的子 issue 上限),创建一个把它们列为 GitHub 原生子 issue 的合并 issue,并解决该 issue —— 这样一个 pull request 就能一次性关闭它们全部。该模式还会自动启用 `--deep-analysis` 和 `--ensure-all-sub-issues-addressed`。参见 [docs/CONFIGURATION.md](./docs/CONFIGURATION.md#solve-options)。
361
+
357
362
  **最常用选项:**
358
363
 
359
364
  | 选项 | 简写 | 描述 | 默认值 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.19.2",
3
+ "version": "2.20.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -696,6 +696,12 @@ export const SOLVE_OPTION_DEFINITIONS = {
696
696
  alias: ['keep-going-until-all-requirements-are-fully-done', 'keep-working', 'keep-going'],
697
697
  default: undefined,
698
698
  },
699
+ 'ensure-all-sub-issues-addressed': {
700
+ type: 'string',
701
+ description: '[EXPERIMENTAL] After the main solve completes, check that the pull request description closes every GitHub native sub-issue of the issue being solved with a reference GitHub actually recognizes. When references are missing, automatically restart the AI tool and ask it to double check that each of those sub-issues was really addressed in this single pull request. Accepts a number of restarts (default: 5), or "forever"/"unlimited" to remove the limit. Bare flag means the default of 5. Enabled automatically when solving a repository URL.',
702
+ alias: ['ensure-all-sub-issues', 'ensure-sub-issues'],
703
+ default: undefined,
704
+ },
699
705
  escalate: {
700
706
  type: 'string',
701
707
  description: '[EXPERIMENTAL] Start solving with a cheaper/lower-tier model and automatically escalate to a more capable (more expensive) model while unfinished work remains. Accepts a model range "<lower>-<upper>" using short Claude tier names (ladder: haiku < sonnet < opus < fable), e.g. "sonnet-opus". A single name (e.g. "opus") means just that tier. Bare flag means "sonnet-fable". The idea: iterate cheaply first so expensive models do more reading and less writing.',
@@ -1059,6 +1065,26 @@ export const parseArguments = async (yargs = getLinoYargsFactory(), hideBinFn =
1059
1065
  }
1060
1066
  }
1061
1067
 
1068
+ // --ensure-all-sub-issues-addressed normalization (issue #2212)
1069
+ // Same value semantics as --keep-working-until-all-requirements-are-fully-done:
1070
+ // a number of restarts, the keywords "forever"/"unlimited"/"infinite", or no
1071
+ // value (bare flag => default of 5). Final numeric normalization happens at
1072
+ // runtime in solve.ensure-sub-issues.detect.lib.mjs.
1073
+ {
1074
+ const ensureSubIssuesAliases = ['--ensure-all-sub-issues-addressed', '--ensure-all-sub-issues', '--ensure-sub-issues'];
1075
+ const ensureSubIssuesProvided = ensureSubIssuesAliases.some(alias => hasRawOption(rawArgs, alias));
1076
+ if (ensureSubIssuesProvided) {
1077
+ const current = argv.ensureAllSubIssuesAddressed;
1078
+ if (current === true || current === '' || current === undefined || current === null) {
1079
+ argv.ensureAllSubIssuesAddressed = 5;
1080
+ } else if (typeof current === 'string') {
1081
+ argv.ensureAllSubIssuesAddressed = current.trim();
1082
+ }
1083
+ } else if (argv.ensureAllSubIssuesAddressed === undefined) {
1084
+ argv.ensureAllSubIssuesAddressed = undefined;
1085
+ }
1086
+ }
1087
+
1062
1088
  // --escalate / --escalate-from / --escalate-steps normalization (issue #1885)
1063
1089
  // The bare `--escalate` flag is a string-typed option, so yargs yields `true`
1064
1090
  // (or an empty string) for a value-less flag. Canonicalize that to the default
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pure detection helpers for `--ensure-all-sub-issues-addressed` (issue #2212).
5
+ *
6
+ * The option checks that the pull request description closes every sub-issue of
7
+ * the issue being solved with a reference GitHub actually recognizes. When a
8
+ * reference is missing, solve auto-restarts the AI tool and asks it to double
9
+ * check that every sub-issue was really addressed in this single pull request.
10
+ *
11
+ * GitHub requires the full closing syntax per issue — `Fixes #1, #2` closes only
12
+ * `#1` — which is exactly what `prClosesIssue` implements, so this module reuses
13
+ * it instead of writing a second, subtly different parser.
14
+ *
15
+ * Everything here is network-free so it can be unit tested in isolation. The
16
+ * network side lives in `solve.ensure-sub-issues.lib.mjs`.
17
+ */
18
+
19
+ import { prClosesIssue } from './github-linking.lib.mjs';
20
+ import { normalizeKeepWorkingLimit } from './solve.keep-working.detect.lib.mjs';
21
+
22
+ /** Default number of auto-restarts when closing references are missing. */
23
+ export const DEFAULT_ENSURE_SUB_ISSUES_LIMIT = 5;
24
+
25
+ /**
26
+ * Reinforcement prompt appended to every restart, in addition to the concrete
27
+ * list of sub-issues whose closing references are missing.
28
+ */
29
+ export const ENSURE_SUB_ISSUES_PROMPT = 'Double check that every sub-issue listed above was really addressed in this single pull request, then make sure the pull request description closes each of them with a GitHub recognized closing reference.';
30
+
31
+ /**
32
+ * Normalize the `--ensure-all-sub-issues-addressed` value into a restart limit.
33
+ *
34
+ * Shares the semantics of `--keep-working-until-all-requirements-are-fully-done`:
35
+ * - falsy (undefined / null / false / "") -> 0 (disabled)
36
+ * - bare flag (true) -> DEFAULT_ENSURE_SUB_ISSUES_LIMIT
37
+ * - "forever" / "unlimited" / "infinite" / 0 / "0" -> Infinity
38
+ * - positive number / numeric string -> floor(value)
39
+ *
40
+ * @param {*} value
41
+ * @returns {number}
42
+ */
43
+ export function normalizeEnsureSubIssuesLimit(value) {
44
+ return normalizeKeepWorkingLimit(value, DEFAULT_ENSURE_SUB_ISSUES_LIMIT);
45
+ }
46
+
47
+ /**
48
+ * Human readable description of the limit for logs.
49
+ * @param {number} limit
50
+ * @returns {string}
51
+ */
52
+ export function formatEnsureSubIssuesLimit(limit) {
53
+ return limit === Infinity ? 'unlimited' : `${limit}`;
54
+ }
55
+
56
+ /**
57
+ * Normalize a raw sub-issue entry (REST `sub_issues` payload, or an already
58
+ * normalized entry) into `{number, title, owner, repo, url}`.
59
+ *
60
+ * The owner/repo are derived from `repository_url` when present so cross
61
+ * repository sub-issues are matched with their fully qualified reference.
62
+ *
63
+ * @param {object} entry
64
+ * @param {{owner?: string, repo?: string}} [fallbackRepository]
65
+ * @returns {{number: number, title: string, owner: string|null, repo: string|null, url: string}|null}
66
+ */
67
+ export function normalizeSubIssueEntry(entry, fallbackRepository = {}) {
68
+ if (!entry || typeof entry !== 'object') return null;
69
+ const number = Number(entry.number);
70
+ if (!Number.isInteger(number) || number <= 0) return null;
71
+
72
+ let owner = entry.owner || fallbackRepository.owner || null;
73
+ let repo = entry.repo || fallbackRepository.repo || null;
74
+
75
+ const repositoryUrl = String(entry.repository_url || '');
76
+ const match = repositoryUrl.match(/repos\/([^/]+)\/([^/]+)$/);
77
+ if (match) {
78
+ owner = match[1];
79
+ repo = match[2];
80
+ }
81
+
82
+ return {
83
+ number,
84
+ title: String(entry.title || '').trim(),
85
+ owner,
86
+ repo,
87
+ url: String(entry.html_url || entry.url || '').trim(),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Find the sub-issues whose closing reference is missing from `text`.
93
+ *
94
+ * A sub-issue counts as referenced when the text contains a GitHub recognized
95
+ * closing keyword for it (`closes #12`, `fixes owner/repo#12`,
96
+ * `resolves https://github.com/owner/repo/issues/12`, ...).
97
+ *
98
+ * @param {object} params
99
+ * @param {string} params.text - pull request description (and optionally title)
100
+ * @param {Array<object>} params.subIssues
101
+ * @param {string} [params.owner] - repository of the parent issue
102
+ * @param {string} [params.repo]
103
+ * @returns {{missing: Array<object>, referenced: Array<object>, total: number}}
104
+ */
105
+ export function findMissingSubIssueReferences({ text, subIssues, owner = null, repo = null }) {
106
+ const normalized = (Array.isArray(subIssues) ? subIssues : []).map(entry => normalizeSubIssueEntry(entry, { owner, repo })).filter(Boolean);
107
+
108
+ const missing = [];
109
+ const referenced = [];
110
+
111
+ for (const subIssue of normalized) {
112
+ if (prClosesIssue(text, subIssue.number, subIssue.owner, subIssue.repo)) {
113
+ referenced.push(subIssue);
114
+ } else {
115
+ missing.push(subIssue);
116
+ }
117
+ }
118
+
119
+ return { missing, referenced, total: normalized.length };
120
+ }
121
+
122
+ function formatSubIssueReference(subIssue, owner, repo) {
123
+ const crossRepository = subIssue.owner && subIssue.repo && (subIssue.owner !== owner || subIssue.repo !== repo);
124
+ return crossRepository ? `${subIssue.owner}/${subIssue.repo}#${subIssue.number}` : `#${subIssue.number}`;
125
+ }
126
+
127
+ /**
128
+ * Build the closing-reference block the AI is asked to add, one keyword per
129
+ * issue as GitHub requires.
130
+ *
131
+ * @param {Array<object>} subIssues
132
+ * @param {object} [params]
133
+ * @returns {string}
134
+ */
135
+ export function buildMissingReferenceBlock(subIssues, { owner = null, repo = null, keyword = 'Fixes' } = {}) {
136
+ return (Array.isArray(subIssues) ? subIssues : []).map(subIssue => `${keyword} ${formatSubIssueReference(subIssue, owner, repo)}`).join('\n');
137
+ }
138
+
139
+ /**
140
+ * Build the feedback lines injected into the restart iteration.
141
+ *
142
+ * @param {object} params
143
+ * @param {Array<object>} params.missing
144
+ * @param {number} params.total
145
+ * @param {number} params.iteration
146
+ * @param {number} params.limit
147
+ * @param {string} [params.owner]
148
+ * @param {string} [params.repo]
149
+ * @param {string|number} [params.issueNumber] - parent issue
150
+ * @returns {string[]}
151
+ */
152
+ export function buildEnsureSubIssuesFeedback({ missing, total, iteration, limit, owner = null, repo = null, issueNumber = null }) {
153
+ const missingList = Array.isArray(missing) ? missing : [];
154
+ const lines = ['', '='.repeat(60), '🧩 ENSURE ALL SUB-ISSUES ADDRESSED:', '='.repeat(60), '', `Restart ${iteration}/${formatEnsureSubIssuesLimit(limit)}.`, '', `This issue${issueNumber ? ` (#${issueNumber})` : ''} has ${total} sub-issue(s). The pull request description is missing a GitHub recognized closing reference for ${missingList.length} of them:`, ''];
155
+
156
+ for (const subIssue of missingList) {
157
+ const reference = formatSubIssueReference(subIssue, owner, repo);
158
+ lines.push(` • ${reference}${subIssue.title ? ` — ${subIssue.title}` : ''}${subIssue.url ? ` (${subIssue.url})` : ''}`);
159
+ }
160
+
161
+ lines.push('', 'For each sub-issue above:', ' 1. Verify it was really addressed by the changes in this pull request. If it was not, implement it now — in this same pull request.', ' 2. Then update the pull request description so it closes the sub-issue.', '', 'GitHub only recognizes the full closing syntax repeated per issue: "Fixes #1, #2" closes only #1.', 'Add these lines to the pull request description (keep any existing closing references):', '', buildMissingReferenceBlock(missingList, { owner, repo }), '', ENSURE_SUB_ISSUES_PROMPT, '');
162
+
163
+ return lines;
164
+ }
165
+
166
+ export default {
167
+ DEFAULT_ENSURE_SUB_ISSUES_LIMIT,
168
+ ENSURE_SUB_ISSUES_PROMPT,
169
+ normalizeEnsureSubIssuesLimit,
170
+ formatEnsureSubIssuesLimit,
171
+ normalizeSubIssueEntry,
172
+ findMissingSubIssueReferences,
173
+ buildMissingReferenceBlock,
174
+ buildEnsureSubIssuesFeedback,
175
+ };
@@ -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('');
@@ -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
+ };
@@ -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(' ')) : '',