@link-assistant/hive-mind 2.0.12 → 2.0.13

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,45 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.0.13
4
+
5
+ ### Patch Changes
6
+
7
+ - ca7c412: fix(branch): validate `--base-branch` existence up-front and stop misreporting a missing base branch as an empty repository (#1959)
8
+
9
+ A `/solve` run from the Telegram bot crashed with the unactionable message
10
+ `Branch operation failed`. Root cause: the user passed `--base-branch issue-375-8a4323e580780`
11
+ — a **one-character typo** of the real branch `issue-375-8a4323e58078`. The branch did not
12
+ exist, but nothing validated that before the run started. The solver cloned 72 MB, then
13
+ `git checkout -b … origin/issue-375-8a4323e580780` failed with
14
+ `fatal: 'origin/…' is not a commit`. Worse, the branch-creation error handler **misdiagnosed**
15
+ this as "the repository appears to be empty (no commits)" and suggested `--auto-init-repository`,
16
+ which is wrong for a non-empty repo. The top level then collapsed everything to the bare
17
+ `Branch operation failed` comment on GitHub.
18
+
19
+ Defense-in-depth fix applied across the codebase:
20
+ - `validateGitHubEntityExistence()` (`src/github-entity-validation.lib.mjs`) gains a new
21
+ base-branch step: when `--base-branch` is supplied, `checkBaseBranchExists()` verifies it
22
+ via `gh api repos/{owner}/{repo}/branches/{branch}` **before** cloning, in the same fail-fast
23
+ gate that already checks user/repo/issue/PR. A definitive 404 fails the run; transient
24
+ errors fail open so a network hiccup never blocks a valid run.
25
+ - New helpers `levenshteinDistance()` and `findClosestBranchName()` power a "did you mean
26
+ '<closest-existing-branch>'?" suggestion built from the repo's actual branch list, so the
27
+ exact real-world typo points the user straight at `issue-375-8a4323e58078`.
28
+ - Both entry points share the gate: `src/solve.mjs` (CLI + the GitHub comment path) and
29
+ `src/telegram-bot.mjs` (the bot pre-flight) now pass `baseBranch` in, so a missing base
30
+ branch fails immediately at every level — including in Telegram, before the solve command
31
+ is queued or spawned.
32
+ - `handleBranchCreationError()` (`src/solve.branch-errors.lib.mjs`) now receives `baseBranch`
33
+ and `branchSource`. When a custom base branch is the missing ref it reports the real root
34
+ cause instead of the bogus empty-repository advice; the genuine empty-repository path
35
+ (creating from the default branch) is preserved. `createOrCheckoutBranch()`
36
+ (`src/solve.branch.lib.mjs`) threads the base branch and its source into the handler.
37
+
38
+ Adds `tests/test-base-branch-existence.mjs` (17 offline assertions covering the helpers and
39
+ the misdiagnosis fix), `tests/test-base-branch-existence-integration.mjs`
40
+ (`@hive-mind-integration`, the real `gh` gate), and a deep case study in
41
+ `docs/case-studies/issue-1959/`.
42
+
3
43
  ## 2.0.12
4
44
 
5
45
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.0.12",
3
+ "version": "2.0.13",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -10,8 +10,135 @@ import { ghCmdRetry } from './lib.mjs';
10
10
  import { ghPrView, ghIssueView } from './github.lib.mjs';
11
11
 
12
12
  /**
13
- * Validate existence of GitHub entities (user/org, repository, issue/PR) before executing a command.
14
- * Checks each level in order: user/org repository issue/PR, failing fast at the first missing entity.
13
+ * Compute the Levenshtein edit distance between two strings.
14
+ * Used to suggest the closest existing branch name when a requested base
15
+ * branch is not found (issue #1959 — typos like an extra trailing character).
16
+ *
17
+ * @param {string} a - First string
18
+ * @param {string} b - Second string
19
+ * @returns {number} The minimum number of single-character edits
20
+ */
21
+ export function levenshteinDistance(a, b) {
22
+ a = String(a);
23
+ b = String(b);
24
+ if (a === b) return 0;
25
+ if (a.length === 0) return b.length;
26
+ if (b.length === 0) return a.length;
27
+
28
+ // Single-row dynamic programming to keep memory at O(min(len)).
29
+ let previousRow = Array.from({ length: b.length + 1 }, (_, i) => i);
30
+ for (let i = 0; i < a.length; i++) {
31
+ const currentRow = [i + 1];
32
+ for (let j = 0; j < b.length; j++) {
33
+ const insertCost = currentRow[j] + 1;
34
+ const deleteCost = previousRow[j + 1] + 1;
35
+ const replaceCost = previousRow[j] + (a[i] === b[j] ? 0 : 1);
36
+ currentRow.push(Math.min(insertCost, deleteCost, replaceCost));
37
+ }
38
+ previousRow = currentRow;
39
+ }
40
+ return previousRow[b.length];
41
+ }
42
+
43
+ /**
44
+ * Find the closest matching branch name to a (likely mistyped) target.
45
+ * Returns the nearest candidate only when it is close enough to be a plausible
46
+ * typo, so we never suggest an unrelated branch.
47
+ *
48
+ * @param {string} target - The requested (not found) branch name
49
+ * @param {string[]} candidates - Existing branch names to compare against
50
+ * @returns {string|null} The closest branch name, or null if none is close enough
51
+ */
52
+ export function findClosestBranchName(target, candidates) {
53
+ if (!target || !Array.isArray(candidates) || candidates.length === 0) {
54
+ return null;
55
+ }
56
+ // Allow a larger edit budget for longer names, but cap it so unrelated
57
+ // branches are never suggested (e.g. a 1-char typo on a 23-char branch).
58
+ const maxDistance = Math.max(2, Math.floor(target.length * 0.34));
59
+ let best = null;
60
+ let bestDistance = Infinity;
61
+ for (const candidate of candidates) {
62
+ if (!candidate || candidate === target) continue;
63
+ const distance = levenshteinDistance(target, candidate);
64
+ if (distance < bestDistance) {
65
+ bestDistance = distance;
66
+ best = candidate;
67
+ }
68
+ }
69
+ return bestDistance <= maxDistance ? best : null;
70
+ }
71
+
72
+ /**
73
+ * Check whether a branch exists in the given repository using the GitHub API.
74
+ * Works without cloning the repo, so it can run during early validation
75
+ * (before the heavy clone/spawn work) for both the CLI and the Telegram bot.
76
+ *
77
+ * @param {Object} options
78
+ * @param {string} options.owner - Repository owner
79
+ * @param {string} options.repo - Repository name
80
+ * @param {string} options.baseBranch - Branch name to check
81
+ * @param {boolean} [options.verbose=false] - Verbose logging
82
+ * @returns {Promise<{exists: boolean, indeterminate?: boolean}>}
83
+ * - exists: true if the branch is present (or the check was inconclusive — fail open)
84
+ * - indeterminate: true when a non-404 error prevented a definitive answer
85
+ */
86
+ export async function checkBaseBranchExists({ owner, repo, baseBranch, verbose = false }) {
87
+ try {
88
+ const result = await ghCmdRetry(() => $`gh api repos/${owner}/${repo}/branches/${baseBranch} --jq .name`, { label: `check branch ${baseBranch}` });
89
+ if (result.code === 0) {
90
+ return { exists: true };
91
+ }
92
+ const errorOutput = (result.stderr ? result.stderr.toString() : '') + (result.stdout ? result.stdout.toString() : '');
93
+ if (errorOutput.includes('404') || errorOutput.includes('Not Found') || errorOutput.includes('Branch not found')) {
94
+ return { exists: false };
95
+ }
96
+ // Non-404 errors (network, auth, rate limit): fail open so we don't block on transient issues.
97
+ verbose && console.log(`[VERBOSE] Entity check: Could not verify branch '${baseBranch}': ${errorOutput.trim()}`);
98
+ return { exists: true, indeterminate: true };
99
+ } catch (e) {
100
+ verbose && console.log(`[VERBOSE] Entity check: Branch check error for '${baseBranch}': ${e.message}`);
101
+ return { exists: true, indeterminate: true };
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Build a user-facing error message for a missing base branch, optionally
107
+ * including a "did you mean" suggestion derived from the repo's branches.
108
+ *
109
+ * @param {Object} options
110
+ * @param {string} options.owner
111
+ * @param {string} options.repo
112
+ * @param {string} options.baseBranch
113
+ * @param {boolean} [options.verbose=false]
114
+ * @returns {Promise<string>} The formatted error message
115
+ */
116
+ export async function buildMissingBaseBranchErrorMessage({ owner, repo, baseBranch, verbose = false }) {
117
+ let suggestion = '';
118
+ try {
119
+ const listResult = await ghCmdRetry(() => $`gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`, { label: `list branches ${owner}/${repo}` });
120
+ if (listResult.code === 0) {
121
+ const branches = listResult.stdout
122
+ .toString()
123
+ .split('\n')
124
+ .map(s => s.trim())
125
+ .filter(Boolean);
126
+ const closest = findClosestBranchName(baseBranch, branches);
127
+ if (closest) {
128
+ suggestion = `\n\n💡 Did you mean '${closest}'? (closest existing branch)`;
129
+ }
130
+ }
131
+ } catch (e) {
132
+ verbose && console.log(`[VERBOSE] Entity check: Could not list branches for suggestion: ${e.message}`);
133
+ }
134
+ const listCmd = `gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`;
135
+ return `Base branch '${baseBranch}' does not exist in ${owner}/${repo}.${suggestion}\n\n💡 Please check:\n• The branch name is spelled correctly\n• The branch has not been deleted or renamed\n• Omit --base-branch to use the repository's default branch\n• List existing branches: ${listCmd}`;
136
+ }
137
+
138
+ /**
139
+ * Validate existence of GitHub entities (user/org, repository, base branch, issue/PR)
140
+ * before executing a command. Checks each level in order, failing fast at the first
141
+ * missing entity: user/org → repository → base branch → issue/PR.
15
142
  *
16
143
  * When autoAcceptInvite is enabled, invitations should be accepted BEFORE calling this function,
17
144
  * so that newly-accepted repos/orgs are visible to the API checks.
@@ -21,6 +148,9 @@ import { ghPrView, ghIssueView } from './github.lib.mjs';
21
148
  * @param {string} options.repo - Repository name
22
149
  * @param {number|string} [options.number] - Issue or PR number (if applicable)
23
150
  * @param {string} [options.type] - URL type: 'issue' or 'pull'
151
+ * @param {string} [options.baseBranch] - Custom base branch (from --base-branch/--target-branch).
152
+ * When provided, its existence is verified up-front so a typo fails fast with a clear
153
+ * message instead of an opaque "Branch operation failed" after cloning (issue #1959).
24
154
  * @param {boolean} [options.verbose=false] - Whether verbose logging is enabled
25
155
  * @param {boolean} [options.autoAcceptInvite=false] - Whether the caller already passed
26
156
  * `--auto-accept-invite`. When true, the repo-404 message omits the suggestion to
@@ -28,10 +158,10 @@ import { ghPrView, ghIssueView } from './github.lib.mjs';
28
158
  * @returns {Promise<{valid: boolean, error?: string, level?: string, details?: string}>}
29
159
  * - valid: true if all entities exist and are accessible
30
160
  * - error: user-facing error message (when valid=false)
31
- * - level: which entity level failed ('user', 'repo', 'issue', 'pull')
161
+ * - level: which entity level failed ('user', 'repo', 'branch', 'issue', 'pull')
32
162
  * - details: additional context for verbose logging
33
163
  */
34
- export async function validateGitHubEntityExistence({ owner, repo, number, type, verbose = false, autoAcceptInvite = false }) {
164
+ export async function validateGitHubEntityExistence({ owner, repo, number, type, baseBranch, verbose = false, autoAcceptInvite = false }) {
35
165
  // Step 1: Check user/organization existence
36
166
  try {
37
167
  const userResult = await ghCmdRetry(() => $`gh api users/${owner} --jq .login`, { label: `check user ${owner}` });
@@ -73,6 +203,24 @@ export async function validateGitHubEntityExistence({ owner, repo, number, type,
73
203
  verbose && console.log(`[VERBOSE] Entity check: Repo check error for '${owner}/${repo}': ${e.message}`);
74
204
  }
75
205
 
206
+ // Step 2.5: Check base branch existence (issue #1959)
207
+ // When the user passes --base-branch/--target-branch, verify it exists in the
208
+ // canonical repository up-front. A typo (e.g. an extra trailing character) would
209
+ // otherwise surface only after cloning as an opaque "Branch operation failed",
210
+ // and was even misdiagnosed as an "empty repository". Failing here gives the
211
+ // same fast, explicit feedback we already provide for repo/issue/PR — in the CLI
212
+ // and in the GitHub comment, and BEFORE the Telegram bot starts the solve run.
213
+ if (baseBranch) {
214
+ const branchCheck = await checkBaseBranchExists({ owner, repo, baseBranch, verbose });
215
+ if (!branchCheck.exists) {
216
+ return {
217
+ valid: false,
218
+ error: await buildMissingBaseBranchErrorMessage({ owner, repo, baseBranch, verbose }),
219
+ level: 'branch',
220
+ };
221
+ }
222
+ }
223
+
76
224
  // Step 3: Check issue or PR existence (if number is provided)
77
225
  if (number) {
78
226
  if (type === 'pull') {
@@ -219,7 +219,7 @@ export async function handleBranchCheckoutError({ branchName, prNumber, errorOut
219
219
  }
220
220
  }
221
221
 
222
- export async function handleBranchCreationError({ branchName, errorOutput, tempDir, owner, repo, formatAligned, log }) {
222
+ export async function handleBranchCreationError({ branchName, errorOutput, tempDir, owner, repo, baseBranch, branchSource, formatAligned, log }) {
223
223
  await log(`${formatAligned('❌', 'BRANCH CREATION FAILED', '')}`, { level: 'error' });
224
224
  await log('');
225
225
  await log(' 🔍 What happened:');
@@ -234,6 +234,31 @@ export async function handleBranchCreationError({ branchName, errorOutput, tempD
234
234
  }
235
235
  await log('');
236
236
 
237
+ // Issue #1959: A failure to create the branch from origin/<baseBranch> looks
238
+ // identical at the git level to an empty repository ("is not a commit"). Only
239
+ // treat it as "empty repo" when the failing ref is NOT a custom base branch.
240
+ // When a custom base branch is involved and the error references it, report the
241
+ // real root cause: the requested base branch does not exist on the remote.
242
+ const errorMentionsBaseRef = baseBranch && (errorOutput.includes(`origin/${baseBranch}`) || errorOutput.includes(`'${baseBranch}'`));
243
+ const looksLikeMissingRef = errorOutput.includes('is not a commit') || errorOutput.includes('not a valid object name') || errorOutput.includes('unknown revision') || errorOutput.includes('did not match any');
244
+ const isMissingBaseBranchError = branchSource === 'custom' && errorMentionsBaseRef && looksLikeMissingRef;
245
+
246
+ if (isMissingBaseBranchError) {
247
+ await log(' 💡 Root cause:');
248
+ await log(` The requested base branch '${baseBranch}' does not exist on the remote.`);
249
+ await log(' A branch cannot be created from a base branch that is not there.');
250
+ await log('');
251
+ await log(' 🔧 How to fix:');
252
+ await log(' • Check the branch name for typos (a single extra/missing character is enough)');
253
+ await log(' • Omit --base-branch to use the repository default branch');
254
+ if (owner && repo) {
255
+ await log(` • List existing branches: gh api repos/${owner}/${repo}/branches --paginate --jq .[].name`);
256
+ } else {
257
+ await log(' • List existing branches: git branch -r');
258
+ }
259
+ return;
260
+ }
261
+
237
262
  // Check if this is an empty repository error (no commits to branch from)
238
263
  const isEmptyRepoError = errorOutput.includes('is not a commit') || errorOutput.includes('not a valid object name') || errorOutput.includes('unknown revision');
239
264
  if (isEmptyRepoError) {
@@ -308,6 +308,10 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
308
308
  // Create a branch for the issue or checkout existing PR branch
309
309
  let branchName;
310
310
  let checkoutResult;
311
+ // Tracked across the create path so the error handler can report the real
312
+ // root cause when branch creation fails from a missing base branch (issue #1959).
313
+ let baseBranchForError = null;
314
+ let branchSourceForError = null;
311
315
 
312
316
  if (isContinueMode && prBranch) {
313
317
  // Continue mode: checkout existing PR branch
@@ -324,6 +328,8 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
324
328
  // Use user-specified base branch if provided, otherwise use repository default
325
329
  const baseBranch = argv.baseBranch || defaultBranch;
326
330
  const branchSource = argv.baseBranch ? 'custom' : 'default';
331
+ baseBranchForError = baseBranch;
332
+ branchSourceForError = branchSource;
327
333
 
328
334
  // Defense-in-depth: validate base branch name even if already validated at CLI parsing (issue #1482)
329
335
  const baseBranchValidation = validateBranchName(baseBranch);
@@ -391,6 +397,8 @@ export async function createOrCheckoutBranch({ isContinueMode, prBranch, issueNu
391
397
  tempDir,
392
398
  owner,
393
399
  repo,
400
+ baseBranch: baseBranchForError,
401
+ branchSource: branchSourceForError,
394
402
  formatAligned,
395
403
  log,
396
404
  });
package/src/solve.mjs CHANGED
@@ -289,7 +289,7 @@ if (!hasWriteAccess) {
289
289
  }
290
290
 
291
291
  // Issue #1552: Validate entity existence AFTER permissions (cascade: user/org → repo → issue/PR)
292
- const entityCheck = await (await import('./github-entity-validation.lib.mjs')).validateGitHubEntityExistence({ owner, repo, number: urlNumber, type: isIssueUrl ? 'issue' : isPrUrl ? 'pull' : undefined, verbose: argv.verbose, autoAcceptInvite: !!argv.autoAcceptInvite });
292
+ const entityCheck = await (await import('./github-entity-validation.lib.mjs')).validateGitHubEntityExistence({ owner, repo, number: urlNumber, type: isIssueUrl ? 'issue' : isPrUrl ? 'pull' : undefined, baseBranch: argv.baseBranch, verbose: argv.verbose, autoAcceptInvite: !!argv.autoAcceptInvite });
293
293
  if (!entityCheck.valid) {
294
294
  await log(`\n❌ ${entityCheck.error}\n`, { level: 'error' });
295
295
  await safeExit(1, `GitHub entity not found (${entityCheck.level})`);
@@ -873,7 +873,7 @@ async function handleSolveCommand(ctx) {
873
873
  }
874
874
  // Issue #1714: read the parsed argv (default-on per #1694) instead of the raw args list,
875
875
  // so the invite hint is suppressed on the default-on path where the literal flag is absent.
876
- const entityCheck = await validateGitHubEntityExistence({ owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, verbose: VERBOSE, autoAcceptInvite: !!parsedSolveArgs?.autoAcceptInvite });
876
+ const entityCheck = await validateGitHubEntityExistence({ owner: validation.parsed.owner, repo: validation.parsed.repo, number: validation.parsed.number, type: validation.parsed.type, baseBranch: parsedSolveArgs?.baseBranch, verbose: VERBOSE, autoAcceptInvite: !!parsedSolveArgs?.autoAcceptInvite });
877
877
  if (!entityCheck.valid) {
878
878
  await safeReply(ctx, `❌ ${escapeMarkdown(entityCheck.error)}`, { reply_to_message_id: ctx.message.message_id });
879
879
  return;