@link-assistant/hive-mind 2.17.0 → 2.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/github-url-parser.lib.mjs +80 -23
- package/src/github-url-recovery.lib.mjs +514 -0
- package/src/hive.mjs +10 -0
- package/src/locales/en.lino +7 -0
- package/src/locales/hi.lino +7 -0
- package/src/locales/ru.lino +7 -0
- package/src/locales/zh.lino +7 -0
- package/src/solve.validation.lib.mjs +16 -0
- package/src/telegram-bot.mjs +20 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.18.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 3fc12ef: Recover broken GitHub URLs instead of rejecting them, and say what was repaired (issue #2194).
|
|
8
|
+
|
|
9
|
+
A user sent `/Claude https://github.com/G-Ivan-A/aether-orbis/pulls/30`. Telegram drew a healthy GitHub preview card under it, because github.com really does answer `/owner/repo/pulls/30` with **HTTP 200** and a full set of Open Graph tags — a genuinely wrong path like `/pullz/30` 404s and gets no card. The bot then refused the command and told the user to "1. Open the repository: https://github.com/G-Ivan-A/aether-orbis/**pulls/30**" — the same broken link that had just failed. Meanwhile `parseGitHubUrl` had already extracted the number: the pre-fix `case 'pulls'` stored `'30'` in `result.subpath` and nothing ever read it. The data to restore the URL from was sitting in the result object.
|
|
10
|
+
|
|
11
|
+
- **`src/github-url-recovery.lib.mjs`** repairs a URL before it is parsed: strips invisible characters (`\p{Cf}`/`\p{Cc}`/`\p{Zl}`/`\p{Zp}`, U+034F, variation selectors), unwraps `[title](url)` / `<url>` / `(url).`, folds full-width and fraction punctuation and full-width digits to ASCII, lower-cases scheme and host, reads `git@github.com:owner/repo.git` and `api.github.com/repos/...` as their web addresses, and reads `/pulls/30` as `/pull/30`, `/issue/123` as `/issues/123`, `/pull/30/files` as the pull request itself.
|
|
12
|
+
- **Recovery is on by default inside `parseGitHubUrl`**, so all 48 call sites across 14 files get it without changing any of them. `{ recover: false }` reproduces the old code path exactly, which is what makes the before/after evidence reproducible.
|
|
13
|
+
- **It never invents a GitHub URL.** `gitlab.com`, `bitbucket.org`, `gist.github.com`, `raw.githubusercontent.com`, `github.com.evil.example` and `evil.example/github.com/...` are all still rejected — and so is over-reach in the other direction: `support@github.com` is an email address rather than a repository (`git@github.com:owner/repo.git` still works), and `[the PR](aether-orbis)` stays rejected because unwrapping prose is only worth doing when what comes out already names github.com.
|
|
14
|
+
- **It never repairs silently.** Every result carries `original`, `repairs[]` and `recovered`, plus `hidden`/`revealed` when something invisible was removed. The Telegram bot (new `telegram.url_recovered` string in `en`/`ru`/`hi`/`zh`), `solve` and `hive` each print what they understood before acting on it.
|
|
15
|
+
- **Two silent-corruption bugs went with it.** A zero-width space in a repository name used to produce `valid: true` for `aether-orbis%E2%80%8B` — a repository that does not exist. `HTTPS://GITHUB.COM/...` used to parse as a _relative path_ with `HTTPS:` as the owner; the bot's own `url.includes('github.com')` gate had the same flaw and rejected the URL before the parser saw it — that gate now asks the recovery layer whether the text names github.com as its _host_, so `evil.example/github.com/...` no longer passes it either.
|
|
16
|
+
- **The log could not have proved an invisible character even if there had been one** — it never recorded the message text. `/solve` and its aliases now log raw text through `revealHiddenCharacters()` (a zero-width space appears as `[U+200B]`), and `traceUrlRecovery()` reports each repair stage under `--verbose`.
|
|
17
|
+
|
|
18
|
+
`normalize-url@9` and `confusables@1` were installed and measured against these exact inputs rather than assumed: `normalize-url` percent-encodes the zero-width space instead of removing it and **throws** on a full-width colon, and `confusables` turns `github.com/Ćwikła/...` into `github.com/Cwikla/...` — it would silently retarget a real owner. Neither knows that `/pulls/30` means pull request 30.
|
|
19
|
+
|
|
20
|
+
77 assertions in `tests/test-issue-2194-broken-url-recovery.mjs`. Timeline, evidence and the full analysis are in `docs/case-studies/issue-2194/README.md`, with reproduction scripts in `experiments/issue-2194/` and `examples/github-url-recovery-demo.mjs`.
|
|
21
|
+
|
|
3
22
|
## 2.17.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
import { reportError } from './sentry.lib.mjs';
|
|
2
|
+
import { describeHiddenCharacters, repairGitHubPathParts, repairGitHubUrlText, revealHiddenCharacters, traceUrlRecovery } from './github-url-recovery.lib.mjs';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Attach the issue #2194 recovery diagnostics to a parse result.
|
|
6
|
+
*
|
|
7
|
+
* `original` is always present so a caller can show the user what they actually
|
|
8
|
+
* sent; `hidden`/`revealed` only appear when there was something invisible to
|
|
9
|
+
* reveal, keeping the common result small.
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} result - The result object to annotate (mutated and returned).
|
|
12
|
+
* @param {string} original - The URL exactly as it was passed in.
|
|
13
|
+
* @param {Array<{code: string, message: string, notable: boolean}>} repairs
|
|
14
|
+
* @param {Array<{escape: string, name: string}>} hidden
|
|
15
|
+
* @returns {Object} The same result object.
|
|
16
|
+
*/
|
|
17
|
+
function withRecoveryDiagnostics(result, original, repairs, hidden) {
|
|
18
|
+
result.original = original;
|
|
19
|
+
result.repairs = repairs;
|
|
20
|
+
result.recovered = repairs.length > 0;
|
|
21
|
+
if (hidden.length > 0) {
|
|
22
|
+
result.hidden = hidden;
|
|
23
|
+
result.revealed = revealHiddenCharacters(original);
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
2
27
|
|
|
3
28
|
/**
|
|
4
29
|
* Universal GitHub URL parser that handles various formats
|
|
5
30
|
* @param {string} url - The GitHub URL to parse
|
|
31
|
+
* @param {Object} [options] - Parsing options
|
|
32
|
+
* @param {boolean} [options.recover=true] - Repair recoverable damage (invisible
|
|
33
|
+
* Unicode, wrappers, look-alike punctuation, `/pulls/30` for `/pull/30`, …)
|
|
34
|
+
* before parsing. Pass `false` to see the URL exactly as it was typed.
|
|
6
35
|
* @returns {Object} Parsed URL information including:
|
|
7
36
|
* - valid: boolean indicating if the URL is valid
|
|
8
37
|
* - normalized: the normalized URL (https://github.com/...), query/fragment kept
|
|
@@ -13,22 +42,41 @@ import { reportError } from './sentry.lib.mjs';
|
|
|
13
42
|
* - number: issue/PR number (if applicable)
|
|
14
43
|
* - path: additional path components
|
|
15
44
|
* - error: error message if invalid
|
|
45
|
+
* - original: the URL exactly as it was passed in
|
|
46
|
+
* - repairs: the list of repairs recovery had to apply (issue #2194)
|
|
47
|
+
* - recovered: true when at least one repair was applied
|
|
48
|
+
* - hidden/revealed: codepoint diagnostics, present only when the input carried
|
|
49
|
+
* invisible or look-alike characters
|
|
16
50
|
*/
|
|
17
|
-
export function parseGitHubUrl(url) {
|
|
51
|
+
export function parseGitHubUrl(url, options = {}) {
|
|
18
52
|
if (!url || typeof url !== 'string') {
|
|
19
53
|
return {
|
|
20
54
|
valid: false,
|
|
21
55
|
error: 'Invalid input: URL must be a non-empty string',
|
|
22
56
|
};
|
|
23
57
|
}
|
|
58
|
+
const { recover = true } = options;
|
|
59
|
+
// Issue #2194: a URL that renders correctly on screen can still be broken —
|
|
60
|
+
// `…/pulls/30` previews as a healthy page, a zero-width space is unprintable by
|
|
61
|
+
// definition. Repair what can be repaired first, and keep a record of it, so the
|
|
62
|
+
// user is told what was interpreted instead of being told "invalid URL".
|
|
63
|
+
const hidden = describeHiddenCharacters(url);
|
|
64
|
+
let repairs = [];
|
|
24
65
|
// Trim whitespace and remove trailing slashes
|
|
25
66
|
let normalizedUrl = url.trim().replace(/\/+$/, '');
|
|
67
|
+
if (recover) {
|
|
68
|
+
const repaired = repairGitHubUrlText(normalizedUrl);
|
|
69
|
+
repairs = repaired.repairs;
|
|
70
|
+
if (repaired.rejection) {
|
|
71
|
+
traceUrlRecovery('rejected', { original: url, error: repaired.rejection, repairs, hidden });
|
|
72
|
+
return withRecoveryDiagnostics({ valid: false, error: repaired.rejection }, url, repairs, hidden);
|
|
73
|
+
}
|
|
74
|
+
normalizedUrl = repaired.text.replace(/\/+$/, '');
|
|
75
|
+
if (repairs.length > 0) traceUrlRecovery('repaired-text', { original: url, repaired: normalizedUrl, repairs, hidden });
|
|
76
|
+
}
|
|
26
77
|
// Check if this looks like a valid GitHub-related input Reject clearly invalid inputs (spaces in the URL, special chars at the start, etc.)
|
|
27
78
|
if (/\s/.test(normalizedUrl) || /^[!@#$%^&*()[\]{}|\\:;"'<>,?`~]/.test(normalizedUrl)) {
|
|
28
|
-
return {
|
|
29
|
-
valid: false,
|
|
30
|
-
error: 'Invalid GitHub URL format',
|
|
31
|
-
};
|
|
79
|
+
return withRecoveryDiagnostics({ valid: false, error: 'Invalid GitHub URL format' }, url, repairs, hidden);
|
|
32
80
|
}
|
|
33
81
|
// Handle protocol normalization
|
|
34
82
|
if (!normalizedUrl.startsWith('http://') && !normalizedUrl.startsWith('https://')) {
|
|
@@ -40,10 +88,7 @@ export function parseGitHubUrl(url) {
|
|
|
40
88
|
normalizedUrl = 'https://github.com/' + normalizedUrl;
|
|
41
89
|
} else {
|
|
42
90
|
// Has github.com somewhere but not at the start - likely malformed
|
|
43
|
-
return {
|
|
44
|
-
valid: false,
|
|
45
|
-
error: 'Invalid GitHub URL format',
|
|
46
|
-
};
|
|
91
|
+
return withRecoveryDiagnostics({ valid: false, error: 'Invalid GitHub URL format' }, url, repairs, hidden);
|
|
47
92
|
}
|
|
48
93
|
}
|
|
49
94
|
// Convert http to https
|
|
@@ -56,11 +101,16 @@ export function parseGitHubUrl(url) {
|
|
|
56
101
|
// Generate suggested URL by replacing backslashes with forward slashes
|
|
57
102
|
const suggestedUrl = urlBeforeQueryAndHash.replace(/\\/g, '/');
|
|
58
103
|
const urlAfterPath = normalizedUrl.substring(urlBeforeQueryAndHash.length);
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
104
|
+
return withRecoveryDiagnostics(
|
|
105
|
+
{
|
|
106
|
+
valid: false,
|
|
107
|
+
error: 'Invalid character in URL: backslash (\\) is not allowed in URL paths',
|
|
108
|
+
suggestion: suggestedUrl + urlAfterPath,
|
|
109
|
+
},
|
|
110
|
+
url,
|
|
111
|
+
repairs,
|
|
112
|
+
hidden
|
|
113
|
+
);
|
|
64
114
|
}
|
|
65
115
|
// Parse the URL
|
|
66
116
|
let urlObj;
|
|
@@ -74,17 +124,11 @@ export function parseGitHubUrl(url) {
|
|
|
74
124
|
url: normalizedUrl,
|
|
75
125
|
});
|
|
76
126
|
}
|
|
77
|
-
return {
|
|
78
|
-
valid: false,
|
|
79
|
-
error: 'Invalid URL format',
|
|
80
|
-
};
|
|
127
|
+
return withRecoveryDiagnostics({ valid: false, error: 'Invalid URL format' }, url, repairs, hidden);
|
|
81
128
|
}
|
|
82
129
|
// Ensure it's a GitHub URL
|
|
83
130
|
if (urlObj.hostname !== 'github.com' && urlObj.hostname !== 'www.github.com') {
|
|
84
|
-
return {
|
|
85
|
-
valid: false,
|
|
86
|
-
error: 'Not a GitHub URL',
|
|
87
|
-
};
|
|
131
|
+
return withRecoveryDiagnostics({ valid: false, error: 'Not a GitHub URL' }, url, repairs, hidden);
|
|
88
132
|
}
|
|
89
133
|
// Normalize hostname
|
|
90
134
|
if (urlObj.hostname === 'www.github.com') {
|
|
@@ -92,7 +136,19 @@ export function parseGitHubUrl(url) {
|
|
|
92
136
|
urlObj = new globalThis.URL(normalizedUrl);
|
|
93
137
|
}
|
|
94
138
|
// Parse the pathname
|
|
95
|
-
|
|
139
|
+
let pathParts = urlObj.pathname.split('/').filter(p => p);
|
|
140
|
+
// Issue #2194: `/owner/repo/pulls/30` carries every byte needed to address pull
|
|
141
|
+
// request 30 — restore it rather than reporting the pull request list page.
|
|
142
|
+
if (recover) {
|
|
143
|
+
const pathRepair = repairGitHubPathParts(pathParts);
|
|
144
|
+
if (pathRepair.repairs.length > 0) {
|
|
145
|
+
repairs = repairs.concat(pathRepair.repairs);
|
|
146
|
+
pathParts = pathRepair.parts;
|
|
147
|
+
urlObj.pathname = `/${pathParts.join('/')}`;
|
|
148
|
+
normalizedUrl = urlObj.toString().replace(/\/+$/, '');
|
|
149
|
+
traceUrlRecovery('repaired-path', { original: url, repaired: normalizedUrl, repairs, hidden });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
96
152
|
// Handle different GitHub URL patterns
|
|
97
153
|
const result = {
|
|
98
154
|
valid: true,
|
|
@@ -108,6 +164,7 @@ export function parseGitHubUrl(url) {
|
|
|
108
164
|
protocol: 'https',
|
|
109
165
|
path: urlObj.pathname,
|
|
110
166
|
};
|
|
167
|
+
withRecoveryDiagnostics(result, url, repairs, hidden);
|
|
111
168
|
// No path - just github.com
|
|
112
169
|
if (pathParts.length === 0) {
|
|
113
170
|
result.type = 'home';
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery layer for GitHub URLs that "look valid" but are not.
|
|
3
|
+
*
|
|
4
|
+
* A URL pasted into a chat client rarely arrives byte-for-byte as it left the
|
|
5
|
+
* address bar: messengers wrap it in punctuation, IMEs substitute full-width
|
|
6
|
+
* punctuation, bidi and zero-width characters ride along invisibly, and users type
|
|
7
|
+
* `/pulls/30` where they meant `/pull/30` — which github.com answers with HTTP 200
|
|
8
|
+
* (the pull request *list* page), so the link preview looks perfectly healthy while
|
|
9
|
+
* the number the user cared about is silently dropped.
|
|
10
|
+
*
|
|
11
|
+
* Every function here is pure. Repairs are conservative enough to be no-ops on a
|
|
12
|
+
* well-formed URL, and each one is reported back to the caller, so the bot can say
|
|
13
|
+
* what it actually interpreted instead of failing with "invalid URL".
|
|
14
|
+
*
|
|
15
|
+
* Scope note: the confusable-punctuation folding below is applied to the scheme and
|
|
16
|
+
* host, and to path *separators* only. A `tree`/`blob` file path therefore keeps its
|
|
17
|
+
* original bytes apart from its slashes — those URL types address no task in this
|
|
18
|
+
* codebase, and corrupting a file name would be worse than failing to normalize one.
|
|
19
|
+
*
|
|
20
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2194
|
|
21
|
+
* @module github-url-recovery
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Canonical GitHub web host. */
|
|
25
|
+
const GITHUB_HOST = 'github.com';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Characters that must never survive into a URL:
|
|
29
|
+
* - `\p{Cf}` — zero-width space/joiner, BOM, soft hyphen, bidi marks and embeddings
|
|
30
|
+
* - `\p{Cc}` — C0/C1 control characters (including tab, CR and LF)
|
|
31
|
+
* - `\p{Zl}`/`\p{Zp}` — line and paragraph separators
|
|
32
|
+
* - U+034F COMBINING GRAPHEME JOINER and U+FE00–U+FE0F variation selectors
|
|
33
|
+
* They are removable rather than replaceable: they carry no addressing information.
|
|
34
|
+
*/
|
|
35
|
+
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Cc}\p{Zl}\p{Zp}\u034F\uFE00-\uFE0F]/gu;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Unicode space separators other than U+0020. A URL can never contain one, so a
|
|
39
|
+
* space that is not the plain ASCII space is always paste damage. The ASCII space
|
|
40
|
+
* is deliberately left in place: it is how the caller still detects "this is two
|
|
41
|
+
* words, not a URL".
|
|
42
|
+
*/
|
|
43
|
+
const EXOTIC_SPACES = /[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
44
|
+
|
|
45
|
+
/** Names for the characters we most often have to strip, for the diagnostics. */
|
|
46
|
+
const CHARACTER_NAMES = {
|
|
47
|
+
0x0009: 'TAB',
|
|
48
|
+
0x000a: 'LINE FEED',
|
|
49
|
+
0x000d: 'CARRIAGE RETURN',
|
|
50
|
+
0x00ad: 'SOFT HYPHEN',
|
|
51
|
+
0x00a0: 'NO-BREAK SPACE',
|
|
52
|
+
0x034f: 'COMBINING GRAPHEME JOINER',
|
|
53
|
+
0x200b: 'ZERO WIDTH SPACE',
|
|
54
|
+
0x200c: 'ZERO WIDTH NON-JOINER',
|
|
55
|
+
0x200d: 'ZERO WIDTH JOINER',
|
|
56
|
+
0x200e: 'LEFT-TO-RIGHT MARK',
|
|
57
|
+
0x200f: 'RIGHT-TO-LEFT MARK',
|
|
58
|
+
0x202a: 'LEFT-TO-RIGHT EMBEDDING',
|
|
59
|
+
0x202b: 'RIGHT-TO-LEFT EMBEDDING',
|
|
60
|
+
0x202c: 'POP DIRECTIONAL FORMATTING',
|
|
61
|
+
0x202d: 'LEFT-TO-RIGHT OVERRIDE',
|
|
62
|
+
0x202e: 'RIGHT-TO-LEFT OVERRIDE',
|
|
63
|
+
0x202f: 'NARROW NO-BREAK SPACE',
|
|
64
|
+
0x2060: 'WORD JOINER',
|
|
65
|
+
0x2066: 'LEFT-TO-RIGHT ISOLATE',
|
|
66
|
+
0x2067: 'RIGHT-TO-LEFT ISOLATE',
|
|
67
|
+
0x2068: 'FIRST STRONG ISOLATE',
|
|
68
|
+
0x2069: 'POP DIRECTIONAL ISOLATE',
|
|
69
|
+
0x3000: 'IDEOGRAPHIC SPACE',
|
|
70
|
+
0xfe0f: 'VARIATION SELECTOR-16',
|
|
71
|
+
0xfeff: 'ZERO WIDTH NO-BREAK SPACE (BOM)',
|
|
72
|
+
0xff0e: 'FULLWIDTH FULL STOP',
|
|
73
|
+
0xff0f: 'FULLWIDTH SOLIDUS',
|
|
74
|
+
0xff1a: 'FULLWIDTH COLON',
|
|
75
|
+
0x2044: 'FRACTION SLASH',
|
|
76
|
+
0x2215: 'DIVISION SLASH',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Look-alike separators, mapped to the ASCII character they impersonate. */
|
|
80
|
+
const SEPARATOR_CONFUSABLES = new Map([
|
|
81
|
+
['/', '/'], // FULLWIDTH SOLIDUS
|
|
82
|
+
['⁄', '/'], // FRACTION SLASH
|
|
83
|
+
['∕', '/'], // DIVISION SLASH
|
|
84
|
+
['⧸', '/'], // BIG SOLIDUS
|
|
85
|
+
[':', ':'], // FULLWIDTH COLON
|
|
86
|
+
['﹕', ':'], // SMALL COLON
|
|
87
|
+
['︓', ':'], // PRESENTATION FORM FOR VERTICAL COLON
|
|
88
|
+
['∶', ':'], // RATIO
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/** Everything above, plus host punctuation that only makes sense before the path. */
|
|
92
|
+
const PREFIX_CONFUSABLES = new Map([
|
|
93
|
+
...SEPARATOR_CONFUSABLES,
|
|
94
|
+
['.', '.'], // FULLWIDTH FULL STOP
|
|
95
|
+
['@', '@'], // FULLWIDTH COMMERCIAL AT
|
|
96
|
+
['-', '-'], // FULLWIDTH HYPHEN-MINUS
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/** Paired delimiters a messenger or a prose sentence may wrap a URL in. */
|
|
100
|
+
const WRAPPERS = new Map([
|
|
101
|
+
['<', '>'],
|
|
102
|
+
['(', ')'],
|
|
103
|
+
['[', ']'],
|
|
104
|
+
['{', '}'],
|
|
105
|
+
['"', '"'],
|
|
106
|
+
["'", "'"],
|
|
107
|
+
['`', '`'],
|
|
108
|
+
['«', '»'], // « »
|
|
109
|
+
['“', '”'], // “ ”
|
|
110
|
+
['‘', '’'], // ‘ ’
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
/** Sentence punctuation that can only be prose, never the end of a GitHub URL. */
|
|
114
|
+
const TRAILING_PUNCTUATION = new Set(['.', ',', ';', ':', '!', '?', '…']);
|
|
115
|
+
|
|
116
|
+
/** Closing delimiters that are stripped when the matching opener is absent. */
|
|
117
|
+
const UNBALANCED_CLOSERS = new Map([
|
|
118
|
+
[')', '('],
|
|
119
|
+
[']', '['],
|
|
120
|
+
['}', '{'],
|
|
121
|
+
['>', '<'],
|
|
122
|
+
['»', '«'],
|
|
123
|
+
['”', '“'],
|
|
124
|
+
['’', '‘'],
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What may legally stand in front of `github.com`: an optional scheme (with the
|
|
129
|
+
* colon, or without it when at least two slashes follow — the `https//github.com`
|
|
130
|
+
* typo), optional SSH user info, and one of the few host aliases that serve the
|
|
131
|
+
* same site. Anything else — `gist.`, `raw.`, `evil.com/`, `notgithub.com` — must
|
|
132
|
+
* NOT be rewritten into a github.com address.
|
|
133
|
+
*/
|
|
134
|
+
const HOST_PREFIX = /^(?:[A-Za-z][A-Za-z0-9+.-]*:\/{0,3}|[A-Za-z][A-Za-z0-9+.-]*\/{2,3}|\/{0,3})(?:[^/\s@]*@)?(www\.|m\.|api\.)?$/;
|
|
135
|
+
|
|
136
|
+
/** Repairs that mean the token was wrapped in prose punctuation rather than typed as a URL. */
|
|
137
|
+
const WRAPPER_REPAIR_CODES = new Set(['markdown-link-unwrapped', 'wrapper-stripped']);
|
|
138
|
+
|
|
139
|
+
/** The first path segment must be able to be a GitHub login for the shorthand form. */
|
|
140
|
+
const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\/|$)/;
|
|
141
|
+
|
|
142
|
+
/** Any explicit `scheme://`, which means the input names its own host. */
|
|
143
|
+
const EXPLICIT_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
|
|
144
|
+
|
|
145
|
+
/** Alternate spellings of the two entity kinds we can act on. */
|
|
146
|
+
const ENTITY_KIND_ALIASES = new Map([
|
|
147
|
+
['issue', 'issues'],
|
|
148
|
+
['issues', 'issues'],
|
|
149
|
+
['pull', 'pull'],
|
|
150
|
+
['pulls', 'pulls'],
|
|
151
|
+
['pullrequest', 'pull'],
|
|
152
|
+
['pullrequests', 'pulls'],
|
|
153
|
+
['pull-request', 'pull'],
|
|
154
|
+
['pull-requests', 'pulls'],
|
|
155
|
+
['pull_request', 'pull'],
|
|
156
|
+
['pull_requests', 'pulls'],
|
|
157
|
+
['merge_requests', 'pulls'],
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Repairs a user cannot see for themselves. These change *which* entity the URL
|
|
162
|
+
* addresses, or undo damage that is invisible on screen, so the bot has to say out
|
|
163
|
+
* loud what it did. The rest (a stripped bracket, an added `https://`) are obvious
|
|
164
|
+
* from the URL itself and would only be noise.
|
|
165
|
+
*/
|
|
166
|
+
const NOTABLE_REPAIR_CODES = new Set(['invisible-characters-removed', 'confusables-normalized', 'entity-kind-corrected', 'entity-subpath-dropped', 'fullwidth-digits-normalized', 'duplicate-slashes-collapsed', 'api-url-converted']);
|
|
167
|
+
|
|
168
|
+
/** Rejection reasons, worded exactly as `parseGitHubUrl` has always worded them. */
|
|
169
|
+
export const REJECT_NOT_GITHUB = 'Not a GitHub URL';
|
|
170
|
+
export const REJECT_MALFORMED = 'Invalid GitHub URL format';
|
|
171
|
+
|
|
172
|
+
/** Single-character probes need their own non-global copies of the /g regexes. */
|
|
173
|
+
const INVISIBLE_CHARACTER = new RegExp(INVISIBLE_CHARACTERS.source, 'u');
|
|
174
|
+
const EXOTIC_SPACE = new RegExp(EXOTIC_SPACES.source, 'u');
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Describe every character in `text` that is invisible, exotic whitespace, or a
|
|
178
|
+
* punctuation look-alike, so a log line or an error message can show the user *why*
|
|
179
|
+
* a URL that looks correct on screen was not.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} text
|
|
182
|
+
* @returns {Array<{index: number, codePoint: number, escape: string, name: string}>}
|
|
183
|
+
*/
|
|
184
|
+
export function describeHiddenCharacters(text) {
|
|
185
|
+
if (!text || typeof text !== 'string') return [];
|
|
186
|
+
const found = [];
|
|
187
|
+
for (let index = 0; index < text.length; index++) {
|
|
188
|
+
const character = text[index];
|
|
189
|
+
if (!INVISIBLE_CHARACTER.test(character) && !EXOTIC_SPACE.test(character) && !PREFIX_CONFUSABLES.has(character)) continue;
|
|
190
|
+
const codePoint = character.codePointAt(0);
|
|
191
|
+
const escape = `U+${codePoint.toString(16).toUpperCase().padStart(4, '0')}`;
|
|
192
|
+
found.push({ index, codePoint, escape, name: CHARACTER_NAMES[codePoint] || escape });
|
|
193
|
+
}
|
|
194
|
+
return found;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Render `text` with every hidden character replaced by its `U+XXXX` escape, so the
|
|
199
|
+
* damage survives a copy/paste into an issue report. Visible characters are kept.
|
|
200
|
+
*
|
|
201
|
+
* @param {string} text
|
|
202
|
+
* @param {{maxLength?: number}} [options]
|
|
203
|
+
* @returns {string}
|
|
204
|
+
*/
|
|
205
|
+
export function revealHiddenCharacters(text, { maxLength = 300 } = {}) {
|
|
206
|
+
if (!text || typeof text !== 'string') return '';
|
|
207
|
+
const hidden = new Map(describeHiddenCharacters(text).map(entry => [entry.index, entry.escape]));
|
|
208
|
+
let revealed = '';
|
|
209
|
+
for (let index = 0; index < text.length; index++) {
|
|
210
|
+
revealed += hidden.has(index) ? `[${hidden.get(index)}]` : text[index];
|
|
211
|
+
}
|
|
212
|
+
return revealed.length > maxLength ? `${revealed.slice(0, maxLength)}… (truncated)` : revealed;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Replace look-alike punctuation with ASCII, preserving string length. */
|
|
216
|
+
function fold(text, table) {
|
|
217
|
+
let folded = '';
|
|
218
|
+
for (const character of text) folded += table.get(character) ?? character;
|
|
219
|
+
return folded;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Record a repair once; a repeated code keeps its first (most specific) message. */
|
|
223
|
+
function addRepair(repairs, code, message) {
|
|
224
|
+
if (repairs.some(repair => repair.code === code)) return;
|
|
225
|
+
repairs.push({ code, message, notable: NOTABLE_REPAIR_CODES.has(code) });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Strip paired wrappers and prose punctuation that a messenger glued to the URL. */
|
|
229
|
+
function stripDecoration(text, repairs) {
|
|
230
|
+
let current = text;
|
|
231
|
+
let changed = true;
|
|
232
|
+
while (changed && current.length > 1) {
|
|
233
|
+
changed = false;
|
|
234
|
+
// [label](url) — a Markdown link pasted whole.
|
|
235
|
+
const markdownLink = current.match(/^\[[^\]]*\]\((.+)\)$/);
|
|
236
|
+
if (markdownLink) {
|
|
237
|
+
current = markdownLink[1].trim();
|
|
238
|
+
addRepair(repairs, 'markdown-link-unwrapped', 'unwrapped a Markdown link');
|
|
239
|
+
changed = true;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (WRAPPERS.get(current[0]) === current[current.length - 1]) {
|
|
243
|
+
current = current.slice(1, -1).trim();
|
|
244
|
+
addRepair(repairs, 'wrapper-stripped', 'removed the surrounding brackets or quotes');
|
|
245
|
+
changed = true;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const last = current[current.length - 1];
|
|
249
|
+
if (TRAILING_PUNCTUATION.has(last)) {
|
|
250
|
+
current = current.slice(0, -1);
|
|
251
|
+
addRepair(repairs, 'trailing-punctuation-stripped', `removed the trailing "${last}"`);
|
|
252
|
+
changed = true;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const opener = UNBALANCED_CLOSERS.get(last);
|
|
256
|
+
if (opener && !current.includes(opener)) {
|
|
257
|
+
current = current.slice(0, -1);
|
|
258
|
+
addRepair(repairs, 'trailing-punctuation-stripped', `removed the unmatched "${last}"`);
|
|
259
|
+
changed = true;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return current;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Remove invisible characters and exotic spaces, reporting what was dropped. */
|
|
266
|
+
function stripHiddenCharacters(text, repairs) {
|
|
267
|
+
const cleaned = text.replace(INVISIBLE_CHARACTERS, '').replace(EXOTIC_SPACES, ' ');
|
|
268
|
+
if (cleaned === text) return text;
|
|
269
|
+
const names = [...new Set(describeHiddenCharacters(text).map(entry => entry.name))].slice(0, 4).join(', ');
|
|
270
|
+
addRepair(repairs, 'invisible-characters-removed', `removed invisible character(s)${names ? `: ${names}` : ''}`);
|
|
271
|
+
return cleaned;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Rewrite the scheme/host prefix to the canonical `https://github.com`.
|
|
276
|
+
*
|
|
277
|
+
* @returns {{text: string}|{rejection: string}|null} `null` when the input carries
|
|
278
|
+
* no `github.com` at all, leaving the caller to consider the shorthand form.
|
|
279
|
+
*/
|
|
280
|
+
function normalizeHost(text, repairs) {
|
|
281
|
+
const folded = fold(text, PREFIX_CONFUSABLES);
|
|
282
|
+
const hostIndex = folded.toLowerCase().indexOf(GITHUB_HOST);
|
|
283
|
+
if (hostIndex === -1) return null;
|
|
284
|
+
|
|
285
|
+
const hostEnd = hostIndex + GITHUB_HOST.length;
|
|
286
|
+
const prefix = folded.slice(0, hostIndex);
|
|
287
|
+
const prefixMatch = prefix.match(HOST_PREFIX);
|
|
288
|
+
if (!prefixMatch) return { rejection: REJECT_NOT_GITHUB };
|
|
289
|
+
|
|
290
|
+
// Only the separators of the remainder are folded — see the module note.
|
|
291
|
+
let after = fold(text.slice(hostEnd, hostEnd + 1), SEPARATOR_CONFUSABLES) + text.slice(hostEnd + 1);
|
|
292
|
+
if (after !== '' && !/^[/:?#]/.test(after)) return { rejection: REJECT_NOT_GITHUB };
|
|
293
|
+
|
|
294
|
+
// `git@github.com:owner/repo.git` is an SSH remote; `support@github.com` on its
|
|
295
|
+
// own is an email address and must never become a GitHub URL (issue #2194).
|
|
296
|
+
if (prefix.includes('@') && !/^[:/]./.test(after)) return { rejection: REJECT_NOT_GITHUB };
|
|
297
|
+
|
|
298
|
+
const port = after.match(/^:(\d+)(.*)$/s);
|
|
299
|
+
if (port) after = port[2];
|
|
300
|
+
const rest = after.replace(/^:+/, '/');
|
|
301
|
+
|
|
302
|
+
const alias = (prefixMatch[1] || '').toLowerCase();
|
|
303
|
+
let path = rest;
|
|
304
|
+
if (alias === 'api.') {
|
|
305
|
+
// https://api.github.com/repos/{owner}/{repo}/{issues|pulls}/{number}
|
|
306
|
+
const apiPath = rest.match(/^\/repos\/([^/\s]+\/[^/\s]+(?:\/.*)?)$/s);
|
|
307
|
+
if (!apiPath) return { rejection: REJECT_NOT_GITHUB };
|
|
308
|
+
path = `/${apiPath[1]}`;
|
|
309
|
+
addRepair(repairs, 'api-url-converted', 'read the api.github.com address as its web address');
|
|
310
|
+
} else if (alias) {
|
|
311
|
+
addRepair(repairs, 'host-normalized', `normalized the "${alias}${GITHUB_HOST}" host to ${GITHUB_HOST}`);
|
|
312
|
+
}
|
|
313
|
+
if (prefix.includes('@')) addRepair(repairs, 'ssh-url-converted', 'read the SSH/git remote address as its web address');
|
|
314
|
+
if (folded.slice(0, hostEnd) !== text.slice(0, hostEnd)) addRepair(repairs, 'confusables-normalized', 'replaced look-alike punctuation with ASCII');
|
|
315
|
+
if (!/^https:\/\/github\.com(?:[/?#]|$)/.test(text)) addRepair(repairs, 'scheme-normalized', 'normalized the address to https://github.com');
|
|
316
|
+
|
|
317
|
+
return { text: `https://${GITHUB_HOST}${path === '' || /^[/?#]/.test(path) ? path : `/${path}`}` };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Convert full-width digits (`30`) to ASCII; returns null when not all digits. */
|
|
321
|
+
function toAsciiDigits(segment) {
|
|
322
|
+
if (!/^[0-9]+$/.test(segment)) return null;
|
|
323
|
+
return segment.replace(/[0-9]/g, digit => String.fromCharCode(digit.charCodeAt(0) - 0xff10 + 0x30));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Collapse repeated slashes in the path and drop a `.git` suffix on the repo. */
|
|
327
|
+
function normalizePathShape(text, repairs) {
|
|
328
|
+
const match = text.match(/^(https:\/\/github\.com)([\s\S]*)$/);
|
|
329
|
+
if (!match) return text;
|
|
330
|
+
const pathAndRest = match[2];
|
|
331
|
+
const boundary = pathAndRest.search(/[?#]/);
|
|
332
|
+
const path = boundary === -1 ? pathAndRest : pathAndRest.slice(0, boundary);
|
|
333
|
+
const suffix = boundary === -1 ? '' : pathAndRest.slice(boundary);
|
|
334
|
+
|
|
335
|
+
let repaired = path.replace(/\/{2,}/g, '/');
|
|
336
|
+
if (repaired !== path) addRepair(repairs, 'duplicate-slashes-collapsed', 'collapsed repeated slashes');
|
|
337
|
+
|
|
338
|
+
const segments = repaired.split('/').filter(Boolean);
|
|
339
|
+
let rebuild = false;
|
|
340
|
+
if (segments.length >= 2 && /\.git$/i.test(segments[1])) {
|
|
341
|
+
segments[1] = segments[1].replace(/\.git$/i, '');
|
|
342
|
+
rebuild = true;
|
|
343
|
+
addRepair(repairs, 'git-suffix-removed', 'removed the ".git" suffix from the repository name');
|
|
344
|
+
}
|
|
345
|
+
// Full-width digits have to be folded here, before `new URL()` percent-encodes
|
|
346
|
+
// them out of recognition. Only the entity number is touched, never a file name.
|
|
347
|
+
if (segments.length >= 4 && ENTITY_KIND_ALIASES.has(segments[2].toLowerCase())) {
|
|
348
|
+
const asciiNumber = toAsciiDigits(segments[3]);
|
|
349
|
+
if (asciiNumber) {
|
|
350
|
+
segments[3] = asciiNumber;
|
|
351
|
+
rebuild = true;
|
|
352
|
+
addRepair(repairs, 'fullwidth-digits-normalized', 'converted full-width digits to ASCII');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (rebuild) repaired = `/${segments.join('/')}`;
|
|
356
|
+
return `https://${GITHUB_HOST}${repaired}${suffix}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Does `text` address github.com *as its host*?
|
|
361
|
+
*
|
|
362
|
+
* A substring test cannot answer this. `https://evil.example/github.com/o/r` and
|
|
363
|
+
* `https://github.com.evil.example/o/r` both contain "github.com" and neither one is
|
|
364
|
+
* a GitHub URL, while `HTTPS://GITHUB.COM/o/r` and `https://github.com/o/r` are
|
|
365
|
+
* GitHub URLs that contain no ASCII lowercase "github.com" at all. So the question
|
|
366
|
+
* is answered by the same host normalizer the repair path uses, and by nothing else.
|
|
367
|
+
*
|
|
368
|
+
* The shorthand form (`owner/repo`) names no host and is deliberately not a match:
|
|
369
|
+
* this is the check a caller makes *before* deciding the input was meant to be a URL
|
|
370
|
+
* at all, so a bare word must stay distinguishable from a mistyped address.
|
|
371
|
+
*
|
|
372
|
+
* @param {string} text - The URL exactly as the user supplied it.
|
|
373
|
+
* @returns {boolean}
|
|
374
|
+
*/
|
|
375
|
+
export function namesGitHubHost(text) {
|
|
376
|
+
if (!text || typeof text !== 'string') return false;
|
|
377
|
+
const repairs = [];
|
|
378
|
+
const stripped = stripDecoration(stripHiddenCharacters(text.trim(), repairs).trim(), repairs);
|
|
379
|
+
if (stripped === '') return false;
|
|
380
|
+
const host = normalizeHost(stripped, repairs);
|
|
381
|
+
return Boolean(host) && !host.rejection;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Repair the textual form of a GitHub URL before it is parsed.
|
|
386
|
+
*
|
|
387
|
+
* @param {string} raw - The URL exactly as the user supplied it.
|
|
388
|
+
* @returns {{text: string, repairs: Array<{code: string, message: string, notable: boolean}>, rejection?: string}}
|
|
389
|
+
* `text` is the repaired URL (identical to the trimmed input when nothing needed
|
|
390
|
+
* fixing), `repairs` lists what changed, and `rejection` is set when the input
|
|
391
|
+
* names a host that must not be rewritten.
|
|
392
|
+
*/
|
|
393
|
+
export function repairGitHubUrlText(raw) {
|
|
394
|
+
const repairs = [];
|
|
395
|
+
if (!raw || typeof raw !== 'string') return { text: '', repairs };
|
|
396
|
+
|
|
397
|
+
let text = stripHiddenCharacters(raw.trim(), repairs).trim();
|
|
398
|
+
text = stripDecoration(text, repairs);
|
|
399
|
+
if (text === '') return { text, repairs, rejection: REJECT_MALFORMED };
|
|
400
|
+
const wasWrapped = repairs.some(repair => WRAPPER_REPAIR_CODES.has(repair.code));
|
|
401
|
+
|
|
402
|
+
const host = normalizeHost(text, repairs);
|
|
403
|
+
if (host?.rejection) return { text, repairs, rejection: host.rejection };
|
|
404
|
+
if (host) return { text: normalizePathShape(host.text, repairs), repairs };
|
|
405
|
+
|
|
406
|
+
// No github.com anywhere. An input that names its own scheme names its own host
|
|
407
|
+
// too, so it is handed to the parser untouched and rejected there — this is what
|
|
408
|
+
// keeps `https://gitlab.com/owner/repo` from being rewritten into a GitHub URL.
|
|
409
|
+
if (EXPLICIT_SCHEME.test(text)) return { text, repairs };
|
|
410
|
+
// `[label](target)` and `"word"` in the middle of a sentence are prose. Unwrapping
|
|
411
|
+
// them is only worth doing when what comes out already names github.com, so a bare
|
|
412
|
+
// token that was wrapped must not be promoted to a GitHub profile (issue #2194).
|
|
413
|
+
if (wasWrapped) return { text, repairs, rejection: REJECT_MALFORMED };
|
|
414
|
+
if (!GITHUB_LOGIN.test(text)) return { text, repairs, rejection: REJECT_MALFORMED };
|
|
415
|
+
addRepair(repairs, 'scheme-normalized', `read the shorthand as a ${GITHUB_HOST} path`);
|
|
416
|
+
return { text: normalizePathShape(`https://${GITHUB_HOST}/${text.replace(/^\/+/, '')}`, repairs), repairs };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Repair the `owner/repo/kind/number` shape of an already-split path.
|
|
421
|
+
*
|
|
422
|
+
* This is where the reported failure is fixed: `/owner/repo/pulls/30` carries every
|
|
423
|
+
* byte needed to address pull request 30, so it is restored to `/owner/repo/pull/30`
|
|
424
|
+
* instead of being reported as "the pull requests list page".
|
|
425
|
+
*
|
|
426
|
+
* @param {string[]} parts - Path segments, with the empty ones already removed.
|
|
427
|
+
* @returns {{parts: string[], repairs: Array<{code: string, message: string, notable: boolean}>}}
|
|
428
|
+
*/
|
|
429
|
+
export function repairGitHubPathParts(parts) {
|
|
430
|
+
const repairs = [];
|
|
431
|
+
if (!Array.isArray(parts) || parts.length < 3) return { parts, repairs };
|
|
432
|
+
|
|
433
|
+
const repaired = [...parts];
|
|
434
|
+
const rawKind = repaired[2];
|
|
435
|
+
const kind = ENTITY_KIND_ALIASES.get(rawKind.toLowerCase());
|
|
436
|
+
if (!kind) return { parts: repaired, repairs };
|
|
437
|
+
|
|
438
|
+
const asciiNumber = repaired.length > 3 ? toAsciiDigits(repaired[3]) : null;
|
|
439
|
+
if (asciiNumber) {
|
|
440
|
+
repaired[3] = asciiNumber;
|
|
441
|
+
addRepair(repairs, 'fullwidth-digits-normalized', 'converted full-width digits to ASCII');
|
|
442
|
+
}
|
|
443
|
+
const hasNumber = repaired.length > 3 && /^\d+$/.test(repaired[3]);
|
|
444
|
+
|
|
445
|
+
// `/pulls/30` addresses pull request 30; a bare `/pull` addresses the list. Any
|
|
446
|
+
// other `/pull/<something>` (`/pull/new/branch`) is left exactly as it was.
|
|
447
|
+
let canonicalKind = kind;
|
|
448
|
+
if (kind === 'pulls' && hasNumber) canonicalKind = 'pull';
|
|
449
|
+
if (kind === 'pull' && repaired.length === 3) canonicalKind = 'pulls';
|
|
450
|
+
|
|
451
|
+
if (canonicalKind !== rawKind) {
|
|
452
|
+
repaired[2] = canonicalKind;
|
|
453
|
+
const isReportedCase = canonicalKind === 'pull' && rawKind.toLowerCase() === 'pulls';
|
|
454
|
+
addRepair(repairs, 'entity-kind-corrected', isReportedCase ? `"/${rawKind}/${repaired[3]}" is the pull request list page — read it as "/pull/${repaired[3]}"` : `corrected the path segment "${rawKind}" to "${canonicalKind}"`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// A tab suffix (`/files`, `/commits`, `/checks`, …) still addresses the same entity.
|
|
458
|
+
if (hasNumber && repaired.length > 4 && (canonicalKind === 'pull' || canonicalKind === 'issues')) {
|
|
459
|
+
const dropped = repaired.slice(4).join('/');
|
|
460
|
+
repaired.length = 4;
|
|
461
|
+
addRepair(repairs, 'entity-subpath-dropped', `ignored the "/${dropped}" tab and used the ${canonicalKind === 'pull' ? 'pull request' : 'issue'} itself`);
|
|
462
|
+
}
|
|
463
|
+
return { parts: repaired, repairs };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Render a repair list as one human-readable sentence fragment.
|
|
468
|
+
*
|
|
469
|
+
* @param {Array<{message: string}>} repairs
|
|
470
|
+
* @param {{notableOnly?: boolean}} [options]
|
|
471
|
+
* @returns {string} Empty string when there is nothing worth saying.
|
|
472
|
+
*/
|
|
473
|
+
export function formatUrlRepairs(repairs, { notableOnly = false } = {}) {
|
|
474
|
+
if (!Array.isArray(repairs) || repairs.length === 0) return '';
|
|
475
|
+
const selected = notableOnly ? repairs.filter(repair => repair.notable) : repairs;
|
|
476
|
+
return selected.map(repair => repair.message).join('; ');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** True when at least one repair changed something the user could not see. */
|
|
480
|
+
export function hasNotableRepair(repairs) {
|
|
481
|
+
return Array.isArray(repairs) && repairs.some(repair => repair.notable);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Verbose-only trace of one recovery step.
|
|
486
|
+
*
|
|
487
|
+
* `parseGitHubUrl` is synchronous and cannot await the project logger, but the
|
|
488
|
+
* stdio interceptor in `lib.mjs` mirrors console output into the session log, so a
|
|
489
|
+
* `--verbose` run still ends up with the codepoint-level record that issue #2194
|
|
490
|
+
* asked for ("add debug output and verbose mode … that will allow us to find root
|
|
491
|
+
* cause on next iteration").
|
|
492
|
+
*
|
|
493
|
+
* @param {string} stage
|
|
494
|
+
* @param {Object} details
|
|
495
|
+
*/
|
|
496
|
+
export function traceUrlRecovery(stage, details) {
|
|
497
|
+
if (!globalThis.verboseMode) return;
|
|
498
|
+
try {
|
|
499
|
+
console.error(`[url-recovery] ${stage} ${JSON.stringify(details)}`);
|
|
500
|
+
} catch {
|
|
501
|
+
console.error(`[url-recovery] ${stage} (details could not be serialized)`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export default {
|
|
506
|
+
describeHiddenCharacters,
|
|
507
|
+
formatUrlRepairs,
|
|
508
|
+
hasNotableRepair,
|
|
509
|
+
namesGitHubHost,
|
|
510
|
+
repairGitHubPathParts,
|
|
511
|
+
repairGitHubUrlText,
|
|
512
|
+
revealHiddenCharacters,
|
|
513
|
+
traceUrlRecovery,
|
|
514
|
+
};
|
package/src/hive.mjs
CHANGED
|
@@ -86,6 +86,8 @@ if (isRunningDirectly) {
|
|
|
86
86
|
const { validateYouTrackConfig, testYouTrackConnection, createYouTrackConfigFromEnv } = youTrackLib;
|
|
87
87
|
const youTrackSync = await import('./youtrack/youtrack-sync.mjs');
|
|
88
88
|
const { syncYouTrackToGitHub, formatIssuesForHive } = youTrackSync;
|
|
89
|
+
// Issue #2194: recovery diagnostics for URLs that had to be repaired before parsing.
|
|
90
|
+
const { formatUrlRepairs, hasNotableRepair, revealHiddenCharacters } = await import('./github-url-recovery.lib.mjs');
|
|
89
91
|
const memCheck = await import('./memory-check.mjs');
|
|
90
92
|
const { checkSystem } = memCheck;
|
|
91
93
|
const exitHandler = await import('./exit-handler.lib.mjs');
|
|
@@ -185,6 +187,14 @@ if (isRunningDirectly) {
|
|
|
185
187
|
console.error(' - owner/repo (will be converted to https://github.com/owner/repo)');
|
|
186
188
|
await safeExit(1, 'Error occurred');
|
|
187
189
|
}
|
|
190
|
+
// Issue #2194: report a repaired URL before monitoring starts, so the user
|
|
191
|
+
// can catch a wrong guess instead of watching the wrong repository.
|
|
192
|
+
if (hasNotableRepair(parsedUrl.repairs)) {
|
|
193
|
+
console.error('ℹ️ Repaired the GitHub URL before monitoring:');
|
|
194
|
+
console.error(` You typed: ${revealHiddenCharacters(githubUrl)}`);
|
|
195
|
+
console.error(` Using: ${parsedUrl.canonical || parsedUrl.normalized}`);
|
|
196
|
+
console.error(` Repaired: ${formatUrlRepairs(parsedUrl.repairs, { notableOnly: true })}`);
|
|
197
|
+
}
|
|
188
198
|
// Check if it's a valid type for hive (user or repo)
|
|
189
199
|
if (parsedUrl.type !== 'user' && parsedUrl.type !== 'repo') {
|
|
190
200
|
console.error('Error: Invalid GitHub URL for monitoring');
|
package/src/locales/en.lino
CHANGED
|
@@ -508,6 +508,13 @@ en
|
|
|
508
508
|
must
|
|
509
509
|
be
|
|
510
510
|
type "URL must be a GitHub {{allowedTypes}} (not {{type}})"
|
|
511
|
+
recovered """
|
|
512
|
+
ℹ️ I repaired the link before starting.
|
|
513
|
+
|
|
514
|
+
You sent: {{original}}
|
|
515
|
+
Using: {{used}}
|
|
516
|
+
Repaired: {{repairs}}
|
|
517
|
+
"""
|
|
511
518
|
language
|
|
512
519
|
invalid """
|
|
513
520
|
❌ Invalid language. Supported: {{supported}}.
|
package/src/locales/hi.lino
CHANGED
|
@@ -508,6 +508,13 @@ hi
|
|
|
508
508
|
must
|
|
509
509
|
be
|
|
510
510
|
type "URL GitHub {{allowedTypes}} होना चाहिए ({{type}} नहीं)"
|
|
511
|
+
recovered """
|
|
512
|
+
ℹ️ शुरू करने से पहले लिंक ठीक की गई।
|
|
513
|
+
|
|
514
|
+
आपने भेजा: {{original}}
|
|
515
|
+
उपयोग किया जा रहा है: {{used}}
|
|
516
|
+
ठीक किया गया: {{repairs}}
|
|
517
|
+
"""
|
|
511
518
|
language
|
|
512
519
|
invalid """
|
|
513
520
|
❌ अमान्य भाषा। समर्थित: {{supported}}।
|
package/src/locales/ru.lino
CHANGED
|
@@ -508,6 +508,13 @@ ru
|
|
|
508
508
|
must
|
|
509
509
|
be
|
|
510
510
|
type "URL должен быть GitHub {{allowedTypes}} (не {{type}})"
|
|
511
|
+
recovered """
|
|
512
|
+
ℹ️ Ссылка была исправлена перед запуском.
|
|
513
|
+
|
|
514
|
+
Вы отправили: {{original}}
|
|
515
|
+
Используется: {{used}}
|
|
516
|
+
Исправлено: {{repairs}}
|
|
517
|
+
"""
|
|
511
518
|
language
|
|
512
519
|
invalid """
|
|
513
520
|
❌ Неверный язык. Поддерживаются: {{supported}}.
|
package/src/locales/zh.lino
CHANGED
|
@@ -508,6 +508,13 @@ zh
|
|
|
508
508
|
must
|
|
509
509
|
be
|
|
510
510
|
type "URL 必须是 GitHub {{allowedTypes}}(不是 {{type}})"
|
|
511
|
+
recovered """
|
|
512
|
+
ℹ️ 开始前已修复链接。
|
|
513
|
+
|
|
514
|
+
您发送的:{{original}}
|
|
515
|
+
实际使用:{{used}}
|
|
516
|
+
修复内容:{{repairs}}
|
|
517
|
+
"""
|
|
511
518
|
language
|
|
512
519
|
invalid """
|
|
513
520
|
❌ 语言无效。支持的语言:{{supported}}。
|
|
@@ -35,6 +35,9 @@ const {
|
|
|
35
35
|
} = githubLib;
|
|
36
36
|
|
|
37
37
|
// Import git-related functions for identity validation and repair
|
|
38
|
+
// Issue #2194: recovery diagnostics for URLs that had to be repaired before parsing.
|
|
39
|
+
const { formatUrlRepairs, hasNotableRepair, revealHiddenCharacters } = await import('./github-url-recovery.lib.mjs');
|
|
40
|
+
|
|
38
41
|
const gitLib = await import('./git.lib.mjs');
|
|
39
42
|
const { checkGitIdentity, repairGitIdentity } = gitLib;
|
|
40
43
|
|
|
@@ -83,6 +86,16 @@ export const validateGitHubUrl = issueUrl => {
|
|
|
83
86
|
return { isValid: false, isIssueUrl: null, isPrUrl: null };
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
// Issue #2194: the URL needed repair before it could be understood. Say so up
|
|
90
|
+
// front, so a wrong guess is visible before a whole session runs against the
|
|
91
|
+
// wrong entity.
|
|
92
|
+
if (hasNotableRepair(parsedUrl.repairs)) {
|
|
93
|
+
console.error('ℹ️ Repaired the GitHub URL before solving:');
|
|
94
|
+
console.error(` You typed: ${revealHiddenCharacters(issueUrl)}`);
|
|
95
|
+
console.error(` Using: ${parsedUrl.canonical || parsedUrl.normalized}`);
|
|
96
|
+
console.error(` Repaired: ${formatUrlRepairs(parsedUrl.repairs, { notableOnly: true })}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
// Check if it's an issue or pull request
|
|
87
100
|
const isIssueUrl = parsedUrl.type === 'issue';
|
|
88
101
|
const isPrUrl = parsedUrl.type === 'pull';
|
|
@@ -102,9 +115,12 @@ export const validateGitHubUrl = issueUrl => {
|
|
|
102
115
|
isIssueUrl,
|
|
103
116
|
isPrUrl,
|
|
104
117
|
normalizedUrl: parsedUrl.normalized,
|
|
118
|
+
canonicalUrl: parsedUrl.canonical || parsedUrl.normalized,
|
|
105
119
|
owner: parsedUrl.owner,
|
|
106
120
|
repo: parsedUrl.repo,
|
|
107
121
|
number: parsedUrl.number,
|
|
122
|
+
repairs: parsedUrl.repairs || [],
|
|
123
|
+
recovered: Boolean(parsedUrl.recovered),
|
|
108
124
|
};
|
|
109
125
|
};
|
|
110
126
|
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -169,6 +169,8 @@ const { formatUsageMessage, formatCodexLimitsSection, getAllCachedLimits } = lim
|
|
|
169
169
|
const { handleShowLimitsFlag, captureStartSnapshotAndAppend } = await import('./telegram-show-limits.lib.mjs'); // #594
|
|
170
170
|
const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
|
|
171
171
|
const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
|
|
172
|
+
const { formatUrlRepairs, hasNotableRepair, namesGitHubHost, revealHiddenCharacters } = await import('./github-url-recovery.lib.mjs'); // #2194
|
|
173
|
+
|
|
172
174
|
const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
|
|
173
175
|
const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFromText, getSolveToolAliasFromText, moveArgumentToFront, parseArgsWithYargs, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
|
|
174
176
|
const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMessage } = await import('./telegram-command-execution.lib.mjs');
|
|
@@ -315,22 +317,28 @@ async function validateGitHubUrl(args, options = {}) {
|
|
|
315
317
|
if (!rawUrl) return { valid: false, error: t('telegram.missing_github_url', { commandName }, { locale }) };
|
|
316
318
|
// Issue #1102: Clean non-printable chars (Zero-Width Space, BOM, etc.) from URLs
|
|
317
319
|
const url = cleanNonPrintableChars(rawUrl);
|
|
318
|
-
|
|
320
|
+
// Issue #2194: the host may be typed in any case (GITHUB.COM) or hidden behind
|
|
321
|
+
// look-alike punctuation, and "github.com" in the path of another host is not a
|
|
322
|
+
// GitHub URL at all — so the recovery layer's host check makes the call, not a
|
|
323
|
+
// substring test that both misses the first case and accepts the second.
|
|
324
|
+
if (!namesGitHubHost(url)) return { valid: false, error: t('telegram.first_arg_must_be_github_url', {}, { locale }) };
|
|
319
325
|
const parsed = parseGitHubUrl(url);
|
|
320
326
|
if (!parsed.valid) return { valid: false, error: parsed.error || 'Invalid GitHub URL', suggestion: parsed.suggestion };
|
|
327
|
+
// Issue #2194: tell the user which URL we actually understood when we had to repair theirs.
|
|
328
|
+
const recoveryNotice = hasNotableRepair(parsed.repairs) ? t('telegram.url_recovered', { original: escapeMarkdown(makeSpecialCharsVisible(rawUrl)), used: escapeMarkdown(parsed.canonical), repairs: escapeMarkdown(formatUrlRepairs(parsed.repairs, { notableOnly: true })) }, { locale }) : null;
|
|
321
329
|
if (!allowedTypes.includes(parsed.type)) {
|
|
322
330
|
const allowedTypesStr = allowedTypes.map(t => (t === 'pull' ? 'pull request' : t)).join(', ');
|
|
323
331
|
const baseUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
|
|
324
332
|
const escapedUrl = escapeMarkdown(url),
|
|
325
333
|
escapedBaseUrl = escapeMarkdown(baseUrl); // Issue #1102: escape for Markdown
|
|
326
334
|
let error;
|
|
327
|
-
if (parsed.type === 'issues_list') error = t('telegram.url_issues_list_error', { url:
|
|
328
|
-
else if (parsed.type === 'pulls_list') error = t('telegram.url_pulls_list_error', { url:
|
|
335
|
+
if (parsed.type === 'issues_list') error = t('telegram.url_issues_list_error', { url: escapedBaseUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
|
|
336
|
+
else if (parsed.type === 'pulls_list') error = t('telegram.url_pulls_list_error', { url: escapedBaseUrl, example: `${escapedBaseUrl}/pull/1` }, { locale });
|
|
329
337
|
else if (parsed.type === 'repo') error = t('telegram.url_repo_error', { allowedTypes: allowedTypesStr, url: escapedUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
|
|
330
338
|
else error = t('telegram.url_must_be_type', { allowedTypes: allowedTypesStr, type: parsed.type.replace('_', ' ') }, { locale });
|
|
331
339
|
return { valid: false, error };
|
|
332
340
|
}
|
|
333
|
-
return { valid: true, parsed, normalizedUrl: url };
|
|
341
|
+
return { valid: true, parsed, normalizedUrl: url, recoveryNotice };
|
|
334
342
|
}
|
|
335
343
|
|
|
336
344
|
const executeAndUpdateMessage = buildExecuteAndUpdateMessage({ resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage });
|
|
@@ -539,6 +547,10 @@ async function handleSolveCommand(ctx) {
|
|
|
539
547
|
}
|
|
540
548
|
|
|
541
549
|
VERBOSE && console.log(`[VERBOSE] ${solveCommandDisplay} passed all checks, executing...`);
|
|
550
|
+
// Issue #2194: the incoming text is the only record of what the user actually
|
|
551
|
+
// typed, and the log for that issue did not contain it — so an invisible
|
|
552
|
+
// character in the URL was invisible in the log too. Reveal it here.
|
|
553
|
+
VERBOSE && console.log(`[VERBOSE] ${solveCommandDisplay} raw text: ${revealHiddenCharacters(ctx.message.text)}`);
|
|
542
554
|
const solveToolAlias = getSolveToolAliasFromText(ctx.message.text);
|
|
543
555
|
let userArgs = parseCommandArgs(ctx.message.text);
|
|
544
556
|
|
|
@@ -607,6 +619,8 @@ async function handleSolveCommand(ctx) {
|
|
|
607
619
|
await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
|
|
608
620
|
return;
|
|
609
621
|
}
|
|
622
|
+
// Issue #2194: the link needed repair, so show what we understood before we act on it.
|
|
623
|
+
if (validation.recoveryNotice) await safeReply(ctx, validation.recoveryNotice, { reply_to_message_id: ctx.message.message_id });
|
|
610
624
|
userArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
|
|
611
625
|
// Issue #2166: hand the spawned session the same canonical URL that is shown
|
|
612
626
|
// in the chat, so the echo and the actual work can never disagree.
|
|
@@ -826,6 +840,8 @@ async function handleHiveCommand(ctx) {
|
|
|
826
840
|
await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
|
|
827
841
|
return;
|
|
828
842
|
}
|
|
843
|
+
// Issue #2194: the link needed repair, so show what we understood before we act on it.
|
|
844
|
+
if (validation.recoveryNotice) await safeReply(ctx, validation.recoveryNotice, { reply_to_message_id: ctx.message.message_id });
|
|
829
845
|
// Normalize issues_list/pulls_list to base repo URL, or use cleaned URL
|
|
830
846
|
let normalizedArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
|
|
831
847
|
const p = validation.parsed;
|