@link-assistant/hive-mind 1.78.7 → 1.78.9
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 +77 -0
- package/package.json +1 -1
- package/src/github-issue-auto-close.lib.mjs +240 -0
- package/src/github-merge-issue-close.lib.mjs +89 -0
- package/src/github-merge.lib.mjs +7 -0
- package/src/isolation-runner.lib.mjs +204 -68
- package/src/solve.auto-continue.lib.mjs +72 -12
- package/src/solve.auto-merge.lib.mjs +31 -1
- package/src/solve.auto-pr.lib.mjs +48 -20
- package/src/telegram-bot.mjs +10 -0
- package/src/telegram-merge-queue.lib.mjs +12 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,82 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.78.9
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- a3d4d41: fix(isolation): use native Docker isolation and seed the nested daemon for `--isolation docker` (#1914)
|
|
8
|
+
|
|
9
|
+
Two problems made `--isolation docker` behave wrong on the Docker-in-Docker bot
|
|
10
|
+
host:
|
|
11
|
+
1. **It wasn't real Docker isolation.** Hive Mind launched isolated tasks as
|
|
12
|
+
`$ --isolated screen -- docker run …`, so `$ --status` reported
|
|
13
|
+
`options / isolated screen` — a screen wrapper around a raw `docker run`, not
|
|
14
|
+
the native Docker backend. Hive Mind now builds
|
|
15
|
+
`$ --isolated docker --image <img> [--privileged] --shell sh … --detached --session <uuid> -- '<cmd>'`,
|
|
16
|
+
so start-command owns the container lifecycle and `--status` reports real
|
|
17
|
+
Docker isolation.
|
|
18
|
+
2. **The 30+ GB image was re-downloaded for every task.** The bot runs inside a
|
|
19
|
+
DinD container whose nested `dockerd` starts with an empty image store. box
|
|
20
|
+
can seed that daemon from the host (host-image passthrough), but only when the
|
|
21
|
+
host Docker socket is bind-mounted — and when it isn't, passthrough is a
|
|
22
|
+
_silent_ no-op, so the first isolated task pulled the whole image from the
|
|
23
|
+
registry. Hive Mind now runs a startup preflight (`preflightDockerIsolation`)
|
|
24
|
+
that probes the nested daemon and, when the image is absent, prints the exact
|
|
25
|
+
remediation (mount `/var/run/docker.sock` + set `DIND_HOST_PASSTHROUGH_IMAGES`,
|
|
26
|
+
or run `scripts/preload-dind-isolation-image.mjs`). The production deploy
|
|
27
|
+
script was the real root cause — its `docker run` never mounted the host
|
|
28
|
+
socket — and has been fixed to pass `-v /var/run/docker.sock:…:ro` plus the
|
|
29
|
+
allowlist.
|
|
30
|
+
|
|
31
|
+
Also filed the silent-passthrough footgun upstream as link-foundation/box#102
|
|
32
|
+
(warn when an allowlist is set but no socket is mounted) — **now fixed and shipped
|
|
33
|
+
in box v2.3.2** — and bumped this repo's base images from `konard/box:2.3.1` /
|
|
34
|
+
`konard/box-dind:2.3.1` to `2.3.2` so the upstream warning ships at the source.
|
|
35
|
+
Added a deep case study with the full reproduction, timeline, and root-cause
|
|
36
|
+
analysis under `docs/case-studies/issue-1914`.
|
|
37
|
+
|
|
38
|
+
## 1.78.8
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- cb4986f: Close linked issues when a PR is merged into a non-default branch, and stop misreporting the cause (#1895).
|
|
43
|
+
|
|
44
|
+
GitHub only registers a PR's `closingIssuesReferences` and auto-closes the linked
|
|
45
|
+
issue when the PR targets the repository's **default branch**. PRs created against a
|
|
46
|
+
stacked / sub-issue branch (e.g. `issue-47-…` via `--base-branch`) therefore showed
|
|
47
|
+
an empty closing-reference connection and left their issues open after merge — the
|
|
48
|
+
exact failure reported for meta-language PRs #65/#66 / issues #49/#50.
|
|
49
|
+
- src/github-issue-auto-close.lib.mjs (new): `gitHubAutoClosesOnMerge`,
|
|
50
|
+
`classifyIssueLinkStatus`, `buildNonDefaultBranchExplanation`, and
|
|
51
|
+
`ensureLinkedIssueClosedAfterMerge` — diagnose why a closing reference is missing
|
|
52
|
+
and explicitly close the linked issue after a non-default-base merge (no-op when
|
|
53
|
+
GitHub already handles it, the keyword is absent, or the issue is already closed).
|
|
54
|
+
- src/solve.auto-pr.lib.mjs: replace the misleading "ISSUE LINK MISSING — add
|
|
55
|
+
Fixes #N" warning with an accurate "ISSUE LINK DEFERRED" explanation when the
|
|
56
|
+
keyword is present but the PR targets a non-default branch.
|
|
57
|
+
- src/solve.auto-continue.lib.mjs (`collectIssuePrCandidates`): detect the existing
|
|
58
|
+
PR for an issue by BOTH GitHub's `linked:issue` search (legacy, preserved) and the
|
|
59
|
+
deterministic `head:issue-N-` branch search. A PR targeting a non-default base
|
|
60
|
+
branch never appears in `linked:issue`, so `--auto-continue` previously failed to
|
|
61
|
+
resume it and risked creating a duplicate; the head-branch search guarantees the
|
|
62
|
+
PR↔issue association regardless of base branch.
|
|
63
|
+
- src/solve.auto-merge.lib.mjs (watchUntilMergeable + attemptAutoMerge),
|
|
64
|
+
src/github-merge.lib.mjs / src/github-merge-issue-close.lib.mjs
|
|
65
|
+
(`closeLinkedIssueIfNotAutoClosed`, used by the /merge queue), and
|
|
66
|
+
src/telegram-merge-queue.lib.mjs: close the linked issue explicitly after a merge
|
|
67
|
+
into a non-default branch. All gh calls route through the rate-limit-aware wrappers.
|
|
68
|
+
- tests/github-issue-auto-close.test.mjs: 14 cases reproducing the non-default-base
|
|
69
|
+
bug and verifying the diagnosis + fallback.
|
|
70
|
+
- tests/solve-auto-continue-detection-1895.test.mjs: 7 cases proving non-default-base
|
|
71
|
+
PRs are detected for auto-continue (head-branch search), legacy linked detection is
|
|
72
|
+
preserved, results are deduped/merged, and search failures degrade gracefully.
|
|
73
|
+
- docs/case-studies/issue-1895: deep case study with downloaded GraphQL/PR/issue
|
|
74
|
+
evidence, reconstructed timeline, root-cause analysis, requirement mapping, and the
|
|
75
|
+
external-reporting decision. Includes `github-api-linking-research.md` — a
|
|
76
|
+
definitive, introspection-backed answer to "is there an API to link a PR to an
|
|
77
|
+
issue?" (no: confirmed via live GraphQL schema introspection), with the gap
|
|
78
|
+
reported upstream (GitHub Community discussions #112224 / #155339 / #179613).
|
|
79
|
+
|
|
3
80
|
## 1.78.7
|
|
4
81
|
|
|
5
82
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub Issue Auto-Close Diagnosis & Fallback Library
|
|
5
|
+
*
|
|
6
|
+
* Root cause of issue #1895:
|
|
7
|
+
* GitHub only registers a pull request's closing references (the
|
|
8
|
+
* `closingIssuesReferences` GraphQL connection) and only auto-closes the
|
|
9
|
+
* referenced issues when the pull request targets the repository's
|
|
10
|
+
* **default branch**. When a PR uses a closing keyword such as
|
|
11
|
+
* `Fixes #49` / `Closes #50` but targets a non-default branch (for example a
|
|
12
|
+
* stacked / sub-issue branch like `issue-47-...`), GitHub:
|
|
13
|
+
*
|
|
14
|
+
* 1. leaves `closingIssuesReferences` empty, so automatic linking detection
|
|
15
|
+
* reports "ISSUE LINK MISSING" even though the keyword is present, and
|
|
16
|
+
* 2. does not close the linked issue when the PR is merged, so the PR is
|
|
17
|
+
* "closed without its issue to be closed as well".
|
|
18
|
+
*
|
|
19
|
+
* This module provides:
|
|
20
|
+
* - pure helpers to diagnose *why* a closing reference is missing, and
|
|
21
|
+
* - an action helper that explicitly closes the linked issue after a merge
|
|
22
|
+
* into a non-default branch, where GitHub would not do it for us.
|
|
23
|
+
*
|
|
24
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1895
|
|
25
|
+
* @see https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { prClosesIssue, extractLinkedIssueNumber } from './github-linking.lib.mjs';
|
|
29
|
+
import { wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Determine whether GitHub will automatically close a PR's linked issues when
|
|
33
|
+
* the PR is merged, based on the branch it targets.
|
|
34
|
+
*
|
|
35
|
+
* GitHub only auto-closes linked issues for PRs merged into the repository's
|
|
36
|
+
* default branch.
|
|
37
|
+
*
|
|
38
|
+
* @param {string|null|undefined} baseBranch - The branch the PR targets (baseRefName)
|
|
39
|
+
* @param {string|null|undefined} defaultBranch - The repository's default branch
|
|
40
|
+
* @returns {boolean|null} true if GitHub will auto-close, false if it will not,
|
|
41
|
+
* or null when the answer cannot be determined (missing input).
|
|
42
|
+
*/
|
|
43
|
+
export function gitHubAutoClosesOnMerge(baseBranch, defaultBranch) {
|
|
44
|
+
if (!baseBranch || !defaultBranch) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return String(baseBranch).trim() === String(defaultBranch).trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Classify the issue-linking status of a pull request so callers can emit an
|
|
52
|
+
* accurate diagnostic instead of the misleading "add Fixes #N" advice when the
|
|
53
|
+
* keyword is already present.
|
|
54
|
+
*
|
|
55
|
+
* @param {Object} options
|
|
56
|
+
* @param {string|null} [options.prBody] - Pull request body
|
|
57
|
+
* @param {string|null} [options.prTitle] - Pull request title
|
|
58
|
+
* @param {string|number} options.issueNumber - Issue the PR should close
|
|
59
|
+
* @param {string|null} [options.owner] - Repository owner (for cross-repo refs)
|
|
60
|
+
* @param {string|null} [options.repo] - Repository name (for cross-repo refs)
|
|
61
|
+
* @param {string|null} [options.baseBranch] - Branch the PR targets
|
|
62
|
+
* @param {string|null} [options.defaultBranch] - Repository default branch
|
|
63
|
+
* @param {boolean} [options.githubLinked] - Whether GitHub already reports the
|
|
64
|
+
* issue in `closingIssuesReferences`
|
|
65
|
+
* @returns {{
|
|
66
|
+
* hasClosingKeyword: boolean,
|
|
67
|
+
* githubLinked: boolean,
|
|
68
|
+
* autoCloses: boolean|null,
|
|
69
|
+
* targetsNonDefaultBranch: boolean,
|
|
70
|
+
* requiresManualClose: boolean,
|
|
71
|
+
* reason: string,
|
|
72
|
+
* }}
|
|
73
|
+
*/
|
|
74
|
+
export function classifyIssueLinkStatus({ prBody = '', prTitle = '', issueNumber, owner = null, repo = null, baseBranch = null, defaultBranch = null, githubLinked = false } = {}) {
|
|
75
|
+
const hasClosingKeyword = prClosesIssue(prBody, issueNumber, owner, repo) || prClosesIssue(prTitle, issueNumber, owner, repo);
|
|
76
|
+
const autoCloses = gitHubAutoClosesOnMerge(baseBranch, defaultBranch);
|
|
77
|
+
const targetsNonDefaultBranch = autoCloses === false;
|
|
78
|
+
|
|
79
|
+
let reason;
|
|
80
|
+
let requiresManualClose = false;
|
|
81
|
+
|
|
82
|
+
if (githubLinked) {
|
|
83
|
+
reason = 'github-linked';
|
|
84
|
+
} else if (!hasClosingKeyword) {
|
|
85
|
+
// The keyword is genuinely absent — the historical advice applies.
|
|
86
|
+
reason = 'missing-keyword';
|
|
87
|
+
} else if (targetsNonDefaultBranch) {
|
|
88
|
+
// The keyword IS present, but the PR targets a non-default branch, so
|
|
89
|
+
// GitHub never registers the closing reference and will not auto-close.
|
|
90
|
+
reason = 'non-default-base-branch';
|
|
91
|
+
requiresManualClose = true;
|
|
92
|
+
} else {
|
|
93
|
+
// Keyword present, base looks like default (or unknown): GitHub is
|
|
94
|
+
// expected to register the link, possibly after a short delay.
|
|
95
|
+
reason = 'keyword-present-link-pending';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
hasClosingKeyword,
|
|
100
|
+
githubLinked: Boolean(githubLinked),
|
|
101
|
+
autoCloses,
|
|
102
|
+
targetsNonDefaultBranch,
|
|
103
|
+
requiresManualClose,
|
|
104
|
+
reason,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the human-readable lines that explain a non-default-base-branch linking
|
|
110
|
+
* failure. Shared so solve and merge code paths print the same explanation.
|
|
111
|
+
*
|
|
112
|
+
* @param {Object} options
|
|
113
|
+
* @param {string|number} options.issueNumber
|
|
114
|
+
* @param {string} options.baseBranch
|
|
115
|
+
* @param {string} options.defaultBranch
|
|
116
|
+
* @param {string} [options.issueRef] - Display reference such as `#49` or `owner/repo#49`
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
export function buildNonDefaultBranchExplanation({ issueNumber, baseBranch, defaultBranch, issueRef = `#${issueNumber}` }) {
|
|
120
|
+
return [`The PR closing keyword for ${issueRef} is present, but the PR targets the`, `non-default branch '${baseBranch}' (the repository default is '${defaultBranch}').`, 'GitHub only registers closing references and auto-closes linked issues for', 'pull requests merged into the default branch, so:', ` • the automatic link to issue ${issueRef} will not appear, and`, ` • issue ${issueRef} will NOT be closed automatically when this PR merges.`, 'hive-mind will close the linked issue explicitly after the merge instead.'];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* After a PR has been merged, ensure the linked issue is closed when GitHub will
|
|
125
|
+
* not do it automatically (i.e. the PR targeted a non-default branch).
|
|
126
|
+
*
|
|
127
|
+
* This is a no-op (returns a skipped result) when:
|
|
128
|
+
* - GitHub will auto-close the issue (PR merged into the default branch), or
|
|
129
|
+
* - the PR body/title does not contain a closing keyword for the issue, or
|
|
130
|
+
* - the issue is already closed.
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} options
|
|
133
|
+
* @param {Function} options.$ - command-stream `$` exec helper
|
|
134
|
+
* @param {Function} [options.log] - async logger
|
|
135
|
+
* @param {string} options.owner
|
|
136
|
+
* @param {string} options.repo
|
|
137
|
+
* @param {string|number} options.prNumber
|
|
138
|
+
* @param {string|number|null} [options.issueNumber] - Issue the PR should close
|
|
139
|
+
* (derived from the PR body closing keyword when omitted)
|
|
140
|
+
* @param {string|null} [options.baseBranch] - Branch the PR targeted (fetched if omitted)
|
|
141
|
+
* @param {string|null} [options.defaultBranch] - Repo default branch (fetched if omitted)
|
|
142
|
+
* @param {string|null} [options.prBody] - PR body (fetched if omitted)
|
|
143
|
+
* @param {string|null} [options.prTitle] - PR title (fetched if omitted)
|
|
144
|
+
* @param {boolean} [options.verbose]
|
|
145
|
+
* @returns {Promise<{closed: boolean, skipped: boolean, reason: string, error?: string}>}
|
|
146
|
+
*/
|
|
147
|
+
export async function ensureLinkedIssueClosedAfterMerge({ $: rawDollar, log = null, owner, repo, prNumber, issueNumber = null, baseBranch = null, defaultBranch = null, prBody = null, prTitle = null, verbose = false }) {
|
|
148
|
+
// Issue #1726: route every `gh ...` call through the rate-limit-aware wrapper.
|
|
149
|
+
const $ = wrapDollarWithGhRetry(rawDollar);
|
|
150
|
+
const note = async message => {
|
|
151
|
+
if (log) {
|
|
152
|
+
await log(message, { verbose: true });
|
|
153
|
+
} else if (verbose) {
|
|
154
|
+
console.log(message);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
if (!owner || !repo || !prNumber) {
|
|
159
|
+
return { closed: false, skipped: true, reason: 'missing-parameters' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// Fetch PR metadata (base branch, body, title) if not provided.
|
|
164
|
+
if (baseBranch === null || prBody === null || prTitle === null) {
|
|
165
|
+
const prView = await $`gh pr view ${prNumber} --repo ${owner}/${repo} --json baseRefName,body,title`;
|
|
166
|
+
if (prView.code === 0) {
|
|
167
|
+
const data = JSON.parse(prView.stdout.toString().trim() || '{}');
|
|
168
|
+
if (baseBranch === null) baseBranch = data.baseRefName ?? null;
|
|
169
|
+
if (prBody === null) prBody = data.body ?? '';
|
|
170
|
+
if (prTitle === null) prTitle = data.title ?? '';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Derive the linked issue from the PR body when the caller did not supply it.
|
|
175
|
+
if (!issueNumber) {
|
|
176
|
+
issueNumber = extractLinkedIssueNumber(prBody || '') || extractLinkedIssueNumber(prTitle || '');
|
|
177
|
+
}
|
|
178
|
+
if (!issueNumber) {
|
|
179
|
+
await note(`[auto-close] PR #${prNumber} has no closing keyword identifying an issue; nothing to close`);
|
|
180
|
+
return { closed: false, skipped: true, reason: 'no-linked-issue' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Fetch the repository default branch if not provided.
|
|
184
|
+
if (defaultBranch === null) {
|
|
185
|
+
const repoView = await $`gh api repos/${owner}/${repo} --jq .default_branch`;
|
|
186
|
+
if (repoView.code === 0) {
|
|
187
|
+
defaultBranch = repoView.stdout.toString().trim() || null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const status = classifyIssueLinkStatus({ prBody: prBody || '', prTitle: prTitle || '', issueNumber, owner, repo, baseBranch, defaultBranch });
|
|
192
|
+
|
|
193
|
+
if (status.autoCloses === true) {
|
|
194
|
+
await note(`[auto-close] GitHub will auto-close issue #${issueNumber} (PR #${prNumber} merged into default branch '${defaultBranch}')`);
|
|
195
|
+
return { closed: false, skipped: true, reason: 'github-auto-closes' };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!status.hasClosingKeyword) {
|
|
199
|
+
await note(`[auto-close] PR #${prNumber} has no closing keyword for issue #${issueNumber}; not closing it`);
|
|
200
|
+
return { closed: false, skipped: true, reason: 'no-closing-keyword' };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (status.autoCloses === null) {
|
|
204
|
+
await note(`[auto-close] Could not determine base/default branch for PR #${prNumber}; leaving issue #${issueNumber} to GitHub`);
|
|
205
|
+
return { closed: false, skipped: true, reason: 'unknown-branch' };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Check current issue state — do not act if it is already closed.
|
|
209
|
+
const issueState = await $`gh issue view ${issueNumber} --repo ${owner}/${repo} --json state,stateReason`;
|
|
210
|
+
if (issueState.code === 0) {
|
|
211
|
+
const data = JSON.parse(issueState.stdout.toString().trim() || '{}');
|
|
212
|
+
if (String(data.state).toUpperCase() === 'CLOSED') {
|
|
213
|
+
await note(`[auto-close] Issue #${issueNumber} is already closed`);
|
|
214
|
+
return { closed: false, skipped: true, reason: 'already-closed' };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Close the issue explicitly, leaving an explanatory trail.
|
|
219
|
+
const comment = [`Closed by #${prNumber}, which targeted the non-default branch \`${baseBranch}\` (repository default: \`${defaultBranch}\`).`, '', 'GitHub only auto-closes linked issues for pull requests merged into the default branch,', 'so hive-mind closed this issue explicitly after the merge.', '', '_Automated by hive-mind ([#1895](https://github.com/link-assistant/hive-mind/issues/1895))._'].join('\n');
|
|
220
|
+
|
|
221
|
+
const closeResult = await $`gh issue close ${issueNumber} --repo ${owner}/${repo} --reason completed --comment ${comment}`;
|
|
222
|
+
if (closeResult.code === 0) {
|
|
223
|
+
if (log) {
|
|
224
|
+
await log(`🔗 Closed issue #${issueNumber} explicitly (PR #${prNumber} merged into non-default branch '${baseBranch}')`);
|
|
225
|
+
}
|
|
226
|
+
return { closed: true, skipped: false, reason: 'closed-explicitly' };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { closed: false, skipped: false, reason: 'close-command-failed', error: closeResult.stderr?.toString?.() || 'unknown error' };
|
|
230
|
+
} catch (error) {
|
|
231
|
+
return { closed: false, skipped: false, reason: 'exception', error: error.message };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default {
|
|
236
|
+
gitHubAutoClosesOnMerge,
|
|
237
|
+
classifyIssueLinkStatus,
|
|
238
|
+
buildNonDefaultBranchExplanation,
|
|
239
|
+
ensureLinkedIssueClosedAfterMerge,
|
|
240
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* GitHub Merge — Linked Issue Close Helper
|
|
4
|
+
*
|
|
5
|
+
* Issue #1895: After merging a PR, GitHub only auto-closes the linked issue when
|
|
6
|
+
* the PR targeted the repository's default branch. For PRs merged into a
|
|
7
|
+
* non-default branch (e.g. a stacked / sub-issue branch), the linked issue stays
|
|
8
|
+
* open even though the PR body contains a valid `Fixes #N` keyword. This helper
|
|
9
|
+
* closes the linked issue explicitly in that case.
|
|
10
|
+
*
|
|
11
|
+
* Extracted from github-merge.lib.mjs to keep that file under the 1500-line
|
|
12
|
+
* limit (same rationale as the Issue #1413 ready-sync split).
|
|
13
|
+
*
|
|
14
|
+
* @see https://github.com/link-assistant/hive-mind/issues/1895
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promisify } from 'util';
|
|
18
|
+
import { exec as execCallback } from 'child_process';
|
|
19
|
+
|
|
20
|
+
import { githubLimits } from './config.lib.mjs';
|
|
21
|
+
import { ghWithRateLimitRetry } from './github-rate-limit.lib.mjs';
|
|
22
|
+
import { extractLinkedIssueNumber } from './github-linking.lib.mjs';
|
|
23
|
+
import { classifyIssueLinkStatus } from './github-issue-auto-close.lib.mjs';
|
|
24
|
+
import { getDefaultBranch } from './github-merge.lib.mjs';
|
|
25
|
+
|
|
26
|
+
const execRaw = promisify(execCallback);
|
|
27
|
+
|
|
28
|
+
// Issue #1726: keep every gh call rate-limit safe (mirrors github-merge.lib.mjs).
|
|
29
|
+
const exec = (cmd, opts = {}) =>
|
|
30
|
+
ghWithRateLimitRetry(() => execRaw(cmd, { maxBuffer: githubLimits.bufferMaxSize, ...opts }), {
|
|
31
|
+
label: `gh exec (${cmd.split(/\s+/).slice(0, 3).join(' ')})`,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* After merging a PR, explicitly close the linked issue when GitHub will not
|
|
36
|
+
* auto-close it (i.e. the PR targeted a non-default branch).
|
|
37
|
+
*
|
|
38
|
+
* @param {string} owner - Repository owner
|
|
39
|
+
* @param {string} repo - Repository name
|
|
40
|
+
* @param {number} prNumber - Merged pull request number
|
|
41
|
+
* @param {boolean} verbose - Whether to log verbose output
|
|
42
|
+
* @returns {Promise<{closed: boolean, skipped: boolean, reason: string, issueNumber?: number}>}
|
|
43
|
+
*/
|
|
44
|
+
export async function closeLinkedIssueIfNotAutoClosed(owner, repo, prNumber, verbose = false) {
|
|
45
|
+
try {
|
|
46
|
+
const { stdout: prJson } = await exec(`gh pr view ${prNumber} --repo ${owner}/${repo} --json baseRefName,body,title`);
|
|
47
|
+
const pr = JSON.parse(prJson.trim() || '{}');
|
|
48
|
+
const baseBranch = pr.baseRefName || null;
|
|
49
|
+
const prBody = pr.body || '';
|
|
50
|
+
const prTitle = pr.title || '';
|
|
51
|
+
|
|
52
|
+
const issueNumber = extractLinkedIssueNumber(prBody) || extractLinkedIssueNumber(prTitle);
|
|
53
|
+
if (!issueNumber) {
|
|
54
|
+
if (verbose) console.log(`[VERBOSE] /merge: PR #${prNumber} has no closing keyword; no issue to close`);
|
|
55
|
+
return { closed: false, skipped: true, reason: 'no-linked-issue' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const defaultBranch = await getDefaultBranch(owner, repo, verbose);
|
|
59
|
+
const status = classifyIssueLinkStatus({ prBody, prTitle, issueNumber, owner, repo, baseBranch, defaultBranch });
|
|
60
|
+
|
|
61
|
+
if (status.autoCloses !== false) {
|
|
62
|
+
// GitHub handles it (default branch) or branch info is unknown.
|
|
63
|
+
if (verbose) console.log(`[VERBOSE] /merge: Issue #${issueNumber} will be handled by GitHub (base '${baseBranch}', default '${defaultBranch}')`);
|
|
64
|
+
return { closed: false, skipped: true, reason: status.autoCloses === true ? 'github-auto-closes' : 'unknown-branch', issueNumber: Number(issueNumber) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Skip if already closed.
|
|
68
|
+
try {
|
|
69
|
+
const { stdout: issueJson } = await exec(`gh issue view ${issueNumber} --repo ${owner}/${repo} --json state`);
|
|
70
|
+
const issue = JSON.parse(issueJson.trim() || '{}');
|
|
71
|
+
if (String(issue.state).toUpperCase() === 'CLOSED') {
|
|
72
|
+
if (verbose) console.log(`[VERBOSE] /merge: Issue #${issueNumber} already closed`);
|
|
73
|
+
return { closed: false, skipped: true, reason: 'already-closed', issueNumber: Number(issueNumber) };
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// If state lookup fails, fall through and attempt the close.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const comment = `Closed by #${prNumber}, which targeted the non-default branch \`${baseBranch}\` (repository default: \`${defaultBranch}\`).\n\nGitHub only auto-closes linked issues for pull requests merged into the default branch, so hive-mind closed this issue explicitly after the merge.\n\n_Automated by hive-mind ([#1895](https://github.com/link-assistant/hive-mind/issues/1895))._`;
|
|
80
|
+
await exec(`gh issue close ${issueNumber} --repo ${owner}/${repo} --reason completed --comment ${JSON.stringify(comment)}`);
|
|
81
|
+
if (verbose) console.log(`[VERBOSE] /merge: Closed issue #${issueNumber} explicitly (PR #${prNumber} merged into non-default branch '${baseBranch}')`);
|
|
82
|
+
return { closed: true, skipped: false, reason: 'closed-explicitly', issueNumber: Number(issueNumber) };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (verbose) console.log(`[VERBOSE] /merge: Error closing linked issue for PR #${prNumber}: ${error.message}`);
|
|
85
|
+
return { closed: false, skipped: false, reason: 'exception', error: error.message };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default { closeLinkedIssueIfNotAutoClosed };
|
package/src/github-merge.lib.mjs
CHANGED
|
@@ -42,6 +42,12 @@ const exec = (cmd, opts = {}) =>
|
|
|
42
42
|
import { syncReadyTags, getLinkedPRsFromTimeline, READY_LABEL } from './github-merge-ready-sync.lib.mjs';
|
|
43
43
|
export { syncReadyTags, getLinkedPRsFromTimeline, READY_LABEL };
|
|
44
44
|
|
|
45
|
+
// Issue #1895: After merging into a non-default branch, close the linked issue
|
|
46
|
+
// explicitly (GitHub only auto-closes for default-branch merges). Extracted to a
|
|
47
|
+
// separate module to keep this file under the 1500-line limit.
|
|
48
|
+
import { closeLinkedIssueIfNotAutoClosed } from './github-merge-issue-close.lib.mjs';
|
|
49
|
+
export { closeLinkedIssueIfNotAutoClosed };
|
|
50
|
+
|
|
45
51
|
/**
|
|
46
52
|
* Check if 'ready' label exists in repository
|
|
47
53
|
* @param {string} owner - Repository owner
|
|
@@ -1419,6 +1425,7 @@ export default {
|
|
|
1419
1425
|
getActiveBranchRuns, // Issue #1307: New exports for target branch CI waiting
|
|
1420
1426
|
waitForBranchCI,
|
|
1421
1427
|
getDefaultBranch,
|
|
1428
|
+
closeLinkedIssueIfNotAutoClosed, // Issue #1895: close linked issue after merge into non-default branch
|
|
1422
1429
|
// Issue #1314: Billing limit detection and enhanced CI status and re-run capabilities
|
|
1423
1430
|
getCheckRunAnnotations,
|
|
1424
1431
|
getRepoVisibility,
|
|
@@ -32,13 +32,21 @@ const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'c
|
|
|
32
32
|
const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
|
|
33
33
|
const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
|
|
34
34
|
const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
|
|
35
|
-
// Docker's `--pull` accepts these policies. We only emit the flag when an
|
|
36
|
-
// operator explicitly opts in; otherwise Docker's own default ("missing")
|
|
37
|
-
// applies and `docker run` reuses any locally present image. See issue #1879.
|
|
38
|
-
const VALID_DOCKER_PULL_POLICIES = new Set(['always', 'missing', 'never']);
|
|
39
|
-
const DOCKER_ISOLATION_TRACKING_BACKEND = 'screen';
|
|
40
35
|
const DOCKER_CONTAINER_HOME = '/home/box';
|
|
41
|
-
|
|
36
|
+
// Default path where the host Docker socket is bind-mounted inside a DinD
|
|
37
|
+
// container so box's host-image passthrough can copy host images into the
|
|
38
|
+
// nested daemon. Matches box's own DIND_HOST_DOCKER_SOCK default. The deploy
|
|
39
|
+
// must mount it (`-v /var/run/docker.sock:/var/run/host-docker.sock:ro`) or the
|
|
40
|
+
// nested daemon starts empty and the first isolated task pulls the full,
|
|
41
|
+
// multi-gigabyte image. See issue #1914.
|
|
42
|
+
const DEFAULT_HOST_DOCKER_SOCK = '/var/run/host-docker.sock';
|
|
43
|
+
// Force a POSIX shell for the inner command of Docker-isolated tasks. solve/
|
|
44
|
+
// hive/task live on the image's baked-in PATH, so `sh -c` resolves them without
|
|
45
|
+
// needing a login shell. Forcing the shell (instead of start's 'auto') also
|
|
46
|
+
// skips start's shell-detection probe, which would otherwise `docker run` a
|
|
47
|
+
// throwaway container — booting the dind image's dockerd entrypoint — purely to
|
|
48
|
+
// check whether bash exists. See issue #1914.
|
|
49
|
+
const DOCKER_ISOLATION_SHELL = 'sh';
|
|
42
50
|
|
|
43
51
|
function normalizeProcessIds(value) {
|
|
44
52
|
if (!value || typeof value !== 'object') return {};
|
|
@@ -62,19 +70,10 @@ function shellQuote(value) {
|
|
|
62
70
|
return `'${stringValue.replaceAll("'", "'\\''")}'`;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
function shellDoubleQuote(value) {
|
|
66
|
-
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('$', '\\$').replaceAll('`', '\\`')}"`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
73
|
function buildShellCommand(command, args = []) {
|
|
70
74
|
return [command, ...args].map(shellQuote).join(' ');
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
function makeDockerContainerName(sessionId) {
|
|
74
|
-
const normalizedSession = String(sessionId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.-]/g, '-');
|
|
75
|
-
return `${DOCKER_CONTAINER_PREFIX}-${normalizedSession}`;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
77
|
function shouldRunPrivilegedDockerIsolation(image, env = process.env) {
|
|
79
78
|
return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' || String(image || '').includes('hive-mind-dind');
|
|
80
79
|
}
|
|
@@ -117,20 +116,15 @@ export function getDockerIsolationImage({ env = process.env } = {}) {
|
|
|
117
116
|
}
|
|
118
117
|
|
|
119
118
|
/**
|
|
120
|
-
* Resolve the Docker
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* present in the (possibly nested) daemon and fail fast instead of silently
|
|
126
|
-
* re-downloading it. Invalid values are ignored. See issue #1879.
|
|
119
|
+
* Resolve the path where the host Docker socket is expected to be mounted inside
|
|
120
|
+
* a DinD container. box's entrypoint reads this socket to copy host images into
|
|
121
|
+
* the nested daemon (host-image passthrough). Defaults to
|
|
122
|
+
* `/var/run/host-docker.sock` and can be overridden with `DIND_HOST_DOCKER_SOCK`
|
|
123
|
+
* (the same variable box honors). See issue #1914.
|
|
127
124
|
*/
|
|
128
|
-
export function
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
.toLowerCase();
|
|
132
|
-
if (!raw) return null;
|
|
133
|
-
return VALID_DOCKER_PULL_POLICIES.has(raw) ? raw : null;
|
|
125
|
+
export function resolveHostDockerSock({ env = process.env } = {}) {
|
|
126
|
+
const explicit = String(env.DIND_HOST_DOCKER_SOCK || '').trim();
|
|
127
|
+
return explicit || DEFAULT_HOST_DOCKER_SOCK;
|
|
134
128
|
}
|
|
135
129
|
|
|
136
130
|
/**
|
|
@@ -157,46 +151,63 @@ export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.en
|
|
|
157
151
|
}
|
|
158
152
|
|
|
159
153
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* auth mounts directly.
|
|
154
|
+
* Resolve the image-variant marker recorded inside the isolated container.
|
|
155
|
+
* A `hive-mind-dind` image is always the dind variant; otherwise fall back to
|
|
156
|
+
* the parent's `HIVE_MIND_IMAGE_VARIANT` (or `regular`).
|
|
164
157
|
*/
|
|
165
|
-
|
|
158
|
+
function resolveImageVariant(image, env = process.env) {
|
|
159
|
+
return image.includes('hive-mind-dind') ? 'dind' : env.HIVE_MIND_IMAGE_VARIANT || 'regular';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Build the `$` (start-command) arguments that launch a Docker-isolated task
|
|
164
|
+
* using start-command's NATIVE Docker backend (`$ --isolated docker`).
|
|
165
|
+
*
|
|
166
|
+
* Issue #1914: earlier versions wrapped a hand-rolled `docker run` inside a
|
|
167
|
+
* `screen` session (`$ --isolated screen -- docker run …`). That was *screen*
|
|
168
|
+
* isolation merely shelling out to Docker — not Docker isolation. We now hand
|
|
169
|
+
* the container lifecycle to start-command itself and only contribute the
|
|
170
|
+
* pieces Hive Mind must control: which image to run, privileged mode for the
|
|
171
|
+
* dind variant, the environment markers, and the credential mounts scoped to
|
|
172
|
+
* the selected tool.
|
|
173
|
+
*
|
|
174
|
+
* start-command's Docker backend reuses a locally present image and only pulls
|
|
175
|
+
* when it is missing (`docker run` with Docker's default "missing" pull
|
|
176
|
+
* policy), so a host image seeded into the nested daemon via box passthrough is
|
|
177
|
+
* reused instead of re-downloaded — no `--pull` plumbing required (issue #1879).
|
|
178
|
+
*/
|
|
179
|
+
export function buildDockerIsolationStartArgs(command, args = [], options = {}) {
|
|
166
180
|
const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
|
|
167
181
|
const image = getDockerIsolationImage({ env });
|
|
168
|
-
const innerCommand = buildShellCommand(command, args);
|
|
169
|
-
const dockerArgs = ['docker', 'run', '--rm'];
|
|
170
|
-
|
|
171
|
-
// Reuse a locally present image instead of re-downloading it when the
|
|
172
|
-
// operator opts in. Omitted by default so Docker's "missing" policy applies.
|
|
173
|
-
const pullPolicy = getDockerIsolationPullPolicy({ env });
|
|
174
|
-
if (pullPolicy) {
|
|
175
|
-
dockerArgs.push('--pull', pullPolicy);
|
|
176
|
-
}
|
|
177
182
|
|
|
178
|
-
|
|
183
|
+
const startArgs = ['--isolated', 'docker', '--image', image];
|
|
179
184
|
|
|
180
185
|
if (shouldRunPrivilegedDockerIsolation(image, env)) {
|
|
181
|
-
|
|
186
|
+
startArgs.push('--privileged');
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
|
|
185
|
-
|
|
189
|
+
// Force the inner shell so start-command does not probe the image to detect
|
|
190
|
+
// one (see DOCKER_ISOLATION_SHELL).
|
|
191
|
+
startArgs.push('--shell', DOCKER_ISOLATION_SHELL);
|
|
192
|
+
|
|
193
|
+
// The image already sets HOME=/home/box and WORKDIR /home/box; pass HOME
|
|
194
|
+
// explicitly anyway so the credential mounts under /home/box resolve even if
|
|
195
|
+
// a future image forgets to. start-command has no --workdir flag, so the
|
|
196
|
+
// working directory comes from the image's WORKDIR.
|
|
197
|
+
startArgs.push('-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`, '-e', `HIVE_MIND_IMAGE_VARIANT=${resolveImageVariant(image, env)}`);
|
|
186
198
|
|
|
187
199
|
for (const mount of getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync })) {
|
|
188
|
-
|
|
200
|
+
startArgs.push('--volume', `${mount.source}:${mount.target}`);
|
|
189
201
|
}
|
|
190
202
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return [...dockerArgs.map(shellQuote), shellDoubleQuote(innerCommand)].join(' ');
|
|
203
|
+
startArgs.push('--detached', '--session', sessionId, '--', buildShellCommand(command, args));
|
|
204
|
+
return startArgs;
|
|
194
205
|
}
|
|
195
206
|
|
|
196
207
|
export function buildStartCommandArgs(command, args = [], options = {}) {
|
|
197
208
|
const { backend, sessionId } = options;
|
|
198
209
|
if (backend === 'docker') {
|
|
199
|
-
return
|
|
210
|
+
return buildDockerIsolationStartArgs(command, args, { ...options, sessionId });
|
|
200
211
|
}
|
|
201
212
|
return ['--isolated', backend, '--detached', '--session', sessionId, '--', buildShellCommand(command, args)];
|
|
202
213
|
}
|
|
@@ -413,10 +424,11 @@ export async function executeWithIsolation(command, args, options = {}) {
|
|
|
413
424
|
if (backend === 'docker') {
|
|
414
425
|
const env = options.env || process.env;
|
|
415
426
|
const image = getDockerIsolationImage({ env });
|
|
416
|
-
const pullPolicy = getDockerIsolationPullPolicy({ env });
|
|
417
427
|
const mounts = getDockerIsolationAuthMounts({ tool: options.tool, env, homeDir: options.homeDir || os.homedir(), existsSync: options.existsSync || fs.existsSync });
|
|
428
|
+
console.log('[VERBOSE] isolation-runner: Docker isolation backend: native ($ --isolated docker)');
|
|
418
429
|
console.log(`[VERBOSE] isolation-runner: Docker isolation image: ${image}`);
|
|
419
|
-
console.log(`[VERBOSE] isolation-runner: Docker isolation
|
|
430
|
+
console.log(`[VERBOSE] isolation-runner: Docker isolation privileged: ${shouldRunPrivilegedDockerIsolation(image, env)}`);
|
|
431
|
+
console.log('[VERBOSE] isolation-runner: Docker isolation pull: reuse local image if present, pull only if missing (start-command default)');
|
|
420
432
|
console.log(`[VERBOSE] isolation-runner: Docker isolation mounts: ${mounts.map(m => m.target).join(', ') || '(none)'}`);
|
|
421
433
|
}
|
|
422
434
|
}
|
|
@@ -553,12 +565,126 @@ export async function checkScreenSessionRunning(sessionName, verbose = false) {
|
|
|
553
565
|
}
|
|
554
566
|
}
|
|
555
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Check whether the Docker container backing a native `$ --isolated docker`
|
|
570
|
+
* session is still running.
|
|
571
|
+
*
|
|
572
|
+
* start-command names the container after the `--session` value, so the
|
|
573
|
+
* (possibly nested) Docker daemon can be queried directly. This is the
|
|
574
|
+
* native-Docker analogue of the `screen -ls` fallback: it is consulted only
|
|
575
|
+
* when `$ --status` has no usable record. The bot runs inside a Docker-in-
|
|
576
|
+
* Docker container, so `docker` here talks to the same nested daemon that
|
|
577
|
+
* start-command launched the task container on. See issue #1914.
|
|
578
|
+
*
|
|
579
|
+
* @param {string} containerName - Container name (the session UUID)
|
|
580
|
+
* @param {boolean} [verbose] - Enable verbose logging
|
|
581
|
+
* @returns {Promise<boolean>} True if the container exists and is running
|
|
582
|
+
*/
|
|
583
|
+
export async function checkDockerContainerRunning(containerName, verbose = false) {
|
|
584
|
+
try {
|
|
585
|
+
const result = await $({ mirror: false })`docker inspect -f ${'{{.State.Running}}'} ${containerName}`;
|
|
586
|
+
const running = (result.stdout?.toString() || '').trim() === 'true';
|
|
587
|
+
if (verbose) {
|
|
588
|
+
console.log(`[VERBOSE] isolation-runner: docker inspect for '${containerName}': ${running ? 'running' : 'not running'}`);
|
|
589
|
+
}
|
|
590
|
+
return running;
|
|
591
|
+
} catch {
|
|
592
|
+
// `docker inspect` exits non-zero when no such container exists.
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Check whether an image is present in the local Docker daemon.
|
|
599
|
+
*
|
|
600
|
+
* Inside a Docker-in-Docker container "local" is the NESTED daemon. `docker
|
|
601
|
+
* image inspect` exits 0 only when the image exists, so a non-zero exit (or a
|
|
602
|
+
* missing docker binary) is treated as absent. Used by the startup preflight to
|
|
603
|
+
* predict whether the first isolated task will trigger a full image pull.
|
|
604
|
+
* See issue #1914.
|
|
605
|
+
*
|
|
606
|
+
* @param {string} image - Image reference (repo:tag)
|
|
607
|
+
* @param {boolean} [verbose] - Enable verbose logging
|
|
608
|
+
* @returns {Promise<boolean>} True if the image is present locally
|
|
609
|
+
*/
|
|
610
|
+
export async function checkDockerImagePresent(image, verbose = false) {
|
|
611
|
+
try {
|
|
612
|
+
await $({ mirror: false })`docker image inspect ${image}`;
|
|
613
|
+
if (verbose) console.log(`[VERBOSE] isolation-runner: docker image inspect '${image}': present`);
|
|
614
|
+
return true;
|
|
615
|
+
} catch {
|
|
616
|
+
if (verbose) console.log(`[VERBOSE] isolation-runner: docker image inspect '${image}': absent`);
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Startup preflight for `--isolation docker`.
|
|
623
|
+
*
|
|
624
|
+
* The bot usually runs inside a Docker-in-Docker container whose NESTED daemon
|
|
625
|
+
* starts with an empty image store. If the isolation image is not already in
|
|
626
|
+
* that nested daemon, the first isolated task makes `docker run` pull a fresh
|
|
627
|
+
* copy — which for the Hive Mind images is multiple gigabytes (issues #1914,
|
|
628
|
+
* #1879). box can seed the nested daemon automatically (host-image passthrough)
|
|
629
|
+
* but only when the host Docker socket is bind-mounted into the container; if it
|
|
630
|
+
* is not mounted, passthrough is a SILENT no-op and the re-download is the first
|
|
631
|
+
* symptom an operator sees.
|
|
632
|
+
*
|
|
633
|
+
* This preflight makes that condition observable at startup instead: it reports
|
|
634
|
+
* whether the image is already present (reuse, no pull) and, when it is absent,
|
|
635
|
+
* warns loudly with the exact remediation (mount the host socket / set the
|
|
636
|
+
* passthrough allowlist, or run the preload script). It never throws and never
|
|
637
|
+
* blocks startup — a misconfigured passthrough should degrade to a slow first
|
|
638
|
+
* task, not a dead bot.
|
|
639
|
+
*
|
|
640
|
+
* @param {Object} [options]
|
|
641
|
+
* @param {Object} [options.env] - Environment (defaults to process.env)
|
|
642
|
+
* @param {Function} [options.existsSync] - fs.existsSync (injectable for tests)
|
|
643
|
+
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
644
|
+
* @param {Object} [options.logger] - Logger with .log/.warn (defaults to console)
|
|
645
|
+
* @param {Function} [options.checkImagePresent] - Image-presence probe (injectable for tests)
|
|
646
|
+
* @returns {Promise<{image: string, sock: string, socketMounted: boolean, imagePresent: boolean, isDind: boolean, ok: boolean, warnings: string[]}>}
|
|
647
|
+
*/
|
|
648
|
+
export async function preflightDockerIsolation(options = {}) {
|
|
649
|
+
const { env = process.env, existsSync = fs.existsSync, verbose = false, logger = console, checkImagePresent = checkDockerImagePresent } = options;
|
|
650
|
+
|
|
651
|
+
const image = getDockerIsolationImage({ env });
|
|
652
|
+
const sock = resolveHostDockerSock({ env });
|
|
653
|
+
const isDind = shouldRunPrivilegedDockerIsolation(image, env);
|
|
654
|
+
const socketMounted = Boolean(existsSync(sock));
|
|
655
|
+
const imagePresent = Boolean(await checkImagePresent(image, verbose));
|
|
656
|
+
|
|
657
|
+
const result = { image, sock, socketMounted, imagePresent, isDind, ok: imagePresent, warnings: [] };
|
|
658
|
+
const info = typeof logger.log === 'function' ? logger.log.bind(logger) : () => {};
|
|
659
|
+
const warn = typeof logger.warn === 'function' ? logger.warn.bind(logger) : info;
|
|
660
|
+
|
|
661
|
+
if (imagePresent) {
|
|
662
|
+
info(`✅ Docker isolation image '${image}' is already present locally — isolated tasks reuse it (no multi-GB pull). See issue #1914.`);
|
|
663
|
+
return result;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Image absent: the first isolated task will pull the full image. Explain the
|
|
667
|
+
// most likely cause and the exact fix instead of letting the operator first
|
|
668
|
+
// discover it as a surprise multi-gigabyte download mid-task.
|
|
669
|
+
const preload = `node scripts/preload-dind-isolation-image.mjs --image ${image}`;
|
|
670
|
+
if (isDind && !socketMounted) {
|
|
671
|
+
result.warnings.push(`Docker isolation image '${image}' is NOT in the nested Docker daemon and the host Docker socket is not mounted at ${sock}. ` + `box host-image passthrough cannot seed the nested daemon, so the FIRST isolated task will pull the full image (the Hive Mind images are multiple GB). ` + `Fix the deployment: add '-v /var/run/docker.sock:${sock}:ro' and '-e DIND_HOST_PASSTHROUGH_IMAGES="konard/hive-mind konard/hive-mind-dind"' to the bot container's 'docker run', or seed it now with: ${preload}`);
|
|
672
|
+
} else if (isDind && socketMounted) {
|
|
673
|
+
result.warnings.push(`Docker isolation image '${image}' is NOT in the nested Docker daemon even though the host Docker socket is mounted at ${sock}. ` + `box host-image passthrough may have skipped it (check DIND_HOST_PASSTHROUGH mode, the DIND_HOST_PASSTHROUGH_IMAGES allowlist, and that the host actually has '${image}' with a registry digest). ` + `The first isolated task will pull the full image. Seed it now with: ${preload}`);
|
|
674
|
+
} else {
|
|
675
|
+
result.warnings.push(`Docker isolation image '${image}' is not present locally; the first isolated task will pull it. ` + `If this host already has it under a different tag, pin HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG, or seed it with: ${preload}`);
|
|
676
|
+
}
|
|
677
|
+
for (const w of result.warnings) warn(`⚠️ ${w}`);
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
680
|
+
|
|
556
681
|
/**
|
|
557
682
|
* Check if an isolated session is still running.
|
|
558
|
-
* Uses `$ --status` first, with a
|
|
559
|
-
*
|
|
683
|
+
* Uses `$ --status` first, with a backend-specific fallback (screen -ls for
|
|
684
|
+
* screen, docker inspect for docker) to work around start-command UUID
|
|
685
|
+
* mismatch issues.
|
|
560
686
|
*
|
|
561
|
-
* @param {string} sessionId - UUID of the session (
|
|
687
|
+
* @param {string} sessionId - UUID of the session (also the screen session name / docker container name)
|
|
562
688
|
* @param {Object} [options] - Options
|
|
563
689
|
* @param {string} [options.backend] - Isolation backend ('screen', 'tmux', 'docker')
|
|
564
690
|
* @param {boolean} [options.verbose] - Enable verbose logging
|
|
@@ -579,19 +705,29 @@ export async function isSessionRunning(sessionId, options = {}) {
|
|
|
579
705
|
}
|
|
580
706
|
}
|
|
581
707
|
|
|
582
|
-
// Fallback
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if (
|
|
592
|
-
|
|
708
|
+
// Fallback used only when `$ --status` has no usable record. This works
|
|
709
|
+
// around older start-command bugs where `$ --status` can't resolve a session
|
|
710
|
+
// by its --session name (only by an internal UUID). See issue #1545.
|
|
711
|
+
// - screen sessions: confirm via `screen -ls`.
|
|
712
|
+
// - docker sessions: confirm via `docker inspect` on the container that
|
|
713
|
+
// start-command named after the session UUID. Native Docker isolation
|
|
714
|
+
// (issue #1914) is a real container, not a screen wrapper, so the screen
|
|
715
|
+
// check no longer applies to it.
|
|
716
|
+
if (shouldFallbackToScreenStatus(result)) {
|
|
717
|
+
if (backend === 'screen') {
|
|
718
|
+
const screenRunning = await checkScreenSessionRunning(sessionId, verbose);
|
|
719
|
+
if (screenRunning && verbose) {
|
|
720
|
+
console.log(`[VERBOSE] isolation-runner: $ --status says not running, but screen -ls confirms session '${sessionId}' is still active`);
|
|
721
|
+
}
|
|
722
|
+
return screenRunning;
|
|
723
|
+
}
|
|
724
|
+
if (backend === 'docker') {
|
|
725
|
+
const containerRunning = await checkDockerContainerRunning(sessionId, verbose);
|
|
726
|
+
if (containerRunning && verbose) {
|
|
727
|
+
console.log(`[VERBOSE] isolation-runner: $ --status says not running, but docker inspect confirms container '${sessionId}' is still active`);
|
|
728
|
+
}
|
|
729
|
+
return containerRunning;
|
|
593
730
|
}
|
|
594
|
-
return screenRunning;
|
|
595
731
|
}
|
|
596
732
|
|
|
597
733
|
return false;
|
|
@@ -248,6 +248,65 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
|
|
|
248
248
|
}
|
|
249
249
|
};
|
|
250
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Collect candidate PRs for an issue from two complementary sources, deduped by
|
|
253
|
+
* PR number.
|
|
254
|
+
*
|
|
255
|
+
* Issue #1895: a pull request that targets a NON-default base branch (for
|
|
256
|
+
* example a stacked sub-issue branch like `issue-47-...`, or any PR created with
|
|
257
|
+
* `--base-branch <not-main>`) never appears in GitHub's `linked:issue` search,
|
|
258
|
+
* because GitHub only registers closing references for PRs merged into the
|
|
259
|
+
* repository's default branch. Relying on the `linked:issue` search alone makes
|
|
260
|
+
* `--auto-continue` blind to those PRs and silently create a duplicate. We
|
|
261
|
+
* therefore ALSO search by the deterministic `issue-{N}-{hash}` head-branch
|
|
262
|
+
* name, which is a reliable signal regardless of which base branch the PR
|
|
263
|
+
* targets. Source 1 (the legacy `linked:issue` search) is preserved so previous
|
|
264
|
+
* linking behavior is unchanged; source 2 only ever adds PRs that source 1
|
|
265
|
+
* would have missed.
|
|
266
|
+
*
|
|
267
|
+
* The two result sets are merged and deduped by PR number; callers still apply
|
|
268
|
+
* `matchesIssuePattern` to reject any unrelated branch the search may surface.
|
|
269
|
+
*
|
|
270
|
+
* @param {Object} options
|
|
271
|
+
* @param {Function} options.$ - command-stream `$` exec helper (injected for testability)
|
|
272
|
+
* @param {string} options.owner
|
|
273
|
+
* @param {string} options.repo
|
|
274
|
+
* @param {string|number} options.issueNumber
|
|
275
|
+
* @returns {Promise<Array<{number:number, createdAt?:string, headRefName?:string, isDraft?:boolean, state?:string}>>}
|
|
276
|
+
*/
|
|
277
|
+
export const collectIssuePrCandidates = async ({ $: dollar = $, owner, repo, issueNumber }) => {
|
|
278
|
+
const candidates = new Map();
|
|
279
|
+
const collect = result => {
|
|
280
|
+
if (!result || result.code !== 0) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
let parsed;
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(result.stdout.toString().trim() || '[]');
|
|
286
|
+
} catch {
|
|
287
|
+
parsed = [];
|
|
288
|
+
}
|
|
289
|
+
for (const pr of parsed) {
|
|
290
|
+
if (pr && pr.number !== undefined && pr.number !== null) {
|
|
291
|
+
// Keep the first occurrence; the linked search runs first and both
|
|
292
|
+
// sources return the same shape, so dedup order is irrelevant.
|
|
293
|
+
if (!candidates.has(pr.number)) {
|
|
294
|
+
candidates.set(pr.number, pr);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Source 1 (legacy behavior, preserved): PRs GitHub reports as linked.
|
|
301
|
+
collect(await dollar`gh pr list --repo ${owner}/${repo} --search "linked:issue-${issueNumber}" --json number,createdAt,headRefName,isDraft,state --limit 10`);
|
|
302
|
+
|
|
303
|
+
// Source 2 (issue #1895): PRs whose head branch matches this issue's naming
|
|
304
|
+
// convention — reliably surfaces PRs targeting a non-default base branch.
|
|
305
|
+
collect(await dollar`gh pr list --repo ${owner}/${repo} --search "head:${getIssueBranchPrefix(issueNumber)}" --json number,createdAt,headRefName,isDraft,state --limit 20`);
|
|
306
|
+
|
|
307
|
+
return [...candidates.values()];
|
|
308
|
+
};
|
|
309
|
+
|
|
251
310
|
// Auto-continue logic: check for existing PRs if --auto-continue is enabled
|
|
252
311
|
export const checkExistingPRsForAutoContinue = async (argv, isIssueUrl, owner, repo, urlNumber) => {
|
|
253
312
|
let isContinueMode = false;
|
|
@@ -260,14 +319,14 @@ export const checkExistingPRsForAutoContinue = async (argv, isIssueUrl, owner, r
|
|
|
260
319
|
await log(`🔍 Auto-continue enabled: Checking for existing PRs for issue #${issueNumber}...`);
|
|
261
320
|
|
|
262
321
|
try {
|
|
263
|
-
// Get all PRs
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const prs = JSON.parse(prListResult.stdout.toString().trim() || '[]');
|
|
322
|
+
// Get all candidate PRs for this issue. Issue #1895: this includes PRs
|
|
323
|
+
// targeting a non-default base branch (found by head-branch name), which
|
|
324
|
+
// GitHub's `linked:issue` search omits.
|
|
325
|
+
const prs = await collectIssuePrCandidates({ $, owner, repo, issueNumber });
|
|
268
326
|
|
|
327
|
+
{
|
|
269
328
|
if (prs.length > 0) {
|
|
270
|
-
await log(`📋 Found ${prs.length} existing PR(s)
|
|
329
|
+
await log(`📋 Found ${prs.length} existing PR(s) for issue #${issueNumber}`);
|
|
271
330
|
|
|
272
331
|
// Find PRs that are older than 24 hours
|
|
273
332
|
const now = new Date();
|
|
@@ -535,14 +594,15 @@ export const processAutoContinueForIssue = async (argv, isIssueUrl, urlNumber, o
|
|
|
535
594
|
}
|
|
536
595
|
|
|
537
596
|
try {
|
|
538
|
-
// Get all PRs
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
597
|
+
// Get all candidate PRs for this issue. Issue #1895: this includes PRs
|
|
598
|
+
// targeting a non-default base branch (found by head-branch name), which
|
|
599
|
+
// GitHub's `linked:issue` search omits — critical so --auto-continue
|
|
600
|
+
// detects and resumes the existing PR instead of creating a duplicate.
|
|
601
|
+
const prs = await collectIssuePrCandidates({ $, owner, repo, issueNumber });
|
|
543
602
|
|
|
603
|
+
{
|
|
544
604
|
if (prs.length > 0) {
|
|
545
|
-
await log(`📋 Found ${prs.length} existing PR(s)
|
|
605
|
+
await log(`📋 Found ${prs.length} existing PR(s) for issue #${issueNumber}`);
|
|
546
606
|
|
|
547
607
|
// Find PRs that are older than 24 hours
|
|
548
608
|
const now = new Date();
|
|
@@ -77,6 +77,10 @@ const { maybeAttachWorkingSessionSummary, ensurePullRequestIssueLink } = results
|
|
|
77
77
|
const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
|
|
78
78
|
const { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit, shouldSyncBeforeRestart } = await import('./auto-iteration-limits.lib.mjs');
|
|
79
79
|
|
|
80
|
+
// Issue #1895: explicitly close linked issues after merging a PR into a
|
|
81
|
+
// non-default branch, where GitHub does not auto-close them.
|
|
82
|
+
const { ensureLinkedIssueClosedAfterMerge } = await import('./github-issue-auto-close.lib.mjs');
|
|
83
|
+
|
|
80
84
|
const shouldDeleteBranchAfterMerge = argv => argv.autoDeleteBranchOnMerge || argv.deleteBranchAfterMerge || false;
|
|
81
85
|
|
|
82
86
|
/**
|
|
@@ -309,6 +313,22 @@ export const watchUntilMergeable = async params => {
|
|
|
309
313
|
// Don't fail if comment posting fails
|
|
310
314
|
}
|
|
311
315
|
|
|
316
|
+
// Issue #1895: when the PR targeted a non-default branch GitHub does
|
|
317
|
+
// not auto-close the linked issue. Close it explicitly so the issue
|
|
318
|
+
// is not left open after its PR merges.
|
|
319
|
+
if (issueNumber) {
|
|
320
|
+
try {
|
|
321
|
+
const closeResult = await ensureLinkedIssueClosedAfterMerge({ $, log, owner, repo, prNumber, issueNumber, verbose: argv.verbose });
|
|
322
|
+
if (closeResult.skipped && argv.verbose) {
|
|
323
|
+
await log(formatAligned('', 'Issue auto-close:', `skipped (${closeResult.reason})`, 2), { verbose: true });
|
|
324
|
+
} else if (!closeResult.closed && !closeResult.skipped) {
|
|
325
|
+
await log(formatAligned('⚠️', 'Issue auto-close:', `could not close issue #${issueNumber} (${closeResult.reason})`, 2), { level: 'warning' });
|
|
326
|
+
}
|
|
327
|
+
} catch (closeError) {
|
|
328
|
+
await log(formatAligned('⚠️', 'Issue auto-close:', `error closing issue #${issueNumber}: ${closeError.message}`, 2), { level: 'warning' });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
312
332
|
return { success: true, reason: 'auto-merged', latestSessionId, latestAnthropicCost };
|
|
313
333
|
} else {
|
|
314
334
|
await log(formatAligned('⚠️', 'Auto-merge failed:', mergeResult.error || 'Unknown error', 2));
|
|
@@ -1171,7 +1191,7 @@ No further AI sessions will be started automatically for this run. Please review
|
|
|
1171
1191
|
* This implements the --auto-merge functionality for one-shot merge attempts
|
|
1172
1192
|
*/
|
|
1173
1193
|
export const attemptAutoMerge = async params => {
|
|
1174
|
-
const { owner, repo, prNumber, argv } = params;
|
|
1194
|
+
const { owner, repo, prNumber, issueNumber = null, argv } = params;
|
|
1175
1195
|
|
|
1176
1196
|
await log('');
|
|
1177
1197
|
await log(formatAligned('🔀', 'AUTO-MERGE:', 'Checking if PR can be merged...'));
|
|
@@ -1234,6 +1254,16 @@ export const attemptAutoMerge = async params => {
|
|
|
1234
1254
|
// Don't fail if comment posting fails
|
|
1235
1255
|
}
|
|
1236
1256
|
|
|
1257
|
+
// Issue #1895: close linked issue explicitly when GitHub will not (non-default base branch).
|
|
1258
|
+
try {
|
|
1259
|
+
const closeResult = await ensureLinkedIssueClosedAfterMerge({ $, log, owner, repo, prNumber, issueNumber, verbose: argv.verbose });
|
|
1260
|
+
if (!closeResult.closed && !closeResult.skipped) {
|
|
1261
|
+
await log(formatAligned('⚠️', 'Issue auto-close:', `could not close linked issue (${closeResult.reason})`, 2), { level: 'warning' });
|
|
1262
|
+
}
|
|
1263
|
+
} catch (closeError) {
|
|
1264
|
+
await log(formatAligned('⚠️', 'Issue auto-close:', `error: ${closeError.message}`, 2), { level: 'warning' });
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1237
1267
|
return { success: true, reason: 'merged' };
|
|
1238
1268
|
} else {
|
|
1239
1269
|
await log(formatAligned('⚠️', 'Merge failed:', mergeResult.error || 'Unknown error', 2));
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { closingIssueNumbersContain, parseClosingIssueNumbers } from './pr-issue-linking.lib.mjs';
|
|
7
|
+
import { classifyIssueLinkStatus, buildNonDefaultBranchExplanation } from './github-issue-auto-close.lib.mjs'; // Issue #1895: explain non-default-base-branch linking failures instead of the misleading "add Fixes #N" advice.
|
|
7
8
|
import { handleRejectedPushForAutoPr, synchronizeExistingIssueBranchBeforeAutoPrCreation } from './solve.branch-divergence.lib.mjs';
|
|
8
9
|
import { emitForkAwareDiagnostic } from './solve.auto-pr-fork-diagnostic.lib.mjs';
|
|
9
10
|
import { handleCompareApiNotReady } from './solve.auto-pr-compare-readiness.lib.mjs'; // Issue #1829: decides whether a failed compare-API readiness poll is fatal (fork mismatch / 0 commits) or a transient diff-render failure to degrade past.
|
|
@@ -1171,31 +1172,58 @@ ${prBody}`,
|
|
|
1171
1172
|
if (closingIssueNumbersContain(linkedIssues, issueNumber)) {
|
|
1172
1173
|
await log(formatAligned('✅', 'Link verified:', `Issue #${issueNumber} → PR #${localPrNumber}`));
|
|
1173
1174
|
} else {
|
|
1174
|
-
//
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1175
|
+
// The link wasn't registered by GitHub. Issue #1895: this is
|
|
1176
|
+
// expected (not a body problem) when the PR targets a
|
|
1177
|
+
// non-default branch, because GitHub only registers closing
|
|
1178
|
+
// references for PRs into the default branch. Diagnose the
|
|
1179
|
+
// real root cause instead of telling the user to add a
|
|
1180
|
+
// "Fixes #N" line that is already present.
|
|
1181
|
+
const targetBranch = argv.baseBranch || defaultBranch;
|
|
1182
|
+
const linkStatus = classifyIssueLinkStatus({
|
|
1183
|
+
prBody,
|
|
1184
|
+
issueNumber,
|
|
1185
|
+
owner,
|
|
1186
|
+
repo,
|
|
1187
|
+
baseBranch: targetBranch,
|
|
1188
|
+
defaultBranch,
|
|
1189
|
+
githubLinked: false,
|
|
1178
1190
|
});
|
|
1179
|
-
await log('');
|
|
1180
1191
|
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
});
|
|
1192
|
+
await log('');
|
|
1193
|
+
if (linkStatus.reason === 'non-default-base-branch') {
|
|
1194
|
+
// Keyword present + non-default base: GitHub will not
|
|
1195
|
+
// auto-close. This is handled later by the explicit
|
|
1196
|
+
// post-merge close fallback, so surface it as info.
|
|
1197
|
+
await log(formatAligned('ℹ️', 'ISSUE LINK DEFERRED:', `PR targets non-default branch '${targetBranch}'`));
|
|
1188
1198
|
await log('');
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1199
|
+
for (const line of buildNonDefaultBranchExplanation({ issueNumber, baseBranch: targetBranch, defaultBranch, issueRef })) {
|
|
1200
|
+
await log(` ${line}`);
|
|
1201
|
+
}
|
|
1192
1202
|
} else {
|
|
1193
|
-
await log(
|
|
1194
|
-
|
|
1203
|
+
await log(formatAligned('⚠️', 'ISSUE LINK MISSING:', 'PR not linked to issue'), {
|
|
1204
|
+
level: 'warning',
|
|
1205
|
+
});
|
|
1195
1206
|
await log('');
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1207
|
+
|
|
1208
|
+
if (argv.fork) {
|
|
1209
|
+
await log(" The PR was created from a fork but wasn't linked to the issue.", {
|
|
1210
|
+
level: 'warning',
|
|
1211
|
+
});
|
|
1212
|
+
await log(` Expected: "Fixes ${owner}/${repo}#${issueNumber}" in PR body`, {
|
|
1213
|
+
level: 'warning',
|
|
1214
|
+
});
|
|
1215
|
+
await log('');
|
|
1216
|
+
await log(' To fix manually:', { level: 'warning' });
|
|
1217
|
+
await log(` 1. Edit the PR description at: ${prUrl}`, { level: 'warning' });
|
|
1218
|
+
await log(` 2. Add this line: Fixes ${owner}/${repo}#${issueNumber}`, { level: 'warning' });
|
|
1219
|
+
} else {
|
|
1220
|
+
await log(` The PR wasn't linked to issue #${issueNumber}`, { level: 'warning' });
|
|
1221
|
+
await log(` Expected: "Fixes #${issueNumber}" in PR body`, { level: 'warning' });
|
|
1222
|
+
await log('');
|
|
1223
|
+
await log(' To fix manually:', { level: 'warning' });
|
|
1224
|
+
await log(` 1. Edit the PR description at: ${prUrl}`, { level: 'warning' });
|
|
1225
|
+
await log(` 2. Ensure it contains: Fixes #${issueNumber}`, { level: 'warning' });
|
|
1226
|
+
}
|
|
1199
1227
|
}
|
|
1200
1228
|
await log('');
|
|
1201
1229
|
}
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -180,6 +180,16 @@ if (ISOLATION_BACKEND) {
|
|
|
180
180
|
}
|
|
181
181
|
console.log(`🔒 Isolation mode enabled: ${ISOLATION_BACKEND} (experimental)`);
|
|
182
182
|
isolationRunner = await import('./isolation-runner.lib.mjs');
|
|
183
|
+
// For docker isolation, run a startup preflight so a missing/un-passed-through
|
|
184
|
+
// image surfaces as a loud, actionable warning instead of a surprise multi-GB
|
|
185
|
+
// pull on the first isolated task (issues #1914, #1879). Never throws.
|
|
186
|
+
if (ISOLATION_BACKEND === 'docker' && typeof isolationRunner.preflightDockerIsolation === 'function') {
|
|
187
|
+
try {
|
|
188
|
+
await isolationRunner.preflightDockerIsolation({ verbose: VERBOSE });
|
|
189
|
+
} catch (preflightError) {
|
|
190
|
+
console.error(`⚠️ Docker isolation preflight failed (continuing): ${preflightError?.message || preflightError}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
// Validate solve overrides early using solve's yargs config
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* @see https://github.com/link-assistant/hive-mind/issues/1143
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel, waitForBranchCI, getDefaultBranch, waitForCommitCI, checkBranchCIHealth, getMergeCommitSha, getPRStatus, syncReadyTags } from './github-merge.lib.mjs';
|
|
19
|
+
import { getAllReadyPRs, checkPRCIStatus, checkPRMergeable, mergePullRequest, waitForCI, ensureReadyLabel, waitForBranchCI, getDefaultBranch, waitForCommitCI, checkBranchCIHealth, getMergeCommitSha, getPRStatus, syncReadyTags, closeLinkedIssueIfNotAutoClosed } from './github-merge.lib.mjs';
|
|
20
20
|
import { mergeQueue as mergeQueueConfig } from './config.lib.mjs';
|
|
21
21
|
import { getProgressBar } from './limits.lib.mjs';
|
|
22
22
|
|
|
@@ -512,6 +512,17 @@ export class MergeQueueProcessor {
|
|
|
512
512
|
this.stats.merged++;
|
|
513
513
|
this.log(`Successfully merged PR #${item.pr.number}`);
|
|
514
514
|
|
|
515
|
+
// Issue #1895: GitHub does not auto-close linked issues for PRs merged into
|
|
516
|
+
// a non-default branch. Close the linked issue explicitly in that case.
|
|
517
|
+
try {
|
|
518
|
+
const closeResult = await closeLinkedIssueIfNotAutoClosed(this.owner, this.repo, item.pr.number, this.verbose);
|
|
519
|
+
if (closeResult.closed) {
|
|
520
|
+
this.log(`Closed linked issue #${closeResult.issueNumber} for PR #${item.pr.number} (merged into non-default branch)`);
|
|
521
|
+
}
|
|
522
|
+
} catch (closeError) {
|
|
523
|
+
this.log(`Could not close linked issue for PR #${item.pr.number}: ${closeError.message}`);
|
|
524
|
+
}
|
|
525
|
+
|
|
515
526
|
// Issue #1341: Get the merge commit SHA for post-merge CI tracking
|
|
516
527
|
// Need a small delay to allow GitHub to update the PR state
|
|
517
528
|
await this.sleep(5000);
|