@link-assistant/hive-mind 1.78.6 → 1.78.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.78.8
4
+
5
+ ### Patch Changes
6
+
7
+ - cb4986f: Close linked issues when a PR is merged into a non-default branch, and stop misreporting the cause (#1895).
8
+
9
+ GitHub only registers a PR's `closingIssuesReferences` and auto-closes the linked
10
+ issue when the PR targets the repository's **default branch**. PRs created against a
11
+ stacked / sub-issue branch (e.g. `issue-47-…` via `--base-branch`) therefore showed
12
+ an empty closing-reference connection and left their issues open after merge — the
13
+ exact failure reported for meta-language PRs #65/#66 / issues #49/#50.
14
+ - src/github-issue-auto-close.lib.mjs (new): `gitHubAutoClosesOnMerge`,
15
+ `classifyIssueLinkStatus`, `buildNonDefaultBranchExplanation`, and
16
+ `ensureLinkedIssueClosedAfterMerge` — diagnose why a closing reference is missing
17
+ and explicitly close the linked issue after a non-default-base merge (no-op when
18
+ GitHub already handles it, the keyword is absent, or the issue is already closed).
19
+ - src/solve.auto-pr.lib.mjs: replace the misleading "ISSUE LINK MISSING — add
20
+ Fixes #N" warning with an accurate "ISSUE LINK DEFERRED" explanation when the
21
+ keyword is present but the PR targets a non-default branch.
22
+ - src/solve.auto-continue.lib.mjs (`collectIssuePrCandidates`): detect the existing
23
+ PR for an issue by BOTH GitHub's `linked:issue` search (legacy, preserved) and the
24
+ deterministic `head:issue-N-` branch search. A PR targeting a non-default base
25
+ branch never appears in `linked:issue`, so `--auto-continue` previously failed to
26
+ resume it and risked creating a duplicate; the head-branch search guarantees the
27
+ PR↔issue association regardless of base branch.
28
+ - src/solve.auto-merge.lib.mjs (watchUntilMergeable + attemptAutoMerge),
29
+ src/github-merge.lib.mjs / src/github-merge-issue-close.lib.mjs
30
+ (`closeLinkedIssueIfNotAutoClosed`, used by the /merge queue), and
31
+ src/telegram-merge-queue.lib.mjs: close the linked issue explicitly after a merge
32
+ into a non-default branch. All gh calls route through the rate-limit-aware wrappers.
33
+ - tests/github-issue-auto-close.test.mjs: 14 cases reproducing the non-default-base
34
+ bug and verifying the diagnosis + fallback.
35
+ - tests/solve-auto-continue-detection-1895.test.mjs: 7 cases proving non-default-base
36
+ PRs are detected for auto-continue (head-branch search), legacy linked detection is
37
+ preserved, results are deduped/merged, and search failures degrade gracefully.
38
+ - docs/case-studies/issue-1895: deep case study with downloaded GraphQL/PR/issue
39
+ evidence, reconstructed timeline, root-cause analysis, requirement mapping, and the
40
+ external-reporting decision. Includes `github-api-linking-research.md` — a
41
+ definitive, introspection-backed answer to "is there an API to link a PR to an
42
+ issue?" (no: confirmed via live GraphQL schema introspection), with the gap
43
+ reported upstream (GitHub Community discussions #112224 / #155339 / #179613).
44
+
45
+ ## 1.78.7
46
+
47
+ ### Patch Changes
48
+
49
+ - c1617ae: Fix `/task` issue creation when replying to a message: combine the inline command (e.g. the repository URL) with the replied-to message (the issue text) instead of dropping the reply, so replying with `/task <repository-url>` now creates the GitHub issue (issue #1916).
50
+
3
51
  ## 1.78.6
4
52
 
5
53
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.78.6",
3
+ "version": "1.78.8",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -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 };
@@ -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,
@@ -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 linked to this issue
264
- const prListResult = await $`gh pr list --repo ${owner}/${repo} --search "linked:issue-${issueNumber}" --json number,createdAt,headRefName,isDraft,state --limit 10`;
265
-
266
- if (prListResult.code === 0) {
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) linked to issue #${issueNumber}`);
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 linked to this issue
539
- const prListResult = await $`gh pr list --repo ${owner}/${repo} --search "linked:issue-${issueNumber}" --json number,createdAt,headRefName,isDraft,state --limit 10`;
540
-
541
- if (prListResult.code === 0) {
542
- const prs = JSON.parse(prListResult.stdout.toString().trim() || '[]');
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) linked to issue #${issueNumber}`);
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
- // This is a problem - the link wasn't created
1175
- await log('');
1176
- await log(formatAligned('⚠️', 'ISSUE LINK MISSING:', 'PR not linked to issue'), {
1177
- level: 'warning',
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
- if (argv.fork) {
1182
- await log(" The PR was created from a fork but wasn't linked to the issue.", {
1183
- level: 'warning',
1184
- });
1185
- await log(` Expected: "Fixes ${owner}/${repo}#${issueNumber}" in PR body`, {
1186
- level: 'warning',
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
- await log(' To fix manually:', { level: 'warning' });
1190
- await log(` 1. Edit the PR description at: ${prUrl}`, { level: 'warning' });
1191
- await log(` 2. Add this line: Fixes ${owner}/${repo}#${issueNumber}`, { level: 'warning' });
1199
+ for (const line of buildNonDefaultBranchExplanation({ issueNumber, baseBranch: targetBranch, defaultBranch, issueRef })) {
1200
+ await log(` ${line}`);
1201
+ }
1192
1202
  } else {
1193
- await log(` The PR wasn't linked to issue #${issueNumber}`, { level: 'warning' });
1194
- await log(` Expected: "Fixes #${issueNumber}" in PR body`, { level: 'warning' });
1203
+ await log(formatAligned('⚠️', 'ISSUE LINK MISSING:', 'PR not linked to issue'), {
1204
+ level: 'warning',
1205
+ });
1195
1206
  await log('');
1196
- await log(' To fix manually:', { level: 'warning' });
1197
- await log(` 1. Edit the PR description at: ${prUrl}`, { level: 'warning' });
1198
- await log(` 2. Ensure it contains: Fixes #${issueNumber}`, { level: 'warning' });
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
  }
@@ -24,8 +24,13 @@ export function stripTaskCommandPrefix(text) {
24
24
 
25
25
  export function resolveTaskIssueCreationInput({ commandText = '', replyText = '' } = {}) {
26
26
  const inlineText = stripTaskCommandPrefix(commandText);
27
- if (inlineText) return inlineText;
28
- return normalizeNewlines(replyText).trim();
27
+ const reply = normalizeNewlines(replyText).trim();
28
+ // When replying to a message, the inline command and the replied-to message
29
+ // are complementary: one often carries the repository URL while the other
30
+ // carries the issue text (issue #1916). Combine both so neither part is lost.
31
+ // Inline text comes first so it takes precedence for title/body ordering.
32
+ if (inlineText && reply) return `${inlineText}\n${reply}`;
33
+ return inlineText || reply;
29
34
  }
30
35
 
31
36
  export function parseTaskRepository(value) {
@@ -72,6 +77,12 @@ function parseRepositoryLine(line) {
72
77
  function setRepository(currentRepository, nextRepository) {
73
78
  if (!nextRepository) return { repository: currentRepository };
74
79
  if (currentRepository) {
80
+ // The same repository may legitimately appear in both the inline command
81
+ // and the replied-to message once they are combined (issue #1916). Treat
82
+ // identical repositories as a no-op and only reject genuine conflicts.
83
+ if (currentRepository.fullName === nextRepository.fullName) {
84
+ return { repository: currentRepository };
85
+ }
75
86
  return {
76
87
  repository: currentRepository,
77
88
  error: 'Only one GitHub repository may be provided.',
@@ -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);
@@ -131,10 +131,12 @@ export function registerTaskCommands(bot, options) {
131
131
  const splitMode = commandName === 'split' || hasTaskSplitFlag(parsedArgs);
132
132
 
133
133
  if (!splitMode) {
134
+ const replyText = getReplyText(ctx.message);
134
135
  const creationInput = resolveTaskIssueCreationInput({
135
136
  commandText: ctx.message.text,
136
- replyText: getReplyText(ctx.message),
137
+ replyText,
137
138
  });
139
+ VERBOSE && console.log(`[VERBOSE] ${commandDisplay} issue creation: isReply=${Boolean(replyText)} replyChars=${replyText.length} resolvedChars=${creationInput.length}`);
138
140
  const creation = parseTaskIssueCreationInput(creationInput);
139
141
 
140
142
  if (!creation.valid) {