github-search-cli 9.8.2 → 9.9.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 +129 -0
- package/README.md +65 -26
- package/dist/cli.js +898 -184
- package/docs/HOW-TO.md +254 -0
- package/package.json +2 -1
- package/release-notes/9.8.3.md +98 -0
- package/release-notes/9.9.0.md +193 -0
- package/src/cli.ts +13 -11
- package/src/errors.ts +56 -5
- package/src/help.ts +11 -0
- package/src/image-loader.ts +57 -2
- package/src/markdown-images.ts +93 -4
- package/src/markdown-view.ts +49 -20
- package/src/mcp-server.ts +34 -2
- package/src/search-state.ts +8 -10
- package/src/search.ts +701 -12
- package/src/session.ts +58 -2
- package/src/trending.ts +2 -6
- package/src/tui.ts +236 -50
- package/src/types.ts +11 -1
- package/src/update-check.ts +72 -6
- package/src/watch.ts +16 -11
package/src/search.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type {
|
|
9
9
|
ParsedQuery,
|
|
10
10
|
Qualifier,
|
|
11
|
+
QueryCorrection,
|
|
11
12
|
Repo,
|
|
12
13
|
SearchOptions,
|
|
13
14
|
SearchProvider,
|
|
@@ -15,7 +16,14 @@ import type {
|
|
|
15
16
|
SortStrategy,
|
|
16
17
|
CacheEntry,
|
|
17
18
|
} from "./types";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
NetworkError,
|
|
21
|
+
RateLimitError,
|
|
22
|
+
ParseError,
|
|
23
|
+
AuthError,
|
|
24
|
+
ForbiddenError,
|
|
25
|
+
BadQueryError,
|
|
26
|
+
} from "./errors";
|
|
19
27
|
import { parseTrendingHtml, type RawTrendingRepo } from "./trending-parser";
|
|
20
28
|
|
|
21
29
|
export interface Logger {
|
|
@@ -146,9 +154,11 @@ export function normalizeEnvelope(env: GitHubSearchEnvelope): Repo[] {
|
|
|
146
154
|
export const KNOWN_QUALIFIERS = [
|
|
147
155
|
"language",
|
|
148
156
|
"stars",
|
|
157
|
+
"forks",
|
|
149
158
|
"fork",
|
|
150
159
|
"archived",
|
|
151
160
|
"topic",
|
|
161
|
+
"topics",
|
|
152
162
|
"user",
|
|
153
163
|
"org",
|
|
154
164
|
"repo",
|
|
@@ -159,10 +169,123 @@ export const KNOWN_QUALIFIERS = [
|
|
|
159
169
|
"size",
|
|
160
170
|
"license",
|
|
161
171
|
"created",
|
|
172
|
+
"followers",
|
|
162
173
|
] as const;
|
|
163
174
|
|
|
164
175
|
export type KnownQualifier = (typeof KNOWN_QUALIFIERS)[number];
|
|
165
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The same set as `KNOWN_QUALIFIERS`, as a lookup for O(1) membership tests
|
|
179
|
+
* during validation.
|
|
180
|
+
*/
|
|
181
|
+
const KNOWN_QUALIFIER_SET: ReadonlySet<string> = new Set(KNOWN_QUALIFIERS);
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Common misspellings of the qualifiers ghfind knows, mapped to the correct
|
|
185
|
+
* key. Scoped deliberately to ghfind's own qualifier set: correcting a key we
|
|
186
|
+
* don't recognize could silently rewrite a valid `props.*`, `is:`, or `has:`
|
|
187
|
+
* qualifier into something else entirely.
|
|
188
|
+
*/
|
|
189
|
+
export const QUALIFIER_ALIASES: Record<string, string> = {
|
|
190
|
+
// language
|
|
191
|
+
langauge: "language",
|
|
192
|
+
lang: "language",
|
|
193
|
+
languge: "language",
|
|
194
|
+
// stars / forks
|
|
195
|
+
star: "stars",
|
|
196
|
+
strs: "stars",
|
|
197
|
+
forke: "forks",
|
|
198
|
+
// topic
|
|
199
|
+
topc: "topic",
|
|
200
|
+
tpics: "topic",
|
|
201
|
+
// user / org
|
|
202
|
+
usr: "user",
|
|
203
|
+
ogranization: "org",
|
|
204
|
+
orginzation: "org",
|
|
205
|
+
// date qualifiers
|
|
206
|
+
push: "pushed",
|
|
207
|
+
creat: "created",
|
|
208
|
+
updat: "updated",
|
|
209
|
+
// visibility
|
|
210
|
+
visiblity: "visibility",
|
|
211
|
+
visability: "visibility",
|
|
212
|
+
// license
|
|
213
|
+
licence: "license",
|
|
214
|
+
// size
|
|
215
|
+
siz: "size",
|
|
216
|
+
// archived
|
|
217
|
+
archive: "archived",
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Levenshtein edit distance, bounded to `max` so near-miss candidates don't
|
|
222
|
+
* dominate the cost. Returns `max + 1` as soon as the distance provably
|
|
223
|
+
* exceeds `max`.
|
|
224
|
+
*/
|
|
225
|
+
function editDistanceWithin(a: string, b: string, max: number): number {
|
|
226
|
+
if (a === b) return 0;
|
|
227
|
+
if (Math.abs(a.length - b.length) > max) return max + 1;
|
|
228
|
+
|
|
229
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
230
|
+
for (let i = 1; i <= a.length; i++) {
|
|
231
|
+
const curr = [i];
|
|
232
|
+
let rowMin = i;
|
|
233
|
+
for (let j = 1; j <= b.length; j++) {
|
|
234
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
235
|
+
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
236
|
+
if (curr[j] < rowMin) rowMin = curr[j];
|
|
237
|
+
}
|
|
238
|
+
if (rowMin > max) return max + 1;
|
|
239
|
+
prev = curr;
|
|
240
|
+
}
|
|
241
|
+
return prev[b.length];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Resolve a qualifier key to the canonical ghfind key, or return it unchanged.
|
|
246
|
+
*
|
|
247
|
+
* Two passes, in order of confidence:
|
|
248
|
+
* 1. An exact hit in `QUALIFIER_ALIASES` (`langauge` → `language`).
|
|
249
|
+
* 2. A near-miss within edit distance 2 of a known key (`starss` → `stars`).
|
|
250
|
+
*
|
|
251
|
+
* Anything else is returned as-is so unknown-but-valid qualifiers survive.
|
|
252
|
+
*/
|
|
253
|
+
export function resolveQualifierKey(key: string): string {
|
|
254
|
+
const k = key.toLowerCase();
|
|
255
|
+
// Check the exact set before the alias table: `fork`, `forks`, and `topic`
|
|
256
|
+
// are all real GitHub qualifiers, so a fuzzy pass must never collapse one
|
|
257
|
+
// into another (that would strip the numeric rule from `forks`).
|
|
258
|
+
if (KNOWN_QUALIFIER_SET.has(k)) return k;
|
|
259
|
+
|
|
260
|
+
const alias = QUALIFIER_ALIASES[k];
|
|
261
|
+
if (alias) return alias;
|
|
262
|
+
|
|
263
|
+
// Fuzzy correction is restricted to keys of 4+ characters. Short keys are
|
|
264
|
+
// exactly where a wrong guess does real damage: `is` sits one edit from the
|
|
265
|
+
// known `in`, so without this guard the valid qualifier `is:public` would be
|
|
266
|
+
// rewritten to `in:public` and then rejected as an invalid search field.
|
|
267
|
+
if (k.length < 4) return k;
|
|
268
|
+
|
|
269
|
+
const maxDistance = k.length <= 5 ? 1 : 2;
|
|
270
|
+
|
|
271
|
+
let best: string | undefined;
|
|
272
|
+
let bestDistance = maxDistance + 1;
|
|
273
|
+
let ties = 0;
|
|
274
|
+
for (const known of KNOWN_QUALIFIERS) {
|
|
275
|
+
const d = editDistanceWithin(k, known, maxDistance);
|
|
276
|
+
if (d < bestDistance) {
|
|
277
|
+
bestDistance = d;
|
|
278
|
+
best = known;
|
|
279
|
+
ties = 1;
|
|
280
|
+
if (d === 0) break;
|
|
281
|
+
} else if (d === bestDistance && best !== undefined) {
|
|
282
|
+
// Two candidates equally close — not enough signal to rewrite.
|
|
283
|
+
ties++;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return best !== undefined && ties === 1 ? best : k;
|
|
287
|
+
}
|
|
288
|
+
|
|
166
289
|
export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
|
|
167
290
|
l: ["language:"],
|
|
168
291
|
s: ["stars:", "size:"],
|
|
@@ -179,13 +302,279 @@ export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
|
|
|
179
302
|
};
|
|
180
303
|
|
|
181
304
|
export function suggestFor(input: string): string[] {
|
|
182
|
-
const trimmed = input.trim().toLowerCase();
|
|
305
|
+
const trimmed = input.trim().toLowerCase().replace(/:$/, "");
|
|
183
306
|
if (trimmed.length === 0) return [];
|
|
184
307
|
if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
|
|
185
308
|
return QUALIFIER_SUGGESTIONS[trimmed];
|
|
186
309
|
}
|
|
187
|
-
|
|
188
|
-
|
|
310
|
+
|
|
311
|
+
// An exact alias (`langauge`) or a unique near-miss (`starss`) is the
|
|
312
|
+
// highest-confidence completion, so it leads the list.
|
|
313
|
+
const resolved = resolveQualifierKey(trimmed);
|
|
314
|
+
const aliasHit =
|
|
315
|
+
resolved !== trimmed && KNOWN_QUALIFIER_SET.has(resolved)
|
|
316
|
+
? [`${resolved}:`]
|
|
317
|
+
: [];
|
|
318
|
+
|
|
319
|
+
// Prefix matches, minus the alias hit so it isn't duplicated.
|
|
320
|
+
const matches = KNOWN_QUALIFIERS.filter(
|
|
321
|
+
(k) => k.startsWith(trimmed) && !aliasHit.includes(`${k}:`),
|
|
322
|
+
).map((k) => `${k}:`);
|
|
323
|
+
|
|
324
|
+
return [...aliasHit, ...matches];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── Qualifier value rules ──────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Qualifier keys whose value is a count. These accept comparison operators,
|
|
331
|
+
* ranges, and `k`/`m` magnitude sugar (`stars:10k` → `stars:>=10000`).
|
|
332
|
+
* Per GitHub docs: forks, size, stars, topics, followers.
|
|
333
|
+
*/
|
|
334
|
+
const NUMERIC_QUALIFIERS = new Set([
|
|
335
|
+
"forks",
|
|
336
|
+
"size",
|
|
337
|
+
"stars",
|
|
338
|
+
"topics",
|
|
339
|
+
"followers",
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Qualifier keys whose value is `true`/`false`. `fork` is handled separately
|
|
344
|
+
* because GitHub additionally accepts `only` for it (per docs: "add
|
|
345
|
+
* `fork:true` or `fork:only`"). `mirror:` and `template:` are valid GitHub
|
|
346
|
+
* qualifiers but not in `KNOWN_QUALIFIERS`, so they pass through unvalidated
|
|
347
|
+
* by design.
|
|
348
|
+
*/
|
|
349
|
+
const BOOLEAN_QUALIFIERS = new Set(["archived"]);
|
|
350
|
+
|
|
351
|
+
/** Accepted values for the `visibility:` qualifier. */
|
|
352
|
+
const VISIBILITY_VALUES = new Set(["public", "private", "internal"]);
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Accepted fields for the `in:` qualifier. GitHub accepts a comma-separated
|
|
356
|
+
* list (documented form: `in:name,description`).
|
|
357
|
+
*/
|
|
358
|
+
const IN_FIELDS = new Set([
|
|
359
|
+
"name",
|
|
360
|
+
"description",
|
|
361
|
+
"topics",
|
|
362
|
+
"readme",
|
|
363
|
+
"comments",
|
|
364
|
+
]);
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Qualifier keys whose value is an ISO8601 date, optionally prefixed by a
|
|
368
|
+
* comparison operator or written as a `a..b` range.
|
|
369
|
+
* Per GitHub docs: created, pushed, updated.
|
|
370
|
+
*/
|
|
371
|
+
const DATE_QUALIFIERS = new Set(["created", "pushed", "updated"]);
|
|
372
|
+
|
|
373
|
+
/** Every comparison operator GitHub's search syntax accepts on a value. */
|
|
374
|
+
const COMPARISON_OPERATORS = [">=", "<=", ">", "<"] as const;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Strip a token out of a URL before logging. Guarded against the empty
|
|
378
|
+
* string: `url.replace("", …)` inserts the replacement between every
|
|
379
|
+
* character, which would mangle the whole URL whenever no token is in play.
|
|
380
|
+
*/
|
|
381
|
+
function redactToken(url: string, token: string | undefined): string {
|
|
382
|
+
return token ? url.replace(token, "<token>") : url;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Strip a leading comparison operator, returning it alongside the rest. */
|
|
386
|
+
function splitComparison(value: string): { op: string; rest: string } {
|
|
387
|
+
for (const op of COMPARISON_OPERATORS) {
|
|
388
|
+
// `>=` must be tested before `>` or the value `>=5` parses as op `>`,
|
|
389
|
+
// rest `=5` — which then fails the numeric rule.
|
|
390
|
+
if (value.startsWith(op)) {
|
|
391
|
+
return { op, rest: value.slice(op.length) };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return { op: "", rest: value };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Parse a magnitude-suffixed count: `10`, `10k`, `1.5m`, `2_000`.
|
|
399
|
+
* Returns null when the input is not a well-formed count.
|
|
400
|
+
*
|
|
401
|
+
* Deliberately does not accept a bare float with a fractional part
|
|
402
|
+
* (`stars:1.5`) — only fractional values *with* a k/m suffix, since
|
|
403
|
+
* `stars:1.5` is a user mistake rather than a quantity.
|
|
404
|
+
*/
|
|
405
|
+
export function parseCount(raw: string): number | null {
|
|
406
|
+
const trimmed = raw.trim().replace(/_/g, "");
|
|
407
|
+
if (trimmed === "") return null;
|
|
408
|
+
|
|
409
|
+
const match = /^(\d+(?:\.\d+)?)([km])?$/i.exec(trimmed);
|
|
410
|
+
if (!match) return null;
|
|
411
|
+
|
|
412
|
+
// A fraction is only meaningful with a magnitude suffix (`1.5m`); a bare
|
|
413
|
+
// `1.5` is a typo that would otherwise silently floor to 1.
|
|
414
|
+
if (!match[2] && match[1].includes(".")) return null;
|
|
415
|
+
|
|
416
|
+
const base = Number.parseFloat(match[1]);
|
|
417
|
+
if (!Number.isFinite(base)) return null;
|
|
418
|
+
|
|
419
|
+
const suffix = match[2]?.toLowerCase();
|
|
420
|
+
const multiplier = suffix === "k" ? 1_000 : suffix === "m" ? 1_000_000 : 1;
|
|
421
|
+
return Math.floor(base * multiplier);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Validate an ISO8601 date as GitHub accepts it: `YYYY-MM-DD` optionally
|
|
426
|
+
* followed by `THH:MM:SS` and a timezone offset. Rejects calendar-invalid
|
|
427
|
+
* dates like `2024-13-45`, which `Date.parse` would otherwise roll over.
|
|
428
|
+
*/
|
|
429
|
+
export function isValidISODate(raw: string): boolean {
|
|
430
|
+
const trimmed = raw.trim();
|
|
431
|
+
const m =
|
|
432
|
+
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(
|
|
433
|
+
trimmed,
|
|
434
|
+
);
|
|
435
|
+
if (!m) return false;
|
|
436
|
+
|
|
437
|
+
const [, y, mo, d, h = "0", mi = "0", s = "0"] = m;
|
|
438
|
+
const year = Number(y);
|
|
439
|
+
const month = Number(mo);
|
|
440
|
+
const day = Number(d);
|
|
441
|
+
if (month < 1 || month > 12) return false;
|
|
442
|
+
if (day < 1 || day > 31) return false;
|
|
443
|
+
if (Number(h) > 23 || Number(mi) > 59 || Number(s) > 59) return false;
|
|
444
|
+
|
|
445
|
+
// Reject days past the end of the month (e.g. 2024-02-31, 2023-02-29).
|
|
446
|
+
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
447
|
+
return day <= daysInMonth;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Rewrite magnitude sugar into GitHub's comparison-operator form.
|
|
452
|
+
* `stars:10k` → `stars:>=10000`; `stars:1k..5k` → `stars:1000..5000`.
|
|
453
|
+
*
|
|
454
|
+
* A plain integer is left alone: GitHub reads `stars:100` as *exactly* 100,
|
|
455
|
+
* so rewriting it to `>=100` would silently change the question being asked.
|
|
456
|
+
* Only the `k`/`m` sugar — which GitHub does not understand at all — gets an
|
|
457
|
+
* operator, defaulting to `>=` because "10k" almost always means "10k+".
|
|
458
|
+
*/
|
|
459
|
+
export function normalizeQualifierValue(key: string, value: string): string {
|
|
460
|
+
const normalizedKey = key.toLowerCase();
|
|
461
|
+
if (!NUMERIC_QUALIFIERS.has(normalizedKey)) return value;
|
|
462
|
+
if (value === "") return value;
|
|
463
|
+
|
|
464
|
+
const { op, rest } = splitComparison(value);
|
|
465
|
+
const rangeIdx = rest.indexOf("..");
|
|
466
|
+
if (rangeIdx > 0) {
|
|
467
|
+
const lo = parseCount(rest.slice(0, rangeIdx));
|
|
468
|
+
const hi = parseCount(rest.slice(rangeIdx + 2));
|
|
469
|
+
if (lo === null || hi === null) return value;
|
|
470
|
+
return `${op}${lo}..${hi}`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// A bare integer is GitHub's exact-match form — leave it alone. Only the
|
|
474
|
+
// k/m sugar, which GitHub does not understand at all, gets rewritten.
|
|
475
|
+
if (!/[km]$/i.test(rest.trim())) return value;
|
|
476
|
+
|
|
477
|
+
const count = parseCount(rest);
|
|
478
|
+
if (count === null) return value;
|
|
479
|
+
// `>=10k` keeps its operator; a bare `10k` defaults to `>=`, since "10k"
|
|
480
|
+
// almost always means "10k or more".
|
|
481
|
+
return `${op || ">="}${count}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Check one qualifier's value against its key's rule and return a
|
|
486
|
+
* human-readable problem, or null when the value is acceptable.
|
|
487
|
+
*
|
|
488
|
+
* Only keys in `KNOWN_QUALIFIERS` are checked. An unknown key (e.g.
|
|
489
|
+
* `props.environment`, `is:`, `has:`) is passed through untouched — ghfind
|
|
490
|
+
* does not own GitHub's full qualifier set, and rejecting keys it hasn't
|
|
491
|
+
* heard of would break valid queries.
|
|
492
|
+
*/
|
|
493
|
+
export function validateQualifierValue(
|
|
494
|
+
key: string,
|
|
495
|
+
value: string,
|
|
496
|
+
): string | null {
|
|
497
|
+
const k = key.toLowerCase();
|
|
498
|
+
if (!KNOWN_QUALIFIER_SET.has(k)) return null;
|
|
499
|
+
|
|
500
|
+
if (value.trim() === "") {
|
|
501
|
+
return `"${key}:" needs a value (e.g. ${key}:<something>)`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (NUMERIC_QUALIFIERS.has(k)) {
|
|
505
|
+
const { rest } = splitComparison(value.trim());
|
|
506
|
+
const rangeIdx = rest.indexOf("..");
|
|
507
|
+
if (rangeIdx > 0) {
|
|
508
|
+
const lo = parseCount(rest.slice(0, rangeIdx));
|
|
509
|
+
const hi = parseCount(rest.slice(rangeIdx + 2));
|
|
510
|
+
if (lo === null || hi === null) {
|
|
511
|
+
return `"${key}:${value}" is not a valid range — use ${k}:100..500 or ${k}:>=100`;
|
|
512
|
+
}
|
|
513
|
+
if (lo > hi) {
|
|
514
|
+
return `"${key}:${value}" has a reversed range (${lo} > ${hi})`;
|
|
515
|
+
}
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
if (parseCount(rest) === null) {
|
|
519
|
+
return `"${key}:${value}" is not a number — use ${k}:100, ${k}:>=100, or ${k}:1k..10k`;
|
|
520
|
+
}
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (k === "fork") {
|
|
525
|
+
const v = value.trim().toLowerCase();
|
|
526
|
+
if (v !== "true" && v !== "false" && v !== "only") {
|
|
527
|
+
return `"fork:${value}" is not valid — use fork:true, fork:false, or fork:only`;
|
|
528
|
+
}
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (BOOLEAN_QUALIFIERS.has(k)) {
|
|
533
|
+
const v = value.trim().toLowerCase();
|
|
534
|
+
if (v !== "true" && v !== "false") {
|
|
535
|
+
return `"${key}:${value}" is not valid — use ${k}:true or ${k}:false`;
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (k === "visibility") {
|
|
541
|
+
const v = value.trim().toLowerCase();
|
|
542
|
+
if (!VISIBILITY_VALUES.has(v)) {
|
|
543
|
+
return `"visibility:${value}" is not valid — use visibility:public, visibility:private, or visibility:internal`;
|
|
544
|
+
}
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (k === "in") {
|
|
549
|
+
const parts = value.split(",").map((p) => p.trim().toLowerCase());
|
|
550
|
+
if (parts.some((p) => !IN_FIELDS.has(p))) {
|
|
551
|
+
const bad = parts.find((p) => !IN_FIELDS.has(p));
|
|
552
|
+
return `"in:${value}" is not valid — ${bad ? `"${bad}" is not a ` : ""}searchable field. Try in:name, in:description, in:topics, or in:readme`;
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
if (DATE_QUALIFIERS.has(k)) {
|
|
558
|
+
const trimmed = value.trim();
|
|
559
|
+
const { rest } = splitComparison(trimmed);
|
|
560
|
+
const rangeIdx = rest.indexOf("..");
|
|
561
|
+
if (rangeIdx > 0) {
|
|
562
|
+
const lo = rest.slice(0, rangeIdx);
|
|
563
|
+
const hi = rest.slice(rangeIdx + 2);
|
|
564
|
+
if (!isValidISODate(lo) || !isValidISODate(hi)) {
|
|
565
|
+
return `"${key}:${value}" is not a valid date range — use ${k}:2020-01-01..2024-01-01`;
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
if (!isValidISODate(rest)) {
|
|
570
|
+
return `"${key}:${value}" is not a valid date — use ${k}:2024-01-01 (optionally with >, >=, <, <=)`;
|
|
571
|
+
}
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Keys whose value is an opaque identifier (user, org, repo, topic,
|
|
576
|
+
// license) — GitHub owns what counts as valid, so nothing to check here.
|
|
577
|
+
return null;
|
|
189
578
|
}
|
|
190
579
|
|
|
191
580
|
export function tokenize(input: string): string[] {
|
|
@@ -218,25 +607,62 @@ export function tokenize(input: string): string[] {
|
|
|
218
607
|
}
|
|
219
608
|
return tokens;
|
|
220
609
|
}
|
|
610
|
+
export type { ParsedQuery, QueryCorrection } from "./types";
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* A no-op query, used where a search adapter needs a placeholder (the trending
|
|
614
|
+
* adapter ignores the query entirely and reads `options.trendingSince`).
|
|
615
|
+
*/
|
|
616
|
+
export const EMPTY_QUERY: ParsedQuery = {
|
|
617
|
+
keywords: [],
|
|
618
|
+
qualifiers: [],
|
|
619
|
+
raw: "",
|
|
620
|
+
corrections: [],
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
/** The placeholder the trending surfaces pass, preserving the old `raw` value. */
|
|
624
|
+
export function trendingQuery(): ParsedQuery {
|
|
625
|
+
return { ...EMPTY_QUERY, raw: "trending" };
|
|
626
|
+
}
|
|
627
|
+
|
|
221
628
|
export function parseQuery(raw: string): ParsedQuery {
|
|
222
629
|
const trimmed = raw.trim();
|
|
223
630
|
if (trimmed === "") {
|
|
224
|
-
return { keywords: [], qualifiers: [], raw };
|
|
631
|
+
return { keywords: [], qualifiers: [], raw, corrections: [] };
|
|
225
632
|
}
|
|
226
633
|
|
|
227
634
|
const keywords: string[] = [];
|
|
228
635
|
const qualifiers: Qualifier[] = [];
|
|
636
|
+
const corrections: QueryCorrection[] = [];
|
|
229
637
|
|
|
230
638
|
for (const token of tokenize(trimmed)) {
|
|
231
639
|
const qualifier = matchQualifier(token);
|
|
232
640
|
if (qualifier) {
|
|
233
|
-
|
|
641
|
+
// Correct the key in place (typo → canonical) and normalize the value
|
|
642
|
+
// (magnitude sugar). Rewriting the qualifier object rather than
|
|
643
|
+
// re-parsing a corrected copy of the query string keeps keyword order
|
|
644
|
+
// and `raw` exactly as the user typed them.
|
|
645
|
+
const resolvedKey = resolveQualifierKey(qualifier.key);
|
|
646
|
+
const normalizedValue = normalizeQualifierValue(
|
|
647
|
+
resolvedKey,
|
|
648
|
+
qualifier.value,
|
|
649
|
+
);
|
|
650
|
+
const original = `${qualifier.negated ? "-" : ""}${qualifier.key}:${qualifier.value}`;
|
|
651
|
+
const corrected = `${qualifier.negated ? "-" : ""}${resolvedKey}:${normalizedValue}`;
|
|
652
|
+
if (original !== corrected) {
|
|
653
|
+
corrections.push({ from: original, to: corrected });
|
|
654
|
+
}
|
|
655
|
+
qualifiers.push({
|
|
656
|
+
key: resolvedKey,
|
|
657
|
+
value: normalizedValue,
|
|
658
|
+
negated: qualifier.negated,
|
|
659
|
+
});
|
|
234
660
|
} else {
|
|
235
661
|
keywords.push(token);
|
|
236
662
|
}
|
|
237
663
|
}
|
|
238
664
|
|
|
239
|
-
return { keywords, qualifiers, raw: trimmed };
|
|
665
|
+
return { keywords, qualifiers, raw: trimmed, corrections };
|
|
240
666
|
}
|
|
241
667
|
|
|
242
668
|
function matchQualifier(token: string): Qualifier | null {
|
|
@@ -246,8 +672,11 @@ function matchQualifier(token: string): Qualifier | null {
|
|
|
246
672
|
if (idx <= 0) return null;
|
|
247
673
|
const key = body.slice(0, idx).toLowerCase();
|
|
248
674
|
let value = body.slice(idx + 1);
|
|
249
|
-
|
|
250
|
-
|
|
675
|
+
// An empty value (`stars:`) is kept as a qualifier so validation can report
|
|
676
|
+
// "needs a value" instead of silently degrading it into a literal keyword.
|
|
677
|
+
// Custom-property qualifiers (`props.environment:prod`) contain a dot, so
|
|
678
|
+
// the key charset allows one and lets validation ignore what it doesn't own.
|
|
679
|
+
if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/i.test(key)) return null;
|
|
251
680
|
if (value.startsWith("//")) return null;
|
|
252
681
|
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
253
682
|
value = value.slice(1, -1);
|
|
@@ -297,12 +726,235 @@ export function applyFlagFilters(
|
|
|
297
726
|
return { ...query, qualifiers };
|
|
298
727
|
}
|
|
299
728
|
|
|
729
|
+
/**
|
|
730
|
+
* Rebuild a query string with one qualifier removed, for relaxation hints.
|
|
731
|
+
* Returns null when removing it would leave nothing to search for.
|
|
732
|
+
*/
|
|
733
|
+
function withoutQualifier(query: ParsedQuery, index: number): string | null {
|
|
734
|
+
const remaining = query.qualifiers.filter((_, i) => i !== index);
|
|
735
|
+
const parts = [...query.keywords];
|
|
736
|
+
for (const q of remaining) {
|
|
737
|
+
const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
|
|
738
|
+
const value = needsQuote ? `"${q.value}"` : q.value;
|
|
739
|
+
parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
|
|
740
|
+
}
|
|
741
|
+
const text = parts.join(" ").trim();
|
|
742
|
+
return text === "" ? null : text;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Validate a parsed query before it costs an API call.
|
|
747
|
+
*
|
|
748
|
+
* Throws `BadQueryError` with a specific message (and a concrete corrected
|
|
749
|
+
* query where one exists) instead of letting a malformed qualifier reach
|
|
750
|
+
* GitHub, whose 422 surfaces to the user as an opaque error.
|
|
751
|
+
*/
|
|
300
752
|
export function validateQuery(query: ParsedQuery): void {
|
|
301
753
|
const has = (key: string) =>
|
|
302
754
|
query.qualifiers.some((q) => q.key === key && !q.negated);
|
|
755
|
+
|
|
756
|
+
// `private:` is GitHub's legacy spelling of `visibility:private`.
|
|
303
757
|
if (has("visibility") && has("private")) {
|
|
304
|
-
throw new
|
|
758
|
+
throw new BadQueryError(
|
|
759
|
+
"Cannot combine `visibility:` and `private:` filters.",
|
|
760
|
+
`${
|
|
761
|
+
query.qualifiers
|
|
762
|
+
.filter((q) => q.key === "private" && !q.negated)
|
|
763
|
+
.map((q) => withoutQualifier(query, query.qualifiers.indexOf(q)))
|
|
764
|
+
.find((v): v is string => v !== null) ?? query.raw
|
|
765
|
+
}`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Report the first bad qualifier, with the rest of the query as a retry.
|
|
770
|
+
for (let i = 0; i < query.qualifiers.length; i++) {
|
|
771
|
+
const q = query.qualifiers[i];
|
|
772
|
+
const problem = validateQualifierValue(q.key, q.value);
|
|
773
|
+
if (problem) {
|
|
774
|
+
throw new BadQueryError(problem, withoutQualifier(query, i) ?? undefined);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// ─── Zero-result relaxation ─────────────────────────────────────────
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* How likely a qualifier is to be the reason a search returned nothing.
|
|
783
|
+
* Higher = drop it first.
|
|
784
|
+
*
|
|
785
|
+
* Ordered by how sharply the filter narrows GitHub's corpus:
|
|
786
|
+
* - `stars`/`size` thresholds are absolute, so a high bar empties the result
|
|
787
|
+
* set outright (`stars:>=500000` matches almost nothing).
|
|
788
|
+
* - a narrow date window is similarly absolute.
|
|
789
|
+
* - `user`/`org` restricts to one account's repos.
|
|
790
|
+
* - a plain `language` still matches every repo in that language, so it is
|
|
791
|
+
* the last thing worth dropping.
|
|
792
|
+
*/
|
|
793
|
+
const OVER_CONSTRAINT_WEIGHT: Record<string, number> = {
|
|
794
|
+
size: 100,
|
|
795
|
+
stars: 95,
|
|
796
|
+
forks: 80,
|
|
797
|
+
created: 75,
|
|
798
|
+
pushed: 70,
|
|
799
|
+
updated: 70,
|
|
800
|
+
visibility: 65,
|
|
801
|
+
repo: 60,
|
|
802
|
+
user: 55,
|
|
803
|
+
org: 50,
|
|
804
|
+
topic: 45,
|
|
805
|
+
in: 30,
|
|
806
|
+
license: 25,
|
|
807
|
+
language: 20,
|
|
808
|
+
fork: 15,
|
|
809
|
+
archived: 10,
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* A concrete way to loosen a query that returned nothing, e.g.
|
|
814
|
+
* `rust stars:>=50000` → `Try: rust stars:>=1000`.
|
|
815
|
+
*/
|
|
816
|
+
export interface Relaxation {
|
|
817
|
+
/** What to suggest to the user. */
|
|
818
|
+
suggestion: string;
|
|
819
|
+
/** The qualifier being loosened. */
|
|
820
|
+
qualifier: string;
|
|
821
|
+
/** Higher means "try this one first". */
|
|
822
|
+
confidence: number;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** A looser bound for an over-tight numeric threshold. */
|
|
826
|
+
function loosenNumericBound(value: string): string | null {
|
|
827
|
+
const { op, rest } = splitComparison(value.trim());
|
|
828
|
+
const count = parseCount(rest);
|
|
829
|
+
if (count === null || count === 0) return null;
|
|
830
|
+
// Step down by an order of magnitude, but never below 1.
|
|
831
|
+
const loosened = Math.max(1, Math.floor(count / 10));
|
|
832
|
+
return `${op || ">="}${loosened}`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Widen a date bound by two years in the direction that admits more repos.
|
|
837
|
+
* `pushed:>2024-01-01` → `pushed:>2022-01-01`.
|
|
838
|
+
*/
|
|
839
|
+
function loosenDateBound(value: string): { op: string; date: string } | null {
|
|
840
|
+
const { op, rest } = splitComparison(value.trim());
|
|
841
|
+
if (!isValidISODate(rest)) return null;
|
|
842
|
+
|
|
843
|
+
const year = Number.parseInt(rest.slice(0, 4), 10);
|
|
844
|
+
if (!Number.isFinite(year)) return null;
|
|
845
|
+
|
|
846
|
+
// A `>` bound moves earlier (more repos qualify); a `<` bound moves later.
|
|
847
|
+
const shifted =
|
|
848
|
+
op === "<" || op === "<=" ? year + 2 : Math.max(1970, year - 2);
|
|
849
|
+
return { op, date: `${shifted}${rest.slice(4)}` };
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Rank concrete relaxations for a query that returned zero results, most
|
|
854
|
+
* promising first.
|
|
855
|
+
*
|
|
856
|
+
* Each suggestion rewrites one qualifier rather than stripping it outright,
|
|
857
|
+
* so a retry is likely to return *some* results instead of a different
|
|
858
|
+
* question. An empty array means nothing sensible can be loosened — the caller
|
|
859
|
+
* should fall back to its generic advice.
|
|
860
|
+
*/
|
|
861
|
+
export function suggestRelaxations(
|
|
862
|
+
query: ParsedQuery,
|
|
863
|
+
limit = 3,
|
|
864
|
+
): Relaxation[] {
|
|
865
|
+
const out: Relaxation[] = [];
|
|
866
|
+
|
|
867
|
+
for (let i = 0; i < query.qualifiers.length; i++) {
|
|
868
|
+
const q = query.qualifiers[i];
|
|
869
|
+
const key = q.key.toLowerCase();
|
|
870
|
+
// A negated filter (`-language:Rust`) only ever widens a search, so it
|
|
871
|
+
// cannot be the reason a query came back empty.
|
|
872
|
+
if (q.negated) continue;
|
|
873
|
+
|
|
874
|
+
const weight = OVER_CONSTRAINT_WEIGHT[key] ?? 40;
|
|
875
|
+
const emit = (relaxed: string, qualifier: string) => {
|
|
876
|
+
const rest = query.qualifiers
|
|
877
|
+
.filter((_, j) => j !== i)
|
|
878
|
+
.map((other) => {
|
|
879
|
+
const needsQuote =
|
|
880
|
+
other.value.includes(" ") && !other.value.startsWith('"');
|
|
881
|
+
const value = needsQuote ? `"${other.value}"` : other.value;
|
|
882
|
+
return `${other.negated ? "-" : ""}${other.key}:${value}`;
|
|
883
|
+
});
|
|
884
|
+
const text = [...query.keywords, ...rest, relaxed].join(" ").trim();
|
|
885
|
+
out.push({ suggestion: text, qualifier, confidence: weight });
|
|
886
|
+
};
|
|
887
|
+
|
|
888
|
+
if (NUMERIC_QUALIFIERS.has(key) || key === "followers") {
|
|
889
|
+
const rangeIdx = q.value.indexOf("..");
|
|
890
|
+
if (rangeIdx > 0) {
|
|
891
|
+
// A range with an unreachable upper bound: widen the ceiling.
|
|
892
|
+
const hi = parseCount(q.value.slice(rangeIdx + 2));
|
|
893
|
+
if (hi !== null) {
|
|
894
|
+
const lo = q.value.slice(0, rangeIdx);
|
|
895
|
+
emit(`${key}:${lo}..${Math.floor(hi / 10) || hi * 2}`, key);
|
|
896
|
+
}
|
|
897
|
+
} else {
|
|
898
|
+
const looser = loosenNumericBound(q.value);
|
|
899
|
+
if (looser) emit(`${key}:${looser}`, key);
|
|
900
|
+
else {
|
|
901
|
+
// Already at the floor (`stars:>=1`) — try widening a range instead.
|
|
902
|
+
const dropped = withoutQualifier(query, i);
|
|
903
|
+
if (dropped) {
|
|
904
|
+
out.push({
|
|
905
|
+
suggestion: dropped,
|
|
906
|
+
qualifier: key,
|
|
907
|
+
confidence: weight - 30,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (DATE_QUALIFIERS.has(key)) {
|
|
916
|
+
const loosened = loosenDateBound(q.value);
|
|
917
|
+
if (loosened) {
|
|
918
|
+
emit(`${key}:${loosened.op || ">="}${loosened.date}`, key);
|
|
919
|
+
} else {
|
|
920
|
+
const dropped = withoutQualifier(query, i);
|
|
921
|
+
if (dropped) {
|
|
922
|
+
out.push({
|
|
923
|
+
suggestion: dropped,
|
|
924
|
+
qualifier: key,
|
|
925
|
+
confidence: weight - 30,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// Everything else (language, org, topic, …): dropping the qualifier is
|
|
933
|
+
// the only meaningful relaxation.
|
|
934
|
+
const dropped = withoutQualifier(query, i);
|
|
935
|
+
if (dropped) {
|
|
936
|
+
out.push({
|
|
937
|
+
suggestion: dropped,
|
|
938
|
+
qualifier: key,
|
|
939
|
+
confidence: weight - 30,
|
|
940
|
+
});
|
|
941
|
+
}
|
|
305
942
|
}
|
|
943
|
+
|
|
944
|
+
// Deduplicate (two qualifiers can relax to the same text), then rank.
|
|
945
|
+
const seen = new Set<string>();
|
|
946
|
+
const unique = out.filter((r) => {
|
|
947
|
+
if (seen.has(r.suggestion)) return false;
|
|
948
|
+
seen.add(r.suggestion);
|
|
949
|
+
return true;
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
unique.sort((a, b) => b.confidence - a.confidence);
|
|
953
|
+
return unique.slice(0, limit).map((r) => ({
|
|
954
|
+
...r,
|
|
955
|
+
// Clamp so a "drop the qualifier" hint never reports a negative score.
|
|
956
|
+
confidence: Math.max(0, r.confidence),
|
|
957
|
+
}));
|
|
306
958
|
}
|
|
307
959
|
|
|
308
960
|
export function buildGitHubQuery(query: ParsedQuery): string {
|
|
@@ -434,6 +1086,12 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
434
1086
|
const remainingStr = res.headers.get("x-ratelimit-remaining");
|
|
435
1087
|
rateLimitRemaining =
|
|
436
1088
|
remainingStr !== null ? Number(remainingStr) : undefined;
|
|
1089
|
+
if (process.env.GHFIND_LOG) {
|
|
1090
|
+
this.logger.debug(
|
|
1091
|
+
`[github] ${res.status} remaining=${remainingStr ?? "none"} ` +
|
|
1092
|
+
`url=${redactToken(pagedUrl, token)}`,
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
437
1095
|
|
|
438
1096
|
if (res.status === 403) {
|
|
439
1097
|
if (remainingStr === "0" || remainingStr === null) {
|
|
@@ -445,10 +1103,37 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
445
1103
|
throw new RateLimitError(!!token);
|
|
446
1104
|
}
|
|
447
1105
|
const body = await res.text().catch(() => "");
|
|
1106
|
+
// These three 403s are indistinguishable from the status code
|
|
1107
|
+
// alone, and each needs a different fix: SSO enforcement, a
|
|
1108
|
+
// secondary/abuse rate limit, or a token blocked by org policy.
|
|
1109
|
+
// Log the headers that tell them apart.
|
|
448
1110
|
this.logger.error(
|
|
449
|
-
`[github] non-rate-limit 403:
|
|
1111
|
+
`[github] non-rate-limit 403: remaining=${remainingStr} ` +
|
|
1112
|
+
`retry-after=${res.headers.get("retry-after") ?? "none"} ` +
|
|
1113
|
+
`x-ratelimit-reset=${res.headers.get("x-ratelimit-reset") ?? "none"} ` +
|
|
1114
|
+
`authed=${token ? "yes" : "no"} url=${redactToken(pagedUrl, token)}`,
|
|
450
1115
|
);
|
|
451
|
-
|
|
1116
|
+
this.logger.error(`[github] 403 body: ${body.slice(0, 400)}`);
|
|
1117
|
+
// Surface GitHub's own explanation ("Resource not accessible by
|
|
1118
|
+
// integration", "secondary rate limit", …) rather than asserting
|
|
1119
|
+
// a cause we cannot actually know.
|
|
1120
|
+
let reason = "no reason given";
|
|
1121
|
+
try {
|
|
1122
|
+
const parsed = JSON.parse(body) as { message?: unknown };
|
|
1123
|
+
if (typeof parsed.message === "string" && parsed.message) {
|
|
1124
|
+
reason = parsed.message;
|
|
1125
|
+
}
|
|
1126
|
+
} catch {
|
|
1127
|
+
// Non-JSON body (HTML/CAPTCHA); keep the generic reason.
|
|
1128
|
+
}
|
|
1129
|
+
throw new ForbiddenError(reason);
|
|
1130
|
+
}
|
|
1131
|
+
if (res.status === 401) {
|
|
1132
|
+
const body = await res.text().catch(() => "");
|
|
1133
|
+
this.logger.error(
|
|
1134
|
+
`[github] auth rejected (401): ${body.slice(0, 200)}`,
|
|
1135
|
+
);
|
|
1136
|
+
throw new AuthError();
|
|
452
1137
|
}
|
|
453
1138
|
if (!res.ok) {
|
|
454
1139
|
const body = await res.text().catch(() => "");
|
|
@@ -492,6 +1177,10 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
492
1177
|
lastErr = undefined;
|
|
493
1178
|
break;
|
|
494
1179
|
} catch (err) {
|
|
1180
|
+
// RateLimitError must propagate: swallowing it turns the failure
|
|
1181
|
+
// into a silent zero-result "success" and the user never learns
|
|
1182
|
+
// why their search died.
|
|
1183
|
+
if (err instanceof RateLimitError) throw err;
|
|
495
1184
|
lastErr = err as Error;
|
|
496
1185
|
if (rateLimited) break;
|
|
497
1186
|
}
|