github-search-cli 0.6.1 → 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/README.md +145 -188
- package/bin/ghfind.js +14 -0
- package/completions/{search-cli.bash → ghfind.bash} +4 -4
- package/completions/ghfind.fish +19 -0
- package/completions/{search-cli.zsh → ghfind.zsh} +27 -27
- package/demo.svg +66 -0
- package/package.json +18 -9
- package/src/bookmarks.ts +69 -69
- package/src/cli.ts +239 -242
- package/src/compare.ts +46 -46
- package/src/config.ts +65 -66
- package/src/deepdive.ts +118 -124
- package/src/errors.ts +59 -62
- package/src/explore.ts +29 -45
- package/src/export.ts +63 -56
- package/src/help.ts +112 -46
- package/src/history.ts +91 -91
- package/src/init.ts +55 -47
- package/src/notifications.ts +65 -69
- package/src/open-url.ts +16 -0
- package/src/output.ts +67 -73
- package/src/pipe.ts +54 -65
- package/src/saved-searches.ts +58 -58
- package/src/search.ts +555 -0
- package/src/session.ts +36 -35
- package/src/share.ts +54 -49
- package/src/status.ts +61 -61
- package/src/themes.ts +52 -52
- package/src/tips.ts +27 -27
- package/src/trending.ts +556 -587
- package/src/tui.ts +1665 -1469
- package/src/types.ts +139 -147
- package/src/watch.ts +62 -53
- package/bin/search-cli.js +0 -17
- package/completions/search-cli.fish +0 -19
- package/src/cache.ts +0 -64
- package/src/normalizer.ts +0 -66
- package/src/provider.ts +0 -142
- package/src/query.ts +0 -251
- package/src/ranking.ts +0 -55
package/src/provider.ts
DELETED
|
@@ -1,142 +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
|
-
rateLimitRemaining = Number(res.headers.get("x-ratelimit-remaining") ?? NaN);
|
|
93
|
-
if (res.status === 403 && rateLimitRemaining === 0) {
|
|
94
|
-
if (this.tokens.length > 0 && attempt < this.tokens.length) {
|
|
95
|
-
// Token exhausted, try next
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
this.logger.warn("[github] rate limit exceeded");
|
|
99
|
-
rateLimited = true;
|
|
100
|
-
throw new RateLimitError(!!token);
|
|
101
|
-
}
|
|
102
|
-
if (!res.ok) {
|
|
103
|
-
const body = await res.text().catch(() => "");
|
|
104
|
-
this.logger.error(`[github] API error ${res.status}: ${body.slice(0, 200)}`);
|
|
105
|
-
throw new NetworkError();
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const env = (await res.json()) as GitHubSearchEnvelope;
|
|
109
|
-
totalCount = env.total_count;
|
|
110
|
-
const repos = normalizeEnvelope(env);
|
|
111
|
-
all.push(...repos);
|
|
112
|
-
lastErr = undefined;
|
|
113
|
-
break; // success, exit token retry loop
|
|
114
|
-
} catch (err) {
|
|
115
|
-
lastErr = err as Error;
|
|
116
|
-
if (rateLimited) break;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (lastErr && !rateLimited) throw lastErr;
|
|
121
|
-
if (rateLimited) break;
|
|
122
|
-
if (all.length >= options.limit) break;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const repos = all.slice(0, options.limit);
|
|
126
|
-
if (repos.length === 0) {
|
|
127
|
-
this.logger.debug("[github] empty results");
|
|
128
|
-
}
|
|
129
|
-
const response: SearchResponse = { totalCount, repos, rateLimited, rateLimitRemaining };
|
|
130
|
-
if (!rateLimited) {
|
|
131
|
-
GitHubSearchProvider.cache.set(cacheKey, response);
|
|
132
|
-
}
|
|
133
|
-
return response;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function appendPage(url: string, page: number, perPage: number): string {
|
|
138
|
-
const u = new URL(url);
|
|
139
|
-
u.searchParams.set("page", String(page));
|
|
140
|
-
u.searchParams.set("per_page", String(perPage));
|
|
141
|
-
return u.toString();
|
|
142
|
-
}
|
package/src/query.ts
DELETED
|
@@ -1,251 +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
|
-
if (has("fork") && has("archived")) {
|
|
190
|
-
throw new Error("Cannot combine `fork:` and `archived:` filters (a fork can still be archived, but the combination is usually unintended).");
|
|
191
|
-
}
|
|
192
|
-
if (has("user") && has("org")) {
|
|
193
|
-
throw new Error("Cannot combine `user:` and `org:` filters (a repo belongs to one or the other).");
|
|
194
|
-
}
|
|
195
|
-
if (has("visibility") && has("private")) {
|
|
196
|
-
throw new Error("Cannot combine `visibility:` and `private:` filters.");
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Build the GitHub search `q` string from a parsed query.
|
|
202
|
-
* Keywords are joined with spaces; qualifiers are emitted as `key:value`
|
|
203
|
-
* (or `-key:value` when negated). Quoted values are re-quoted when they
|
|
204
|
-
* contain spaces.
|
|
205
|
-
*/
|
|
206
|
-
export function buildGitHubQuery(query: ParsedQuery): string {
|
|
207
|
-
const parts: string[] = [];
|
|
208
|
-
|
|
209
|
-
for (const kw of query.keywords) {
|
|
210
|
-
const alreadyQuoted = kw.startsWith('"') && kw.endsWith('"');
|
|
211
|
-
parts.push(kw.includes(" ") && !alreadyQuoted ? `"${kw}"` : kw);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
for (const q of query.qualifiers) {
|
|
215
|
-
const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
|
|
216
|
-
const value = needsQuote ? `"${q.value}"` : q.value;
|
|
217
|
-
parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return parts.join(" ");
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Map our sort strategy to the GitHub `sort` param and the in-query `sort:`
|
|
225
|
-
* qualifier. GitHub's repository search supports `stars`, `forks`, `updated`
|
|
226
|
-
* and (implicitly) best-match. We encode best-match by omitting the param.
|
|
227
|
-
*/
|
|
228
|
-
export function githubSortParam(sort: SortStrategy): { sort?: string; order?: string } {
|
|
229
|
-
switch (sort) {
|
|
230
|
-
case "stars":
|
|
231
|
-
return { sort: "stars", order: "desc" };
|
|
232
|
-
case "forks":
|
|
233
|
-
return { sort: "forks", order: "desc" };
|
|
234
|
-
case "updated":
|
|
235
|
-
return { sort: "updated", order: "desc" };
|
|
236
|
-
case "best-match":
|
|
237
|
-
default:
|
|
238
|
-
return {}; // GitHub default relevance ranking
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/** Build the full GitHub search URL for the given query + options. */
|
|
243
|
-
export function buildSearchUrl(query: ParsedQuery, options: SearchOptions): string {
|
|
244
|
-
const q = buildGitHubQuery(query);
|
|
245
|
-
const { sort, order } = githubSortParam(options.sort);
|
|
246
|
-
const perPage = Math.min(Math.max(options.limit, 1), 100); // GitHub max page size
|
|
247
|
-
const params = new URLSearchParams({ q: q || " ", per_page: String(perPage) });
|
|
248
|
-
if (sort) params.set("sort", sort);
|
|
249
|
-
if (order) params.set("order", order);
|
|
250
|
-
return `https://api.github.com/search/repositories?${params.toString()}`;
|
|
251
|
-
}
|
package/src/ranking.ts
DELETED
|
@@ -1,55 +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; keep it but apply a stable
|
|
31
|
-
// tie-breaker so the output is reproducible.
|
|
32
|
-
copy.sort((a, b) => b.score - a.score || b.stars - a.stars || a.id - b.id);
|
|
33
|
-
break;
|
|
34
|
-
}
|
|
35
|
-
return copy;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Comparator factory: descending by `key`, then descending stars, then
|
|
39
|
-
* ascending id (stable, deterministic tie-break). */
|
|
40
|
-
function byKey(key: (r: Repo) => number): (a: Repo, b: Repo) => number {
|
|
41
|
-
return (a, b) => key(b) - key(a) || b.stars - a.stars || a.id - b.id;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* A simple, explainable composite score used for the (optional) "best" blend.
|
|
46
|
-
* Not used by default but available for an AI/rerank layer later.
|
|
47
|
-
* Range is roughly 0..1 after normalization by the caller.
|
|
48
|
-
*/
|
|
49
|
-
export function compositeScore(r: Repo): number {
|
|
50
|
-
// Log-scaled stars + recency bonus. Deterministic and transparent.
|
|
51
|
-
const starScore = Math.log10(r.stars + 1);
|
|
52
|
-
const recencyMs = Date.now() - Date.parse(r.pushedAt);
|
|
53
|
-
const recencyScore = Math.max(0, 1 - recencyMs / (1000 * 60 * 60 * 24 * 365));
|
|
54
|
-
return starScore * 0.7 + recencyScore * 0.3;
|
|
55
|
-
}
|