@link-assistant/hive-mind 2.0.12 → 2.0.14
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 +46 -0
- package/package.json +1 -1
- package/src/cli-arguments.lib.mjs +20 -11
- package/src/github-entity-validation.lib.mjs +152 -4
- package/src/option-suggestions.lib.mjs +87 -45
- package/src/solve.branch-errors.lib.mjs +26 -1
- package/src/solve.branch.lib.mjs +8 -0
- package/src/solve.mjs +1 -1
- package/src/telegram-bot.mjs +6 -3
- package/src/telegram-solve-command.lib.mjs +6 -1
- package/src/telegram-task-command.lib.mjs +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.0.14
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 03954a9: Show fuzzy suggestions for mistyped CLI and Telegram command options, including the closest match plus up to four additional alternatives.
|
|
8
|
+
|
|
9
|
+
## 2.0.13
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- ca7c412: fix(branch): validate `--base-branch` existence up-front and stop misreporting a missing base branch as an empty repository (#1959)
|
|
14
|
+
|
|
15
|
+
A `/solve` run from the Telegram bot crashed with the unactionable message
|
|
16
|
+
`Branch operation failed`. Root cause: the user passed `--base-branch issue-375-8a4323e580780`
|
|
17
|
+
— a **one-character typo** of the real branch `issue-375-8a4323e58078`. The branch did not
|
|
18
|
+
exist, but nothing validated that before the run started. The solver cloned 72 MB, then
|
|
19
|
+
`git checkout -b … origin/issue-375-8a4323e580780` failed with
|
|
20
|
+
`fatal: 'origin/…' is not a commit`. Worse, the branch-creation error handler **misdiagnosed**
|
|
21
|
+
this as "the repository appears to be empty (no commits)" and suggested `--auto-init-repository`,
|
|
22
|
+
which is wrong for a non-empty repo. The top level then collapsed everything to the bare
|
|
23
|
+
`Branch operation failed` comment on GitHub.
|
|
24
|
+
|
|
25
|
+
Defense-in-depth fix applied across the codebase:
|
|
26
|
+
- `validateGitHubEntityExistence()` (`src/github-entity-validation.lib.mjs`) gains a new
|
|
27
|
+
base-branch step: when `--base-branch` is supplied, `checkBaseBranchExists()` verifies it
|
|
28
|
+
via `gh api repos/{owner}/{repo}/branches/{branch}` **before** cloning, in the same fail-fast
|
|
29
|
+
gate that already checks user/repo/issue/PR. A definitive 404 fails the run; transient
|
|
30
|
+
errors fail open so a network hiccup never blocks a valid run.
|
|
31
|
+
- New helpers `levenshteinDistance()` and `findClosestBranchName()` power a "did you mean
|
|
32
|
+
'<closest-existing-branch>'?" suggestion built from the repo's actual branch list, so the
|
|
33
|
+
exact real-world typo points the user straight at `issue-375-8a4323e58078`.
|
|
34
|
+
- Both entry points share the gate: `src/solve.mjs` (CLI + the GitHub comment path) and
|
|
35
|
+
`src/telegram-bot.mjs` (the bot pre-flight) now pass `baseBranch` in, so a missing base
|
|
36
|
+
branch fails immediately at every level — including in Telegram, before the solve command
|
|
37
|
+
is queued or spawned.
|
|
38
|
+
- `handleBranchCreationError()` (`src/solve.branch-errors.lib.mjs`) now receives `baseBranch`
|
|
39
|
+
and `branchSource`. When a custom base branch is the missing ref it reports the real root
|
|
40
|
+
cause instead of the bogus empty-repository advice; the genuine empty-repository path
|
|
41
|
+
(creating from the default branch) is preserved. `createOrCheckoutBranch()`
|
|
42
|
+
(`src/solve.branch.lib.mjs`) threads the base branch and its source into the handler.
|
|
43
|
+
|
|
44
|
+
Adds `tests/test-base-branch-existence.mjs` (17 offline assertions covering the helpers and
|
|
45
|
+
the misdiagnosis fix), `tests/test-base-branch-existence-integration.mjs`
|
|
46
|
+
(`@hive-mind-integration`, the real `gh` gate), and a deep case study in
|
|
47
|
+
`docs/case-studies/issue-1959/`.
|
|
48
|
+
|
|
3
49
|
## 2.0.12
|
|
4
50
|
|
|
5
51
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { getenv, makeConfig, yargs as linoYargs } from 'lino-arguments';
|
|
2
2
|
|
|
3
|
+
import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
|
|
4
|
+
|
|
3
5
|
export { getenv };
|
|
4
6
|
|
|
5
7
|
export const hideBin = argv => argv.slice(2);
|
|
@@ -52,17 +54,24 @@ export function addCliCompatibilityAliases(parsed, { positionalAliases = [] } =
|
|
|
52
54
|
|
|
53
55
|
export function parseCliArgumentsWithLino({ argv = process.argv, commandName = 'cli', createYargsConfig, positionalAliases = [], lenv = { enabled: true }, env = { enabled: false }, getenv: getenvOptions = { enabled: true } } = {}) {
|
|
54
56
|
const fullArgv = ensureFullArgv(argv, commandName);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
let configuredParser = null;
|
|
58
|
+
let parsed;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
parsed = makeConfig({
|
|
62
|
+
argv: fullArgv,
|
|
63
|
+
lenv,
|
|
64
|
+
env,
|
|
65
|
+
getenv: getenvOptions,
|
|
66
|
+
yargs: ({ yargs, getenv: getenvHelper }) => {
|
|
67
|
+
const parser = yargs.exitProcess(false);
|
|
68
|
+
configuredParser = createYargsConfig ? createYargsConfig(parser, getenvHelper) : parser;
|
|
69
|
+
return configuredParser.exitProcess(false);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw enhanceUnknownArgumentError(error, configuredParser);
|
|
74
|
+
}
|
|
66
75
|
|
|
67
76
|
return addCliCompatibilityAliases(parsed, { positionalAliases });
|
|
68
77
|
}
|
|
@@ -10,8 +10,135 @@ import { ghCmdRetry } from './lib.mjs';
|
|
|
10
10
|
import { ghPrView, ghIssueView } from './github.lib.mjs';
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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') {
|
|
@@ -44,79 +44,97 @@ export function calculateLevenshteinDistance(a, b) {
|
|
|
44
44
|
return matrix[b.length][a.length];
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
* @returns {string[]} - Array of suggested option names, sorted by similarity
|
|
55
|
-
*/
|
|
56
|
-
export function findSimilarOptions(unknownOption, yargsInstance, maxSuggestions = 3, distanceThreshold = 5) {
|
|
57
|
-
// Remove leading dashes from the unknown option
|
|
58
|
-
const cleanUnknown = unknownOption.replace(/^-+/, '');
|
|
47
|
+
const normalizeOptionName = option =>
|
|
48
|
+
String(option || '')
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^-+/, '')
|
|
51
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
52
|
+
.replace(/[_\s]+/g, '-')
|
|
53
|
+
.toLowerCase();
|
|
59
54
|
|
|
60
|
-
|
|
55
|
+
const compactOptionName = option => normalizeOptionName(option).replace(/-/g, '');
|
|
56
|
+
|
|
57
|
+
function getAvailableOptionNames(yargsInstance, includeShortAliases) {
|
|
61
58
|
const availableOptions = yargsInstance.getOptions();
|
|
62
59
|
const allOptions = new Set();
|
|
60
|
+
const rawHiddenOptions = availableOptions.hiddenOptions || [];
|
|
61
|
+
const hiddenOptionNames = Array.isArray(rawHiddenOptions) ? rawHiddenOptions : Object.keys(rawHiddenOptions);
|
|
62
|
+
const hiddenOptions = new Set(hiddenOptionNames.map(normalizeOptionName));
|
|
63
|
+
|
|
64
|
+
const addOption = option => {
|
|
65
|
+
const normalized = normalizeOptionName(option);
|
|
66
|
+
if (!normalized || hiddenOptions.has(normalized)) return;
|
|
67
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(normalized)) return;
|
|
68
|
+
if (!includeShortAliases && normalized.length === 1) return;
|
|
69
|
+
allOptions.add(normalized);
|
|
70
|
+
};
|
|
63
71
|
|
|
64
|
-
// Collect all option names (both long form and aliases)
|
|
65
72
|
if (availableOptions.key) {
|
|
66
|
-
// Ensure it's an array before iterating
|
|
67
73
|
const keys = Array.isArray(availableOptions.key) ? availableOptions.key : Object.keys(availableOptions.key || {});
|
|
68
|
-
keys.forEach(
|
|
69
|
-
allOptions.add(opt);
|
|
70
|
-
});
|
|
74
|
+
keys.forEach(addOption);
|
|
71
75
|
}
|
|
72
76
|
|
|
73
|
-
// Collect aliases
|
|
74
77
|
if (availableOptions.alias) {
|
|
75
78
|
Object.entries(availableOptions.alias).forEach(([key, aliases]) => {
|
|
76
|
-
|
|
79
|
+
addOption(key);
|
|
77
80
|
if (Array.isArray(aliases)) {
|
|
78
|
-
aliases.forEach(
|
|
81
|
+
aliases.forEach(addOption);
|
|
79
82
|
} else if (aliases) {
|
|
80
|
-
|
|
81
|
-
allOptions.add(String(aliases));
|
|
83
|
+
addOption(aliases);
|
|
82
84
|
}
|
|
83
85
|
});
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
|
|
88
|
+
return allOptions;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find similar option names based on Levenshtein distance
|
|
93
|
+
*
|
|
94
|
+
* @param {string} unknownOption - The option name that was not recognized (e.g., "branch")
|
|
95
|
+
* @param {Object} yargsInstance - The yargs instance with defined options
|
|
96
|
+
* @param {number} maxSuggestions - Maximum number of suggestions to return (default: 5)
|
|
97
|
+
* @param {number} distanceThreshold - Maximum distance to consider for suggestions (default: 5)
|
|
98
|
+
* @returns {string[]} - Array of suggested option names, sorted by similarity
|
|
99
|
+
*/
|
|
100
|
+
export function findSimilarOptions(unknownOption, yargsInstance, maxSuggestions = 5, distanceThreshold = 5) {
|
|
101
|
+
const cleanUnknown = normalizeOptionName(unknownOption);
|
|
102
|
+
const compactUnknown = compactOptionName(unknownOption);
|
|
103
|
+
const includeShortAliases = cleanUnknown.length === 1;
|
|
104
|
+
const allOptions = getAvailableOptionNames(yargsInstance, includeShortAliases);
|
|
105
|
+
|
|
87
106
|
const distances = [];
|
|
88
107
|
allOptions.forEach(option => {
|
|
89
|
-
const distance = calculateLevenshteinDistance(cleanUnknown, option);
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
// If the unknown option is a substring of the valid option, it's likely what the user meant
|
|
93
|
-
// Check both directions: is unknown a substring of option, or is option a substring of unknown
|
|
94
|
-
const unknownInOption = option.includes(cleanUnknown);
|
|
95
|
-
const optionInUnknown = cleanUnknown.includes(option);
|
|
96
|
-
|
|
97
|
-
// Strong bonus for when user typed a word that appears in the option name
|
|
98
|
-
// e.g., "branch" appears in "base-branch"
|
|
99
|
-
const substringBonus = unknownInOption ? -10 : optionInUnknown ? -5 : 0;
|
|
108
|
+
const distance = Math.min(calculateLevenshteinDistance(cleanUnknown, option), calculateLevenshteinDistance(compactUnknown, compactOptionName(option)));
|
|
109
|
+
const unknownInOption = option.includes(cleanUnknown) || compactOptionName(option).includes(compactUnknown);
|
|
110
|
+
const optionInUnknown = cleanUnknown.includes(option) || compactUnknown.includes(compactOptionName(option));
|
|
100
111
|
|
|
101
|
-
|
|
112
|
+
if (distance <= distanceThreshold || unknownInOption || optionInUnknown) {
|
|
113
|
+
const substringBonus = unknownInOption ? -10 : optionInUnknown ? -5 : 0;
|
|
102
114
|
const lengthDiff = Math.abs(option.length - cleanUnknown.length);
|
|
103
115
|
const lengthBonus = lengthDiff < 3 ? -1 : 0;
|
|
104
116
|
|
|
105
117
|
distances.push({
|
|
106
118
|
option,
|
|
107
119
|
distance,
|
|
120
|
+
lengthDiff,
|
|
108
121
|
effectiveDistance: distance + substringBonus + lengthBonus,
|
|
109
122
|
});
|
|
110
123
|
}
|
|
111
124
|
});
|
|
112
125
|
|
|
113
|
-
// Sort by effective distance (closest first), then by actual distance
|
|
114
126
|
return distances
|
|
115
127
|
.sort((a, b) => {
|
|
116
128
|
if (a.effectiveDistance !== b.effectiveDistance) {
|
|
117
129
|
return a.effectiveDistance - b.effectiveDistance;
|
|
118
130
|
}
|
|
119
|
-
|
|
131
|
+
if (a.distance !== b.distance) {
|
|
132
|
+
return a.distance - b.distance;
|
|
133
|
+
}
|
|
134
|
+
if (a.lengthDiff !== b.lengthDiff) {
|
|
135
|
+
return a.lengthDiff - b.lengthDiff;
|
|
136
|
+
}
|
|
137
|
+
return a.option.localeCompare(b.option);
|
|
120
138
|
})
|
|
121
139
|
.slice(0, maxSuggestions)
|
|
122
140
|
.map(item => item.option);
|
|
@@ -133,17 +151,17 @@ export function formatSuggestions(suggestions) {
|
|
|
133
151
|
return '';
|
|
134
152
|
}
|
|
135
153
|
|
|
136
|
-
if (suggestions.length === 1) {
|
|
137
|
-
return `\n\nDid you mean --${suggestions[0]}?`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// For multiple suggestions, format them nicely
|
|
141
154
|
const formattedOptions = suggestions.map(opt => {
|
|
142
155
|
// If it's a single character, show as -x, otherwise --option-name
|
|
143
156
|
return opt.length === 1 ? `-${opt}` : `--${opt}`;
|
|
144
157
|
});
|
|
158
|
+
const [primarySuggestion, ...alternatives] = formattedOptions;
|
|
145
159
|
|
|
146
|
-
|
|
160
|
+
if (alternatives.length === 0) {
|
|
161
|
+
return `\n\nDid you mean \`${primarySuggestion}\` option?`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return `\n\nDid you mean \`${primarySuggestion}\` option?\n\nOther close matches:\n${alternatives.map(opt => ` • \`${opt}\``).join('\n')}`;
|
|
147
165
|
}
|
|
148
166
|
|
|
149
167
|
/**
|
|
@@ -300,9 +318,13 @@ export function detectMalformedFlags(args) {
|
|
|
300
318
|
* @returns {string} - Enhanced error message with suggestions
|
|
301
319
|
*/
|
|
302
320
|
export function enhanceErrorMessage(originalError, yargsInstance) {
|
|
321
|
+
if (/Did you mean/i.test(originalError)) {
|
|
322
|
+
return originalError;
|
|
323
|
+
}
|
|
324
|
+
|
|
303
325
|
// Extract the unknown option name from the error message
|
|
304
326
|
// Typical format: "Unknown argument: branch" or "Unknown arguments: branch, test"
|
|
305
|
-
const unknownMatch = originalError.match(/Unknown arguments?:\s*(
|
|
327
|
+
const unknownMatch = originalError.match(/Unknown arguments?:\s*([^\n]+)/i);
|
|
306
328
|
|
|
307
329
|
if (!unknownMatch) {
|
|
308
330
|
return originalError;
|
|
@@ -324,3 +346,23 @@ export function enhanceErrorMessage(originalError, yargsInstance) {
|
|
|
324
346
|
|
|
325
347
|
return enhancedMessage;
|
|
326
348
|
}
|
|
349
|
+
|
|
350
|
+
export function enhanceUnknownArgumentError(error, yargsInstance) {
|
|
351
|
+
if (!error || error._enhanced || !yargsInstance || !/Unknown arguments?/i.test(error.message || '')) {
|
|
352
|
+
return error;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const enhancedMessage = enhanceErrorMessage(error.message, yargsInstance);
|
|
356
|
+
if (enhancedMessage === error.message) {
|
|
357
|
+
return error;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const enhancedError = new Error(enhancedMessage);
|
|
361
|
+
enhancedError.name = error.name;
|
|
362
|
+
enhancedError.cause = error;
|
|
363
|
+
for (const key of Object.keys(error)) {
|
|
364
|
+
enhancedError[key] = error[key];
|
|
365
|
+
}
|
|
366
|
+
enhancedError._enhanced = true;
|
|
367
|
+
return enhancedError;
|
|
368
|
+
}
|
|
@@ -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) {
|
package/src/solve.branch.lib.mjs
CHANGED
|
@@ -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})`);
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -25,6 +25,7 @@ await loadLenvConfig({ override: true, quiet: true });
|
|
|
25
25
|
const yargs = getLinoYargsFactory();
|
|
26
26
|
const { createYargsConfig: createSolveYargsConfig, detectMalformedFlags } = await import('./solve.config.lib.mjs');
|
|
27
27
|
const { createYargsConfig: createHiveYargsConfig } = await import('./hive.config.lib.mjs');
|
|
28
|
+
const { enhanceUnknownArgumentError } = await import('./option-suggestions.lib.mjs');
|
|
28
29
|
const { validateBranchInArgs } = await import('./solve.branch.lib.mjs');
|
|
29
30
|
const { extractIsolationFromArgs, isValidPerCommandIsolation } = await import('./telegram-isolation.lib.mjs');
|
|
30
31
|
|
|
@@ -229,7 +230,8 @@ if (solveEnabled && solveOverrides.length > 0) {
|
|
|
229
230
|
process.stderr.write = originalStderrWrite;
|
|
230
231
|
}
|
|
231
232
|
} catch (error) {
|
|
232
|
-
|
|
233
|
+
const enhancedError = enhanceUnknownArgumentError(error, createSolveYargsConfig(yargs()));
|
|
234
|
+
console.error(`❌ Invalid solve-overrides: ${enhancedError.message || String(enhancedError)}`);
|
|
233
235
|
console.error(` Overrides: ${solveOverrides.join(' ')}`);
|
|
234
236
|
process.exit(1);
|
|
235
237
|
}
|
|
@@ -275,7 +277,8 @@ if (hiveEnabled && hiveOverrides.length > 0) {
|
|
|
275
277
|
process.stderr.write = originalStderrWrite;
|
|
276
278
|
}
|
|
277
279
|
} catch (error) {
|
|
278
|
-
|
|
280
|
+
const enhancedError = enhanceUnknownArgumentError(error, createHiveYargsConfig(yargs()));
|
|
281
|
+
console.error(`❌ Invalid hive-overrides: ${enhancedError.message || String(enhancedError)}`);
|
|
279
282
|
console.error(` Overrides: ${hiveOverrides.join(' ')}`);
|
|
280
283
|
process.exit(1);
|
|
281
284
|
}
|
|
@@ -873,7 +876,7 @@ async function handleSolveCommand(ctx) {
|
|
|
873
876
|
}
|
|
874
877
|
// Issue #1714: read the parsed argv (default-on per #1694) instead of the raw args list,
|
|
875
878
|
// 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 });
|
|
879
|
+
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
880
|
if (!entityCheck.valid) {
|
|
878
881
|
await safeReply(ctx, `❌ ${escapeMarkdown(entityCheck.error)}`, { reply_to_message_id: ctx.message.message_id });
|
|
879
882
|
return;
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* @see https://github.com/link-assistant/hive-mind/issues/1618
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { enhanceUnknownArgumentError } from './option-suggestions.lib.mjs';
|
|
12
|
+
|
|
11
13
|
export const TOOL_SOLVE_COMMAND_ALIASES = Object.freeze({
|
|
12
14
|
claude: 'claude',
|
|
13
15
|
codex: 'codex',
|
|
@@ -89,8 +91,9 @@ export async function parseArgsWithYargs(args, yargsFactory, createYargsConfig)
|
|
|
89
91
|
else if (typeof callback === 'function') callback();
|
|
90
92
|
return true;
|
|
91
93
|
};
|
|
94
|
+
let parser = null;
|
|
92
95
|
try {
|
|
93
|
-
|
|
96
|
+
parser = createYargsConfig(yargsFactory());
|
|
94
97
|
parser
|
|
95
98
|
.exitProcess(false)
|
|
96
99
|
.showHelpOnFail(false)
|
|
@@ -98,6 +101,8 @@ export async function parseArgsWithYargs(args, yargsFactory, createYargsConfig)
|
|
|
98
101
|
throw err || new Error(msg || 'Invalid arguments');
|
|
99
102
|
});
|
|
100
103
|
return await parser.parse(args);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw enhanceUnknownArgumentError(error, parser);
|
|
101
106
|
} finally {
|
|
102
107
|
process.stderr.write = originalStderrWrite;
|
|
103
108
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { buildUserMention } from './buildUserMention.lib.mjs';
|
|
2
2
|
import { validateModelName } from './models/index.mjs';
|
|
3
|
+
import { getLinoYargsFactory } from './cli-arguments.lib.mjs';
|
|
4
|
+
import { createYargsConfig as createTaskYargsConfig } from './task.config.lib.mjs';
|
|
3
5
|
import { createTaskIssue, parseTaskIssueCreationInput, resolveTaskIssueCreationInput } from './task.issue-creation.lib.mjs';
|
|
4
6
|
import { parseTaskIssueUrl } from './task.split.lib.mjs';
|
|
5
7
|
import { escapeMarkdown } from './telegram-markdown.lib.mjs';
|
|
6
8
|
import { extractIsolationFromArgs, isValidPerCommandIsolation } from './telegram-isolation.lib.mjs';
|
|
7
|
-
import { moveArgumentToFront, parseCommandArgs } from './telegram-solve-command.lib.mjs';
|
|
9
|
+
import { moveArgumentToFront, parseArgsWithYargs, parseCommandArgs } from './telegram-solve-command.lib.mjs';
|
|
8
10
|
import { formatStartingWorkSessionMessage } from './work-session-formatting.lib.mjs';
|
|
9
11
|
|
|
10
12
|
export const TASK_COMMAND_NAMES = Object.freeze(['task', 'split']);
|
|
@@ -187,6 +189,13 @@ export function registerTaskCommands(bot, options) {
|
|
|
187
189
|
return;
|
|
188
190
|
}
|
|
189
191
|
|
|
192
|
+
try {
|
|
193
|
+
await parseArgsWithYargs(filteredArgs, getLinoYargsFactory(), createTaskYargsConfig);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
await safeReply(ctx, `❌ Invalid options: ${escapeMarkdown(error.message || String(error))}\n\nUse /help to see available options`, { reply_to_message_id: ctx.message.message_id });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
190
199
|
const modelError = validateTaskModel(filteredArgs);
|
|
191
200
|
if (modelError) {
|
|
192
201
|
await safeReply(ctx, `❌ ${escapeMarkdown(modelError)}`, { reply_to_message_id: ctx.message.message_id });
|