github-search-cli 0.7.0 → 0.8.1
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 +163 -183
- package/bin/ghfind.js +55 -0
- package/completions/{search-cli.bash → ghfind.bash} +4 -4
- package/completions/ghfind.fish +19 -0
- package/completions/{search-cli.zsh → ghfind.zsh} +4 -4
- package/demo.svg +65 -65
- package/package.json +20 -9
- package/src/bookmarks.ts +2 -2
- package/src/cli.ts +32 -45
- package/src/compare.ts +1 -1
- package/src/config.ts +8 -8
- package/src/deepdive.ts +4 -10
- package/src/errors.ts +1 -4
- package/src/explore.ts +0 -1
- package/src/export.ts +2 -3
- package/src/help.ts +112 -120
- package/src/history.ts +2 -2
- package/src/init.ts +6 -6
- package/src/notifications.ts +2 -6
- package/src/open-url.ts +16 -17
- package/src/output.ts +2 -9
- package/src/pipe.ts +2 -8
- package/src/saved-searches.ts +2 -2
- package/src/search.ts +555 -0
- package/src/session.ts +4 -5
- package/src/share.ts +27 -22
- package/src/trending.ts +10 -15
- package/src/tui.ts +408 -261
- package/src/types.ts +3 -11
- package/src/watch.ts +3 -6
- package/vendor/bun.exe +0 -0
- 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 -165
- package/src/query.ts +0 -249
- package/src/ranking.ts +0 -57
package/src/output.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
* concise, human-readable list. This layer owns all presentation concerns so
|
|
4
|
-
* the search/ranking logic stays presentation-free.
|
|
5
|
-
*/
|
|
6
|
-
import type { Repo, SearchResponse } from "./types.ts";
|
|
1
|
+
/** Output layer: render ranked results as JSON or human-readable list. */
|
|
2
|
+
import type { Repo, SearchResponse } from "./types";
|
|
7
3
|
|
|
8
4
|
/** Format a number compactly (e.g. 1234 -> "1.2k", 999999 -> "1M"). */
|
|
9
5
|
export function formatCount(n: number): string {
|
|
@@ -13,14 +9,12 @@ export function formatCount(n: number): string {
|
|
|
13
9
|
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
14
10
|
}
|
|
15
11
|
|
|
16
|
-
/** Format an ISO date as YYYY-MM-DD. */
|
|
17
12
|
export function formatDate(iso: string): string {
|
|
18
13
|
const d = new Date(iso);
|
|
19
14
|
if (isNaN(d.getTime())) return "unknown";
|
|
20
15
|
return d.toISOString().slice(0, 10);
|
|
21
16
|
}
|
|
22
17
|
|
|
23
|
-
/** Emit stable, parseable JSON. */
|
|
24
18
|
export function renderJson(response: SearchResponse, rawQuery: string): string {
|
|
25
19
|
return JSON.stringify(
|
|
26
20
|
{
|
|
@@ -35,7 +29,6 @@ export function renderJson(response: SearchResponse, rawQuery: string): string {
|
|
|
35
29
|
);
|
|
36
30
|
}
|
|
37
31
|
|
|
38
|
-
/** Emit a concise, readable list for terminals. */
|
|
39
32
|
export function renderHuman(response: SearchResponse, rawQuery: string): string {
|
|
40
33
|
const lines: string[] = [];
|
|
41
34
|
lines.push(`Query: ${rawQuery}`);
|
package/src/pipe.ts
CHANGED
|
@@ -1,37 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pipe-friendly format modifiers — output repo data as lines for shell piping.
|
|
3
3
|
*/
|
|
4
|
-
import type { Repo } from "./types
|
|
5
|
-
import { openUrl } from "./open-url
|
|
4
|
+
import type { Repo } from "./types";
|
|
5
|
+
import { openUrl } from "./open-url";
|
|
6
6
|
|
|
7
7
|
export type FormatLine = "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
|
|
8
8
|
|
|
9
|
-
/** Format repos as one URL per line. */
|
|
10
9
|
export function formatUrls(repos: Repo[]): string {
|
|
11
10
|
return repos.map((r) => r.url).join("\n");
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
/** Format repos as one owner/repo per line. */
|
|
15
13
|
export function formatNames(repos: Repo[]): string {
|
|
16
14
|
return repos.map((r) => r.fullName).join("\n");
|
|
17
15
|
}
|
|
18
16
|
|
|
19
|
-
/** Format repos as one git@github.com URL per line. */
|
|
20
17
|
export function formatSshUrls(repos: Repo[]): string {
|
|
21
18
|
return repos.map((r) => `git@github.com:${r.fullName}.git`).join("\n");
|
|
22
19
|
}
|
|
23
20
|
|
|
24
|
-
/** Format repos as ready-to-paste git clone commands. */
|
|
25
21
|
export function formatCloneCommands(repos: Repo[]): string {
|
|
26
22
|
return repos.map((r) => `git clone git@github.com:${r.fullName}.git`).join("\n");
|
|
27
23
|
}
|
|
28
24
|
|
|
29
|
-
/** Format repos as GitHub repo IDs, one per line. */
|
|
30
25
|
export function formatIds(repos: Repo[]): string {
|
|
31
26
|
return repos.map((r) => String(r.id)).join("\n");
|
|
32
27
|
}
|
|
33
28
|
|
|
34
|
-
/** Format repos in the given line format. */
|
|
35
29
|
export function formatLines(repos: Repo[], format: FormatLine): string {
|
|
36
30
|
switch (format) {
|
|
37
31
|
case "urls": return formatUrls(repos);
|
package/src/saved-searches.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
|
-
import type { SavedSearch, SortStrategy } from "./types
|
|
7
|
-
import { stateDir } from "./config
|
|
6
|
+
import type { SavedSearch, SortStrategy } from "./types";
|
|
7
|
+
import { stateDir } from "./config";
|
|
8
8
|
|
|
9
9
|
const SAVED_FILE = join(stateDir(), "saved-searches.json");
|
|
10
10
|
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search module — deep module that owns the full search pipeline:
|
|
3
|
+
* query parsing → URL building → API fetch (via adapter) → normalize → cache → rank.
|
|
4
|
+
*
|
|
5
|
+
* The adapter seam (`SearchAdapter`) is the only point of contact with the
|
|
6
|
+
* network. Two implementations justify the seam: GitHub in prod, in-memory in tests.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
ParsedQuery,
|
|
10
|
+
Qualifier,
|
|
11
|
+
Repo,
|
|
12
|
+
SearchOptions,
|
|
13
|
+
SearchProvider,
|
|
14
|
+
SearchResponse,
|
|
15
|
+
SortStrategy,
|
|
16
|
+
CacheEntry,
|
|
17
|
+
} from "./types";
|
|
18
|
+
import { NetworkError, RateLimitError, ParseError } from "./errors";
|
|
19
|
+
|
|
20
|
+
export interface Logger {
|
|
21
|
+
debug(msg: string): void;
|
|
22
|
+
info(msg: string): void;
|
|
23
|
+
warn(msg: string): void;
|
|
24
|
+
error(msg: string): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const noopLogger: Logger = {
|
|
28
|
+
debug: () => {},
|
|
29
|
+
info: () => {},
|
|
30
|
+
warn: () => {},
|
|
31
|
+
error: () => {},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export interface SearchAdapter {
|
|
35
|
+
search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class MemoryCache<T> {
|
|
39
|
+
private cache = new Map<string, CacheEntry<T>>();
|
|
40
|
+
|
|
41
|
+
constructor(private defaultTtlMs: number = 300_000) {}
|
|
42
|
+
|
|
43
|
+
static key(...parts: string[]): string {
|
|
44
|
+
return parts.join("::").toLowerCase().replace(/\s+/g, " ");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get(key: string): T | null {
|
|
48
|
+
const entry = this.cache.get(key);
|
|
49
|
+
if (!entry) return null;
|
|
50
|
+
if (Date.now() - entry.cachedAt > entry.ttlMs) {
|
|
51
|
+
this.cache.delete(key);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
this.cache.delete(key);
|
|
55
|
+
this.cache.set(key, entry);
|
|
56
|
+
return entry.data;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
set(key: string, data: T, ttlMs?: number): void {
|
|
60
|
+
if (this.cache.size >= MAX_CACHE_SIZE) {
|
|
61
|
+
const firstKey = this.cache.keys().next().value;
|
|
62
|
+
if (firstKey !== undefined) this.cache.delete(firstKey);
|
|
63
|
+
}
|
|
64
|
+
this.cache.set(key, {
|
|
65
|
+
data,
|
|
66
|
+
cachedAt: Date.now(),
|
|
67
|
+
ttlMs: ttlMs ?? this.defaultTtlMs,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
delete(key: string): void {
|
|
72
|
+
this.cache.delete(key);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
clear(): void {
|
|
76
|
+
this.cache.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get size(): number {
|
|
80
|
+
return this.cache.size;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const MAX_CACHE_SIZE = 50;
|
|
85
|
+
|
|
86
|
+
export interface GitHubApiItem {
|
|
87
|
+
id: number;
|
|
88
|
+
name: string;
|
|
89
|
+
full_name: string;
|
|
90
|
+
owner?: { login?: string };
|
|
91
|
+
description: string | null;
|
|
92
|
+
html_url: string;
|
|
93
|
+
stargazers_count: number;
|
|
94
|
+
forks_count: number;
|
|
95
|
+
watchers_count: number;
|
|
96
|
+
language: string | null;
|
|
97
|
+
topics?: string[];
|
|
98
|
+
archived: boolean;
|
|
99
|
+
fork: boolean;
|
|
100
|
+
private: boolean;
|
|
101
|
+
created_at: string;
|
|
102
|
+
updated_at: string;
|
|
103
|
+
pushed_at: string;
|
|
104
|
+
score: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface GitHubSearchEnvelope {
|
|
108
|
+
total_count: number;
|
|
109
|
+
incomplete_results: boolean;
|
|
110
|
+
items: GitHubApiItem[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function normalizeRepo(item: GitHubApiItem): Repo {
|
|
114
|
+
const owner = item.owner?.login ?? item.full_name?.split("/")[0] ?? "";
|
|
115
|
+
return {
|
|
116
|
+
id: item.id ?? 0,
|
|
117
|
+
fullName: item.full_name ?? "",
|
|
118
|
+
name: item.name ?? "",
|
|
119
|
+
owner,
|
|
120
|
+
description: item.description ?? null,
|
|
121
|
+
url: item.html_url ?? "",
|
|
122
|
+
stars: item.stargazers_count ?? 0,
|
|
123
|
+
forks: item.forks_count ?? 0,
|
|
124
|
+
watchers: item.watchers_count ?? 0,
|
|
125
|
+
language: item.language ?? null,
|
|
126
|
+
topics: item.topics ?? [],
|
|
127
|
+
archived: item.archived ?? false,
|
|
128
|
+
isFork: item.fork ?? false,
|
|
129
|
+
private: item.private ?? false,
|
|
130
|
+
createdAt: item.created_at ?? "",
|
|
131
|
+
updatedAt: item.updated_at ?? "",
|
|
132
|
+
pushedAt: item.pushed_at ?? "",
|
|
133
|
+
score: item.score ?? 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function normalizeEnvelope(env: GitHubSearchEnvelope): Repo[] {
|
|
138
|
+
return (env.items ?? []).map(normalizeRepo);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const KNOWN_QUALIFIERS = [
|
|
142
|
+
"language",
|
|
143
|
+
"stars",
|
|
144
|
+
"fork",
|
|
145
|
+
"archived",
|
|
146
|
+
"topic",
|
|
147
|
+
"user",
|
|
148
|
+
"org",
|
|
149
|
+
"repo",
|
|
150
|
+
"updated",
|
|
151
|
+
"pushed",
|
|
152
|
+
"visibility",
|
|
153
|
+
"in",
|
|
154
|
+
"size",
|
|
155
|
+
"license",
|
|
156
|
+
"created",
|
|
157
|
+
] as const;
|
|
158
|
+
|
|
159
|
+
export type KnownQualifier = (typeof KNOWN_QUALIFIERS)[number];
|
|
160
|
+
|
|
161
|
+
export const QUALIFIER_SUGGESTIONS: Record<string, string[]> = {
|
|
162
|
+
l: ["language:"],
|
|
163
|
+
s: ["stars:", "size:"],
|
|
164
|
+
t: ["topic:"],
|
|
165
|
+
u: ["user:"],
|
|
166
|
+
o: ["org:"],
|
|
167
|
+
r: ["repo:"],
|
|
168
|
+
c: ["created:"],
|
|
169
|
+
p: ["pushed:"],
|
|
170
|
+
v: ["visibility:"],
|
|
171
|
+
i: ["in:"],
|
|
172
|
+
f: ["fork:"],
|
|
173
|
+
a: ["archived:"],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
export function suggestFor(input: string): string[] {
|
|
178
|
+
const trimmed = input.trim().toLowerCase();
|
|
179
|
+
if (trimmed.length === 0) return [];
|
|
180
|
+
if (trimmed.length === 1 && QUALIFIER_SUGGESTIONS[trimmed]) {
|
|
181
|
+
return QUALIFIER_SUGGESTIONS[trimmed];
|
|
182
|
+
}
|
|
183
|
+
const matches = KNOWN_QUALIFIERS.filter((k) => k.startsWith(trimmed));
|
|
184
|
+
return matches.map((k) => k + ":");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function tokenize(input: string): string[] {
|
|
188
|
+
const tokens: string[] = [];
|
|
189
|
+
let current = "";
|
|
190
|
+
let inQuotes = false;
|
|
191
|
+
for (let i = 0; i < input.length; i++) {
|
|
192
|
+
const ch = input[i];
|
|
193
|
+
if (ch === '"') {
|
|
194
|
+
inQuotes = !inQuotes;
|
|
195
|
+
current += ch;
|
|
196
|
+
} else if (/\s/.test(ch) && !inQuotes) {
|
|
197
|
+
if (current) {
|
|
198
|
+
tokens.push(current);
|
|
199
|
+
current = "";
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
current += ch;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (current) {
|
|
206
|
+
if (current.startsWith('"') && current.endsWith('"') && current.length >= 2) {
|
|
207
|
+
current = current.slice(1, -1);
|
|
208
|
+
}
|
|
209
|
+
tokens.push(current);
|
|
210
|
+
}
|
|
211
|
+
return tokens;
|
|
212
|
+
}
|
|
213
|
+
export function parseQuery(raw: string): ParsedQuery {
|
|
214
|
+
const trimmed = raw.trim();
|
|
215
|
+
if (trimmed === "") {
|
|
216
|
+
return { keywords: [], qualifiers: [], raw };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const keywords: string[] = [];
|
|
220
|
+
const qualifiers: Qualifier[] = [];
|
|
221
|
+
|
|
222
|
+
for (const token of tokenize(trimmed)) {
|
|
223
|
+
const qualifier = matchQualifier(token);
|
|
224
|
+
if (qualifier) {
|
|
225
|
+
qualifiers.push(qualifier);
|
|
226
|
+
} else {
|
|
227
|
+
keywords.push(token);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { keywords, qualifiers, raw: trimmed };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function matchQualifier(token: string): Qualifier | null {
|
|
235
|
+
const negated = token.startsWith("-");
|
|
236
|
+
const body = negated ? token.slice(1) : token;
|
|
237
|
+
const idx = body.indexOf(":");
|
|
238
|
+
if (idx <= 0) return null;
|
|
239
|
+
const key = body.slice(0, idx).toLowerCase();
|
|
240
|
+
let value = body.slice(idx + 1);
|
|
241
|
+
if (value === "") return null;
|
|
242
|
+
if (!/^[a-z]+$/i.test(key)) return null;
|
|
243
|
+
if (value.startsWith("//")) return null;
|
|
244
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
245
|
+
value = value.slice(1, -1);
|
|
246
|
+
}
|
|
247
|
+
return { key, value, negated };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface FlagFilters {
|
|
251
|
+
language?: string;
|
|
252
|
+
stars?: string;
|
|
253
|
+
org?: string;
|
|
254
|
+
user?: string;
|
|
255
|
+
topic?: string;
|
|
256
|
+
archived?: boolean;
|
|
257
|
+
fork?: boolean;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function applyFlagFilters(query: ParsedQuery, flags: FlagFilters): ParsedQuery {
|
|
261
|
+
const qualifiers = [...query.qualifiers];
|
|
262
|
+
|
|
263
|
+
if (flags.language) qualifiers.push({ key: "language", value: flags.language, negated: false });
|
|
264
|
+
if (flags.stars) qualifiers.push({ key: "stars", value: flags.stars, negated: false });
|
|
265
|
+
if (flags.org) qualifiers.push({ key: "org", value: flags.org, negated: false });
|
|
266
|
+
if (flags.user) qualifiers.push({ key: "user", value: flags.user, negated: false });
|
|
267
|
+
if (flags.topic) qualifiers.push({ key: "topic", value: flags.topic, negated: false });
|
|
268
|
+
if (flags.archived !== undefined)
|
|
269
|
+
qualifiers.push({ key: "archived", value: flags.archived ? "true" : "false", negated: false });
|
|
270
|
+
if (flags.fork !== undefined)
|
|
271
|
+
qualifiers.push({ key: "fork", value: flags.fork ? "true" : "false", negated: false });
|
|
272
|
+
|
|
273
|
+
return { ...query, qualifiers };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function validateQuery(query: ParsedQuery): void {
|
|
277
|
+
const has = (key: string) => query.qualifiers.some((q) => q.key === key && !q.negated);
|
|
278
|
+
if (has("visibility") && has("private")) {
|
|
279
|
+
throw new Error("Cannot combine `visibility:` and `private:` filters.");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function buildGitHubQuery(query: ParsedQuery): string {
|
|
284
|
+
const parts: string[] = [];
|
|
285
|
+
|
|
286
|
+
for (const kw of query.keywords) {
|
|
287
|
+
const alreadyQuoted = kw.startsWith('"') && kw.endsWith('"');
|
|
288
|
+
parts.push(kw.includes(" ") && !alreadyQuoted ? `"${kw}"` : kw);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const q of query.qualifiers) {
|
|
292
|
+
const needsQuote = q.value.includes(" ") && !q.value.startsWith('"');
|
|
293
|
+
const value = needsQuote ? `"${q.value}"` : q.value;
|
|
294
|
+
parts.push(`${q.negated ? "-" : ""}${q.key}:${value}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return parts.join(" ");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function githubSortParam(sort: SortStrategy): { sort?: string; order?: string } {
|
|
301
|
+
switch (sort) {
|
|
302
|
+
case "stars":
|
|
303
|
+
return { sort: "stars", order: "desc" };
|
|
304
|
+
case "forks":
|
|
305
|
+
return { sort: "forks", order: "desc" };
|
|
306
|
+
case "updated":
|
|
307
|
+
return { sort: "updated", order: "desc" };
|
|
308
|
+
case "best-match":
|
|
309
|
+
default:
|
|
310
|
+
return {};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function buildSearchUrl(query: ParsedQuery, options: SearchOptions): string {
|
|
315
|
+
const q = buildGitHubQuery(query);
|
|
316
|
+
const { sort, order } = githubSortParam(options.sort);
|
|
317
|
+
const params = new URLSearchParams({ q: q || " " });
|
|
318
|
+
if (sort) params.set("sort", sort);
|
|
319
|
+
if (order) params.set("order", order);
|
|
320
|
+
return `https://api.github.com/search/repositories?${params.toString()}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function rankRepos(repos: Repo[], strategy: SortStrategy): Repo[] {
|
|
324
|
+
const copy = [...repos];
|
|
325
|
+
switch (strategy) {
|
|
326
|
+
case "stars":
|
|
327
|
+
copy.sort(byKey((r) => r.stars));
|
|
328
|
+
break;
|
|
329
|
+
case "forks":
|
|
330
|
+
copy.sort(byKey((r) => r.forks));
|
|
331
|
+
break;
|
|
332
|
+
case "updated":
|
|
333
|
+
copy.sort(byKey((r) => Date.parse(r.updatedAt)));
|
|
334
|
+
break;
|
|
335
|
+
case "best-match":
|
|
336
|
+
default:
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
return copy;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function byKey(key: (r: Repo) => number): (a: Repo, b: Repo) => number {
|
|
343
|
+
return (a, b) => key(b) - key(a) || b.stars - a.stars || a.id - b.id;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function compositeScore(r: Repo): number {
|
|
347
|
+
const starScore = Math.log10(r.stars + 1);
|
|
348
|
+
const recencyMs = Date.now() - Date.parse(r.pushedAt);
|
|
349
|
+
const recencyScore = Math.max(0, 1 - recencyMs / (1000 * 60 * 60 * 24 * 365));
|
|
350
|
+
return starScore * 0.7 + recencyScore * 0.3;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export class GitHubSearchAdapter implements SearchAdapter {
|
|
354
|
+
readonly name = "github";
|
|
355
|
+
private readonly logger: Logger;
|
|
356
|
+
private tokens: string[];
|
|
357
|
+
private tokenIndex = 0;
|
|
358
|
+
|
|
359
|
+
constructor(logger: Logger = noopLogger, tokens: string[] = []) {
|
|
360
|
+
this.logger = logger;
|
|
361
|
+
this.tokens = tokens.filter(Boolean);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private nextToken(): string | undefined {
|
|
365
|
+
if (this.tokens.length === 0) return undefined;
|
|
366
|
+
const token = this.tokens[this.tokenIndex % this.tokens.length];
|
|
367
|
+
this.tokenIndex++;
|
|
368
|
+
return token;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
|
|
372
|
+
const url = buildSearchUrl(query, options);
|
|
373
|
+
this.logger.debug(`[github] outgoing query: ${query.raw}`);
|
|
374
|
+
this.logger.debug(`[github] request url: ${url}`);
|
|
375
|
+
|
|
376
|
+
const all: Repo[] = [];
|
|
377
|
+
let totalCount = 0;
|
|
378
|
+
let rateLimited = false;
|
|
379
|
+
let rateLimitRemaining: number | undefined;
|
|
380
|
+
|
|
381
|
+
const perPage = Math.min(Math.max(options.limit, 1), 100);
|
|
382
|
+
const startPage = options.page ?? 1;
|
|
383
|
+
const maxPages = startPage > 1 ? startPage : Math.ceil(options.limit / perPage);
|
|
384
|
+
|
|
385
|
+
for (let page = startPage > 1 ? startPage : 1; page <= maxPages; page++) {
|
|
386
|
+
const pagedUrl = appendPage(url, page, perPage);
|
|
387
|
+
let lastErr: Error | undefined;
|
|
388
|
+
|
|
389
|
+
for (let attempt = 0; attempt <= this.tokens.length; attempt++) {
|
|
390
|
+
const token = this.tokens.length > 0 ? this.nextToken() : options.token;
|
|
391
|
+
try {
|
|
392
|
+
const res = await fetch(pagedUrl, {
|
|
393
|
+
headers: {
|
|
394
|
+
Accept: "application/vnd.github+json",
|
|
395
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
396
|
+
"User-Agent": "ghfind",
|
|
397
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const remainingStr = res.headers.get("x-ratelimit-remaining");
|
|
402
|
+
rateLimitRemaining = remainingStr !== null ? Number(remainingStr) : undefined;
|
|
403
|
+
|
|
404
|
+
if (res.status === 403) {
|
|
405
|
+
if (remainingStr === "0" || remainingStr === null) {
|
|
406
|
+
if (this.tokens.length > 0 && attempt < this.tokens.length) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
this.logger.warn("[github] rate limit exceeded");
|
|
410
|
+
rateLimited = true;
|
|
411
|
+
throw new RateLimitError(!!token);
|
|
412
|
+
}
|
|
413
|
+
const body = await res.text().catch(() => "");
|
|
414
|
+
this.logger.error(`[github] non-rate-limit 403: ${body.slice(0, 200)}`);
|
|
415
|
+
throw new NetworkError();
|
|
416
|
+
}
|
|
417
|
+
if (!res.ok) {
|
|
418
|
+
const body = await res.text().catch(() => "");
|
|
419
|
+
this.logger.error(`[github] API error ${res.status}: ${body.slice(0, 200)}`);
|
|
420
|
+
throw new NetworkError();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const raw = await res.text();
|
|
424
|
+
let parsed: unknown;
|
|
425
|
+
try {
|
|
426
|
+
parsed = JSON.parse(raw);
|
|
427
|
+
} catch {
|
|
428
|
+
const snippet = raw.slice(0, 200);
|
|
429
|
+
this.logger.error(`[github] invalid JSON response: ${snippet}`);
|
|
430
|
+
throw new ParseError("GitHub API", snippet.includes("<!DOCTYPE")
|
|
431
|
+
? "GitHub returned an HTML page (maintenance or CAPTCHA?)"
|
|
432
|
+
: "Invalid JSON response");
|
|
433
|
+
}
|
|
434
|
+
if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as any).items)) {
|
|
435
|
+
this.logger.error(`[github] unexpected response shape: ${JSON.stringify(parsed).slice(0, 200)}`);
|
|
436
|
+
throw new ParseError("GitHub API", "Unexpected response shape — missing items array");
|
|
437
|
+
}
|
|
438
|
+
const env = parsed as GitHubSearchEnvelope;
|
|
439
|
+
totalCount = env.total_count;
|
|
440
|
+
const repos = normalizeEnvelope(env);
|
|
441
|
+
all.push(...repos);
|
|
442
|
+
lastErr = undefined;
|
|
443
|
+
break;
|
|
444
|
+
} catch (err) {
|
|
445
|
+
lastErr = err as Error;
|
|
446
|
+
if (rateLimited) break;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (lastErr && !rateLimited) throw lastErr;
|
|
451
|
+
if (rateLimited) break;
|
|
452
|
+
if (all.length >= options.limit) break;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const repos = all.slice(0, options.limit);
|
|
456
|
+
if (repos.length === 0) {
|
|
457
|
+
this.logger.debug("[github] empty results");
|
|
458
|
+
}
|
|
459
|
+
return { totalCount, repos, rateLimited, rateLimitRemaining };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function appendPage(url: string, page: number, perPage: number): string {
|
|
464
|
+
const u = new URL(url);
|
|
465
|
+
u.searchParams.set("page", String(page));
|
|
466
|
+
u.searchParams.set("per_page", String(perPage));
|
|
467
|
+
return u.toString();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ─── In-memory adapter (for tests) ────────────────────────────────────
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* In-memory adapter that returns canned responses.
|
|
474
|
+
* Use in tests to avoid network calls.
|
|
475
|
+
*/
|
|
476
|
+
export class InMemoryAdapter implements SearchAdapter {
|
|
477
|
+
readonly name = "in-memory";
|
|
478
|
+
private responses: Map<string, SearchResponse> = new Map();
|
|
479
|
+
private defaultResponse: SearchResponse = { totalCount: 0, repos: [], rateLimited: false };
|
|
480
|
+
|
|
481
|
+
/** Set a canned response keyed by the raw query string. */
|
|
482
|
+
setResponse(queryRaw: string, response: SearchResponse): void {
|
|
483
|
+
this.responses.set(queryRaw.toLowerCase(), response);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Set the default response returned when no canned response matches. */
|
|
487
|
+
setDefault(response: SearchResponse): void {
|
|
488
|
+
this.defaultResponse = response;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async search(query: ParsedQuery, _options: SearchOptions): Promise<SearchResponse> {
|
|
492
|
+
const key = query.raw.toLowerCase();
|
|
493
|
+
return this.responses.get(key) ?? { ...this.defaultResponse };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
clear(): void {
|
|
497
|
+
this.responses.clear();
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ─── Search module (orchestrator) ─────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Deep Search module — owns the cache, delegates network I/O to an adapter,
|
|
505
|
+
* and orchestrates the full pipeline: cache → adapter → rank.
|
|
506
|
+
*/
|
|
507
|
+
export class SearchModule implements SearchProvider {
|
|
508
|
+
readonly name = "search";
|
|
509
|
+
private readonly adapter: SearchAdapter;
|
|
510
|
+
private readonly logger: Logger;
|
|
511
|
+
/** Shared cache across instances. */
|
|
512
|
+
static cache = new MemoryCache<SearchResponse>(300_000);
|
|
513
|
+
|
|
514
|
+
constructor(adapter: SearchAdapter, logger: Logger = noopLogger) {
|
|
515
|
+
this.adapter = adapter;
|
|
516
|
+
this.logger = logger;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async search(query: ParsedQuery, options: SearchOptions): Promise<SearchResponse> {
|
|
520
|
+
const cacheKey = MemoryCache.key(
|
|
521
|
+
query.raw,
|
|
522
|
+
options.sort,
|
|
523
|
+
String(options.limit),
|
|
524
|
+
String(options.page ?? 1),
|
|
525
|
+
);
|
|
526
|
+
const cached = SearchModule.cache.get(cacheKey);
|
|
527
|
+
if (cached) {
|
|
528
|
+
this.logger.debug("[search] cache hit");
|
|
529
|
+
return cached;
|
|
530
|
+
}
|
|
531
|
+
this.logger.debug("[search] cache miss");
|
|
532
|
+
|
|
533
|
+
const response = await this.adapter.search(query, options);
|
|
534
|
+
if (!response.rateLimited) {
|
|
535
|
+
SearchModule.cache.set(cacheKey, response);
|
|
536
|
+
}
|
|
537
|
+
return response;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ─── Convenience: create a GitHub-backed SearchModule ──────────────────
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Create a SearchModule backed by the GitHub adapter.
|
|
545
|
+
* This is the primary entry point for production code.
|
|
546
|
+
*/
|
|
547
|
+
export function createGitHubSearch(
|
|
548
|
+
logger: Logger = noopLogger,
|
|
549
|
+
tokens: string[] = [],
|
|
550
|
+
): SearchModule {
|
|
551
|
+
return new SearchModule(new GitHubSearchAdapter(logger, tokens), logger);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Re-export types that callers need
|
|
555
|
+
export type { GitHubApiItem, GitHubSearchEnvelope };
|
package/src/session.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
|
-
import type { SessionState, SortStrategy } from "./types
|
|
7
|
-
import { stateDir } from "./config
|
|
6
|
+
import type { SessionState, SortStrategy } from "./types";
|
|
7
|
+
import { stateDir } from "./config";
|
|
8
8
|
|
|
9
9
|
const SESSION_FILE = join(stateDir(), "session.json");
|
|
10
10
|
|
|
@@ -13,9 +13,8 @@ export function saveSession(state: SessionState): void {
|
|
|
13
13
|
try {
|
|
14
14
|
writeFileSync(SESSION_FILE, JSON.stringify(state));
|
|
15
15
|
} catch (err) {
|
|
16
|
-
if (process.env.DEBUG)
|
|
17
|
-
console.error(`[
|
|
18
|
-
}
|
|
16
|
+
if (process.env.DEBUG)
|
|
17
|
+
console.error(`[ghfind] Failed to save session: ${err instanceof Error ? err.message : String(err)}`);
|
|
19
18
|
}
|
|
20
19
|
}
|
|
21
20
|
|