github-search-cli 9.8.3 → 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 +72 -0
- package/README.md +32 -10
- package/dist/cli.js +543 -67
- package/docs/HOW-TO.md +254 -0
- package/package.json +2 -1
- package/release-notes/9.9.0.md +193 -0
- package/src/cli.ts +13 -11
- package/src/errors.ts +35 -11
- package/src/mcp-server.ts +18 -2
- package/src/search-state.ts +8 -10
- package/src/search.ts +683 -11
- package/src/trending.ts +2 -6
- package/src/tui.ts +55 -17
- 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,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
ParseError,
|
|
22
23
|
AuthError,
|
|
23
24
|
ForbiddenError,
|
|
25
|
+
BadQueryError,
|
|
24
26
|
} from "./errors";
|
|
25
27
|
import { parseTrendingHtml, type RawTrendingRepo } from "./trending-parser";
|
|
26
28
|
|
|
@@ -152,9 +154,11 @@ export function normalizeEnvelope(env: GitHubSearchEnvelope): Repo[] {
|
|
|
152
154
|
export const KNOWN_QUALIFIERS = [
|
|
153
155
|
"language",
|
|
154
156
|
"stars",
|
|
157
|
+
"forks",
|
|
155
158
|
"fork",
|
|
156
159
|
"archived",
|
|
157
160
|
"topic",
|
|
161
|
+
"topics",
|
|
158
162
|
"user",
|
|
159
163
|
"org",
|
|
160
164
|
"repo",
|
|
@@ -165,10 +169,123 @@ export const KNOWN_QUALIFIERS = [
|
|
|
165
169
|
"size",
|
|
166
170
|
"license",
|
|
167
171
|
"created",
|
|
172
|
+
"followers",
|
|
168
173
|
] as const;
|
|
169
174
|
|
|
170
175
|
export type KnownQualifier = (typeof KNOWN_QUALIFIERS)[number];
|
|
171
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
|
+
|
|
172
289
|
export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
|
|
173
290
|
l: ["language:"],
|
|
174
291
|
s: ["stars:", "size:"],
|
|
@@ -185,13 +302,279 @@ export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
|
|
|
185
302
|
};
|
|
186
303
|
|
|
187
304
|
export function suggestFor(input: string): string[] {
|
|
188
|
-
const trimmed = input.trim().toLowerCase();
|
|
305
|
+
const trimmed = input.trim().toLowerCase().replace(/:$/, "");
|
|
189
306
|
if (trimmed.length === 0) return [];
|
|
190
307
|
if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
|
|
191
308
|
return QUALIFIER_SUGGESTIONS[trimmed];
|
|
192
309
|
}
|
|
193
|
-
|
|
194
|
-
|
|
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;
|
|
195
578
|
}
|
|
196
579
|
|
|
197
580
|
export function tokenize(input: string): string[] {
|
|
@@ -224,25 +607,62 @@ export function tokenize(input: string): string[] {
|
|
|
224
607
|
}
|
|
225
608
|
return tokens;
|
|
226
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
|
+
|
|
227
628
|
export function parseQuery(raw: string): ParsedQuery {
|
|
228
629
|
const trimmed = raw.trim();
|
|
229
630
|
if (trimmed === "") {
|
|
230
|
-
return { keywords: [], qualifiers: [], raw };
|
|
631
|
+
return { keywords: [], qualifiers: [], raw, corrections: [] };
|
|
231
632
|
}
|
|
232
633
|
|
|
233
634
|
const keywords: string[] = [];
|
|
234
635
|
const qualifiers: Qualifier[] = [];
|
|
636
|
+
const corrections: QueryCorrection[] = [];
|
|
235
637
|
|
|
236
638
|
for (const token of tokenize(trimmed)) {
|
|
237
639
|
const qualifier = matchQualifier(token);
|
|
238
640
|
if (qualifier) {
|
|
239
|
-
|
|
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
|
+
});
|
|
240
660
|
} else {
|
|
241
661
|
keywords.push(token);
|
|
242
662
|
}
|
|
243
663
|
}
|
|
244
664
|
|
|
245
|
-
return { keywords, qualifiers, raw: trimmed };
|
|
665
|
+
return { keywords, qualifiers, raw: trimmed, corrections };
|
|
246
666
|
}
|
|
247
667
|
|
|
248
668
|
function matchQualifier(token: string): Qualifier | null {
|
|
@@ -252,8 +672,11 @@ function matchQualifier(token: string): Qualifier | null {
|
|
|
252
672
|
if (idx <= 0) return null;
|
|
253
673
|
const key = body.slice(0, idx).toLowerCase();
|
|
254
674
|
let value = body.slice(idx + 1);
|
|
255
|
-
|
|
256
|
-
|
|
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;
|
|
257
680
|
if (value.startsWith("//")) return null;
|
|
258
681
|
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
259
682
|
value = value.slice(1, -1);
|
|
@@ -303,14 +726,237 @@ export function applyFlagFilters(
|
|
|
303
726
|
return { ...query, qualifiers };
|
|
304
727
|
}
|
|
305
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
|
+
*/
|
|
306
752
|
export function validateQuery(query: ParsedQuery): void {
|
|
307
753
|
const has = (key: string) =>
|
|
308
754
|
query.qualifiers.some((q) => q.key === key && !q.negated);
|
|
755
|
+
|
|
756
|
+
// `private:` is GitHub's legacy spelling of `visibility:private`.
|
|
309
757
|
if (has("visibility") && has("private")) {
|
|
310
|
-
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
|
+
}
|
|
311
776
|
}
|
|
312
777
|
}
|
|
313
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
|
+
}
|
|
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
|
+
}));
|
|
958
|
+
}
|
|
959
|
+
|
|
314
960
|
export function buildGitHubQuery(query: ParsedQuery): string {
|
|
315
961
|
const parts: string[] = [];
|
|
316
962
|
|
|
@@ -440,6 +1086,12 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
440
1086
|
const remainingStr = res.headers.get("x-ratelimit-remaining");
|
|
441
1087
|
rateLimitRemaining =
|
|
442
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
|
+
}
|
|
443
1095
|
|
|
444
1096
|
if (res.status === 403) {
|
|
445
1097
|
if (remainingStr === "0" || remainingStr === null) {
|
|
@@ -451,10 +1103,30 @@ export class GitHubSearchAdapter implements SearchAdapter {
|
|
|
451
1103
|
throw new RateLimitError(!!token);
|
|
452
1104
|
}
|
|
453
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.
|
|
454
1110
|
this.logger.error(
|
|
455
|
-
`[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)}`,
|
|
456
1115
|
);
|
|
457
|
-
|
|
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);
|
|
458
1130
|
}
|
|
459
1131
|
if (res.status === 401) {
|
|
460
1132
|
const body = await res.text().catch(() => "");
|