@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 +15 -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/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 +24 -0
- 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
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
package/src/solve.config.lib.mjs
CHANGED
|
@@ -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
|
+
};
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -612,7 +612,10 @@ async function handleSolveCommand(ctx) {
|
|
|
612
612
|
return;
|
|
613
613
|
}
|
|
614
614
|
|
|
615
|
-
|
|
615
|
+
// Issue #2212: a repository URL is accepted too — solve then collects every open
|
|
616
|
+
// issue of that repository into one combined issue (with GitHub native sub-issues)
|
|
617
|
+
// and solves that issue, so a single pull request can close all of them.
|
|
618
|
+
const validation = await validateGitHubUrl(userArgs, { allowedTypes: ['issue', 'pull', 'repo'], createYargsConfig: createSolveYargsConfig, positionalNames: ['issue-url'], locale: solveLocale });
|
|
616
619
|
if (!validation.valid) {
|
|
617
620
|
let errorMsg = `❌ ${validation.error}`;
|
|
618
621
|
if (validation.suggestion) {
|
|
@@ -705,7 +708,7 @@ async function handleSolveCommand(ctx) {
|
|
|
705
708
|
let infoBlock = buildTelegramInfoBlock({
|
|
706
709
|
locale: solveLocale,
|
|
707
710
|
requester,
|
|
708
|
-
urlKind: validation.parsed?.type === 'pull' ? 'pullRequest' : 'issue',
|
|
711
|
+
urlKind: validation.parsed?.type === 'pull' ? 'pullRequest' : validation.parsed?.type === 'repo' ? 'url' : 'issue', // #2212: a repository URL is neither an issue nor a pull request
|
|
709
712
|
url: escapeMarkdown(normalizedUrl),
|
|
710
713
|
optionsRaw: userOptionsRaw ? escapeMarkdown(userOptionsRaw) : '',
|
|
711
714
|
lockedOptions: solveOverrides.length > 0 ? escapeMarkdown(solveOverrides.join(' ')) : '',
|