github-search-cli 0.7.0 → 0.8.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/src/provider.ts DELETED
@@ -1,165 +0,0 @@
1
- /**
2
- * Search provider: talks to the GitHub REST search API.
3
- *
4
- * This is the only layer that performs network I/O. It implements the
5
- * {@link SearchProvider} interface so alternative backends (a local index, a
6
- * different forge, or an AI-powered source) can be swapped in without touching
7
- * the CLI, query, ranking, or output layers.
8
- */
9
- import type { ParsedQuery, SearchOptions, SearchProvider, SearchResponse } from "./types.ts";
10
- import { buildSearchUrl } from "./query.ts";
11
- import { normalizeEnvelope, type GitHubSearchEnvelope } from "./normalizer.ts";
12
- import { NetworkError, RateLimitError, ParseError } from "./errors.ts";
13
- import { MemoryCache } from "./cache.ts";
14
-
15
- /** A minimal logger so the CLI can surface outgoing queries, errors, and
16
- * rate-limit events without coupling to a specific logging framework. */
17
- export interface Logger {
18
- debug(msg: string): void;
19
- info(msg: string): void;
20
- warn(msg: string): void;
21
- error(msg: string): void;
22
- }
23
-
24
- const noopLogger: Logger = {
25
- debug: () => {},
26
- info: () => {},
27
- warn: () => {},
28
- error: () => {},
29
- };
30
-
31
- export class GitHubSearchProvider implements SearchProvider {
32
- readonly name = "github";
33
- private readonly logger: Logger;
34
- private tokens: string[];
35
- private tokenIndex = 0;
36
- /** Shared cache across instances. */
37
- static cache = new MemoryCache<SearchResponse>(300_000);
38
-
39
- constructor(logger: Logger = noopLogger, tokens: string[] = []) {
40
- this.logger = logger;
41
- this.tokens = tokens.filter(Boolean);
42
- }
43
-
44
- private nextToken(): string | undefined {
45
- if (this.tokens.length === 0) return undefined;
46
- const token = this.tokens[this.tokenIndex % this.tokens.length];
47
- this.tokenIndex++;
48
- return token;
49
- }
50
-
51
- async search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
52
- // Check cache first
53
- const cacheKey = MemoryCache.key(query.raw, options.sort, String(options.limit), String(options.page ?? 1));
54
- const cached = GitHubSearchProvider.cache.get(cacheKey);
55
- if (cached) {
56
- this.logger.debug("[github] cache hit");
57
- return cached;
58
- }
59
- this.logger.debug("[github] cache miss");
60
- const url = buildSearchUrl(query, options);
61
- this.logger.debug(`[github] outgoing query: ${query.raw}`);
62
- this.logger.debug(`[github] request url: ${url}`);
63
-
64
- const all: SearchResponse["repos"] = [];
65
- let totalCount = 0;
66
- let rateLimited = false;
67
- let rateLimitRemaining: number | undefined;
68
-
69
- // Fetch one page (or multiple pages to reach limit if page=1)
70
- const perPage = Math.min(Math.max(options.limit, 1), 100);
71
- const startPage = options.page ?? 1;
72
- // ponytail: when page > 1, fetch only that specific page
73
- const maxPages = startPage > 1 ? startPage : Math.ceil(options.limit / perPage);
74
-
75
- for (let page = startPage > 1 ? startPage : 1; page <= maxPages; page++) {
76
- const pagedUrl = appendPage(url, page, perPage);
77
- let lastErr: Error | undefined;
78
-
79
- // Try with current token; if 403, retry with next token
80
- for (let attempt = 0; attempt <= this.tokens.length; attempt++) {
81
- const token = this.tokens.length > 0 ? this.nextToken() : options.token;
82
- try {
83
- const res = await fetch(pagedUrl, {
84
- headers: {
85
- Accept: "application/vnd.github+json",
86
- "X-GitHub-Api-Version": "2022-11-28",
87
- "User-Agent": "search-cli",
88
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
89
- },
90
- });
91
-
92
- const remainingStr = res.headers.get("x-ratelimit-remaining");
93
- rateLimitRemaining = remainingStr !== null ? Number(remainingStr) : undefined;
94
- if (res.status === 403) {
95
- if (remainingStr === "0" || remainingStr === null) {
96
- // Rate limited
97
- if (this.tokens.length > 0 && attempt < this.tokens.length) {
98
- continue;
99
- }
100
- this.logger.warn("[github] rate limit exceeded");
101
- rateLimited = true;
102
- throw new RateLimitError(!!token);
103
- }
104
- // Non-rate-limit 403 (e.g. private repo, permissions)
105
- const body = await res.text().catch(() => "");
106
- this.logger.error(`[github] non-rate-limit 403: ${body.slice(0, 200)}`);
107
- throw new NetworkError();
108
- }
109
- if (!res.ok) {
110
- const body = await res.text().catch(() => "");
111
- this.logger.error(`[github] API error ${res.status}: ${body.slice(0, 200)}`);
112
- throw new NetworkError();
113
- }
114
-
115
- // Validate response is valid JSON before trusting the cast
116
- const raw = await res.text();
117
- let parsed: any;
118
- try {
119
- parsed = JSON.parse(raw);
120
- } catch {
121
- const snippet = raw.slice(0, 200);
122
- this.logger.error(`[github] invalid JSON response: ${snippet}`);
123
- throw new ParseError("GitHub API", snippet.includes("<!DOCTYPE")
124
- ? "GitHub returned an HTML page (maintenance or CAPTCHA?)"
125
- : "Invalid JSON response");
126
- }
127
- if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.items)) {
128
- this.logger.error(`[github] unexpected response shape: ${JSON.stringify(parsed).slice(0, 200)}`);
129
- throw new ParseError("GitHub API", "Unexpected response shape — missing items array");
130
- }
131
- const env = parsed as GitHubSearchEnvelope;
132
- totalCount = env.total_count;
133
- const repos = normalizeEnvelope(env);
134
- all.push(...repos);
135
- lastErr = undefined;
136
- break; // success, exit token retry loop
137
- } catch (err) {
138
- lastErr = err as Error;
139
- if (rateLimited) break;
140
- }
141
- }
142
-
143
- if (lastErr && !rateLimited) throw lastErr;
144
- if (rateLimited) break;
145
- if (all.length >= options.limit) break;
146
- }
147
-
148
- const repos = all.slice(0, options.limit);
149
- if (repos.length === 0) {
150
- this.logger.debug("[github] empty results");
151
- }
152
- const response: SearchResponse = { totalCount, repos, rateLimited, rateLimitRemaining };
153
- if (!rateLimited) {
154
- GitHubSearchProvider.cache.set(cacheKey, response);
155
- }
156
- return response;
157
- }
158
- }
159
-
160
- function appendPage(url: string, page: number, perPage: number): string {
161
- const u = new URL(url);
162
- u.searchParams.set("page", String(page));
163
- u.searchParams.set("per_page", String(perPage));
164
- return u.toString();
165
- }
package/src/query.ts DELETED
@@ -1,249 +0,0 @@
1
- /**
2
- * Query layer: parse a raw user query into structured qualifiers/keywords and
3
- * build the GitHub search syntax string.
4
- *
5
- * This layer is deliberately free of any I/O. It only transforms strings into
6
- * the {@link ParsedQuery} shape and then into a GitHub `q` parameter. Keeping it
7
- * pure makes it trivially unit-testable and lets a future AI query-rewriter sit
8
- * in front of it without side effects.
9
- */
10
- import type { ParsedQuery, Qualifier, SearchOptions, SortStrategy } from "./types.ts";
11
-
12
- /** Qualifier keys we explicitly understand and can also accept as CLI flags. */
13
- export const KNOWN_QUALIFIERS = [
14
- "language",
15
- "stars",
16
- "fork",
17
- "archived",
18
- "topic",
19
- "user",
20
- "org",
21
- "repo",
22
- "updated",
23
- "pushed",
24
- "visibility",
25
- "in",
26
- "size",
27
- "license",
28
- "created",
29
- ] as const;
30
-
31
- export type KnownQualifier = (typeof KNOWN_QUALIFIERS)[number];
32
-
33
- /** Qualifier suggestions for Tab auto-complete. Maps a prefix to matching qualifiers. */
34
- export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
35
- l: ["language:"],
36
- s: ["stars:", "size:"],
37
- t: ["topic:"],
38
- u: ["user:"],
39
- o: ["org:"],
40
- r: ["repo:"],
41
- c: ["created:"],
42
- p: ["pushed:"],
43
- v: ["visibility:"],
44
- i: ["in:"],
45
- f: ["fork:"],
46
- a: ["archived:"],
47
- };
48
-
49
- /** Find matching qualifier suggestions for a partial word. */
50
- export function suggestFor(input: string): string[] {
51
- const trimmed = input.trim().toLowerCase();
52
- if (trimmed.length === 0) return [];
53
- // Single letter prefix
54
- if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
55
- return QUALIFIER_SUGGESTIONS[trimmed];
56
- }
57
- // Partial qualifier key (e.g. "lang" → "language:")
58
- const matches = KNOWN_QUALIFIERS.filter(k => k.startsWith(trimmed));
59
- return matches.map(k => k + ":");
60
- }
61
-
62
- /**
63
- * Tokenize a raw query string while respecting double quotes so that phrases
64
- * like `"machine learning"` or `repo:"my cool repo"` stay together as one
65
- * token even when the quote is not at a token boundary.
66
- */
67
- export function tokenize(input: string): string[] {
68
- const tokens: string[] = [];
69
- let current = "";
70
- let inQuotes = false;
71
- for (let i = 0; i < input.length; i++) {
72
- const ch = input[i];
73
- if (ch === '"') {
74
- inQuotes = !inQuotes;
75
- current += ch; // keep the quote so callers can detect quoted values
76
- } else if (/\s/.test(ch) && !inQuotes) {
77
- if (current) {
78
- tokens.push(current);
79
- current = "";
80
- }
81
- } else {
82
- current += ch;
83
- }
84
- }
85
- if (current) {
86
- // Strip quotes only when the whole token is wrapped in them
87
- // (e.g. `"machine learning"`); leave `repo:"my repo"` intact for the
88
- // qualifier parser to handle.
89
- if (current.startsWith('"') && current.endsWith('"') && current.length >= 2) {
90
- current = current.slice(1, -1);
91
- }
92
- tokens.push(current);
93
- }
94
- return tokens;
95
- }
96
-
97
- /**
98
- * Parse a raw query string into a {@link ParsedQuery}.
99
- *
100
- * Rules:
101
- * - `key:value` or `key:"quoted value"` becomes a qualifier.
102
- * - `-key:value` becomes a negated qualifier.
103
- * - Anything else is a free-text keyword (quotes are stripped).
104
- * - The original string is preserved verbatim in `raw`.
105
- */
106
- export function parseQuery(raw: string): ParsedQuery {
107
- const trimmed = raw.trim();
108
- if (trimmed === "") {
109
- return { keywords: [], qualifiers: [], raw };
110
- }
111
-
112
- const keywords: string[] = [];
113
- const qualifiers: Qualifier[] = [];
114
-
115
- for (const token of tokenize(trimmed)) {
116
- const qualifier = matchQualifier(token);
117
- if (qualifier) {
118
- qualifiers.push(qualifier);
119
- } else {
120
- keywords.push(token);
121
- }
122
- }
123
-
124
- return { keywords, qualifiers, raw: trimmed };
125
- }
126
-
127
- /** Match a single token against `[-]key:value` (value may be quoted). */
128
- function matchQualifier(token: string): Qualifier | null {
129
- const negated = token.startsWith("-");
130
- const body = negated ? token.slice(1) : token;
131
- const idx = body.indexOf(":");
132
- if (idx <= 0) return null; // no key, or starts with ':'
133
- const key = body.slice(0, idx).toLowerCase();
134
- let value = body.slice(idx + 1);
135
- if (value === "") return null;
136
- // A qualifier key must be a plain word (letters only) so that URLs like
137
- // `http://example.com` are treated as free-text keywords, not qualifiers.
138
- if (!/^[a-z]+$/i.test(key)) return null;
139
- // A value starting with `//` is a URL scheme, not a qualifier value.
140
- if (value.startsWith("//")) return null;
141
- // Strip surrounding quotes from a quoted value (e.g. `repo:"my repo"`).
142
- if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
143
- value = value.slice(1, -1);
144
- }
145
- return { key, value, negated };
146
- }
147
-
148
- /** CLI flag filters that map onto qualifiers. */
149
- export interface FlagFilters {
150
- language?: string;
151
- stars?: string;
152
- org?: string;
153
- user?: string;
154
- topic?: string;
155
- archived?: boolean;
156
- fork?: boolean;
157
- }
158
-
159
- /**
160
- * Merge flag-based filters into a parsed query. Flags are appended as
161
- * qualifiers. Returns a new {@link ParsedQuery} (does not mutate the input).
162
- */
163
- export function applyFlagFilters(
164
- query: ParsedQuery,
165
- flags: FlagFilters,
166
- ): ParsedQuery {
167
- const qualifiers = [...query.qualifiers];
168
-
169
- if (flags.language) qualifiers.push({ key: "language", value: flags.language, negated: false });
170
- if (flags.stars) qualifiers.push({ key: "stars", value: flags.stars, negated: false });
171
- if (flags.org) qualifiers.push({ key: "org", value: flags.org, negated: false });
172
- if (flags.user) qualifiers.push({ key: "user", value: flags.user, negated: false });
173
- if (flags.topic) qualifiers.push({ key: "topic", value: flags.topic, negated: false });
174
- if (flags.archived !== undefined)
175
- qualifiers.push({ key: "archived", value: flags.archived ? "true" : "false", negated: false });
176
- if (flags.fork !== undefined)
177
- qualifiers.push({ key: "fork", value: flags.fork ? "true" : "false", negated: false });
178
-
179
- return { ...query, qualifiers };
180
- }
181
-
182
- /**
183
- * Validate a parsed query for conflicting or nonsensical filters.
184
- * Throws an Error describing the first conflict found.
185
- */
186
- export function validateQuery(query: ParsedQuery): void {
187
- const has = (key: string) => query.qualifiers.some((q) => q.key === key && !q.negated);
188
-
189
- // ponytail: removed fork+archived and user+org validation — both are valid
190
- // GitHub search queries (a fork can be archived; user: acts as author/committer
191
- // not owner, so user+org is a valid scoping query).
192
- if (has("visibility") && has("private")) {
193
- throw new Error("Cannot combine `visibility:` and `private:` filters.");
194
- }
195
- }
196
-
197
- /**
198
- * Build the GitHub search `q` string from a parsed query.
199
- * Keywords are joined with spaces; qualifiers are emitted as `key:value`
200
- * (or `-key:value` when negated). Quoted values are re-quoted when they
201
- * contain spaces.
202
- */
203
- export function buildGitHubQuery(query: ParsedQuery): string {
204
- const parts: string[] = [];
205
-
206
- for (const kw of query.keywords) {
207
- const alreadyQuoted = kw.startsWith('"') && kw.endsWith('"');
208
- parts.push(kw.includes(" ") && !alreadyQuoted ? `"${kw}"` : kw);
209
- }
210
-
211
- for (const q of query.qualifiers) {
212
- const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
213
- const value = needsQuote ? `"${q.value}"` : q.value;
214
- parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
215
- }
216
-
217
- return parts.join(" ");
218
- }
219
-
220
- /**
221
- * Map our sort strategy to the GitHub `sort` param and the in-query `sort:`
222
- * qualifier. GitHub's repository search supports `stars`, `forks`, `updated`
223
- * and (implicitly) best-match. We encode best-match by omitting the param.
224
- */
225
- export function githubSortParam(sort: SortStrategy): { sort?: string; order?: string } {
226
- switch (sort) {
227
- case "stars":
228
- return { sort: "stars", order: "desc" };
229
- case "forks":
230
- return { sort: "forks", order: "desc" };
231
- case "updated":
232
- return { sort: "updated", order: "desc" };
233
- case "best-match":
234
- default:
235
- return {}; // GitHub default relevance ranking
236
- }
237
- }
238
-
239
- /** Build the full GitHub search URL for the given query + options. */
240
- export function buildSearchUrl(query: ParsedQuery, options: SearchOptions): string {
241
- const q = buildGitHubQuery(query);
242
- const { sort, order } = githubSortParam(options.sort);
243
- const perPage = Math.min(Math.max(options.limit, 1), 100); // GitHub max page size
244
- // ponytail: per_page is set by appendPage in provider, not here
245
- const params = new URLSearchParams({ q: q || " " });
246
- if (sort) params.set("sort", sort);
247
- if (order) params.set("order", order);
248
- return `https://api.github.com/search/repositories?${params.toString()}`;
249
- }
package/src/ranking.ts DELETED
@@ -1,57 +0,0 @@
1
- /**
2
- * Ranking layer: order normalized {@link Repo} results deterministically.
3
- *
4
- * All strategies are pure functions of the repo data plus a stable tie-breaker
5
- * (repo id) so that identical inputs always produce identical output. A future
6
- * AI reranker can be added as an additional strategy or as a post-step without
7
- * changing the rest of the pipeline.
8
- */
9
- import type { Repo, SortStrategy } from "./types.ts";
10
-
11
- /**
12
- * Rank repos according to the chosen strategy.
13
- * The sort is stable and deterministic: when two repos compare equal on the
14
- * primary key, we fall back to descending stars, then descending id.
15
- */
16
- export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
17
- const copy = [...repos];
18
- switch (strategy) {
19
- case "stars":
20
- copy.sort(byKey((r) => r.stars));
21
- break;
22
- case "forks":
23
- copy.sort(byKey((r) => r.forks));
24
- break;
25
- case "updated":
26
- copy.sort(byKey((r) => Date.parse(r.updatedAt)));
27
- break;
28
- case "best-match":
29
- default:
30
- // GitHub already returns best-match order; preserve it unchanged.
31
- // Re-sorting by score would be redundant for first-page results and
32
- // misleading for paginated (page 2 repos would intermix with page 1).
33
- // ponytail: if deterministic output is needed, sort by id:
34
- // copy.sort((a, b) => a.id - b.id);
35
- break;
36
- }
37
- return copy;
38
- }
39
-
40
- /** Comparator factory: descending by `key`, then descending stars, then
41
- * ascending id (stable, deterministic tie-break). */
42
- function byKey(key: (r: Repo) => number): (a: Repo, b: Repo) => number {
43
- return (a, b) => key(b) - key(a) || b.stars - a.stars || a.id - b.id;
44
- }
45
-
46
- /**
47
- * A simple, explainable composite score used for the (optional) "best" blend.
48
- * Not used by default but available for an AI/rerank layer later.
49
- * Range is roughly 0..1 after normalization by the caller.
50
- */
51
- export function compositeScore(r: Repo): number {
52
- // Log-scaled stars + recency bonus. Deterministic and transparent.
53
- const starScore = Math.log10(r.stars + 1);
54
- const recencyMs = Date.now() - Date.parse(r.pushedAt);
55
- const recencyScore = Math.max(0, 1 - recencyMs / (1000 * 60 * 60 * 24 * 365));
56
- return starScore * 0.7 + recencyScore * 0.3;
57
- }