github-search-cli 0.6.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/LICENSE +21 -0
- package/README.md +188 -0
- package/bin/search-cli.js +17 -0
- package/completions/search-cli.bash +43 -0
- package/completions/search-cli.fish +19 -0
- package/completions/search-cli.zsh +27 -0
- package/package.json +55 -0
- package/src/bookmarks.ts +69 -0
- package/src/cache.ts +64 -0
- package/src/cli.ts +242 -0
- package/src/compare.ts +46 -0
- package/src/config.ts +66 -0
- package/src/deepdive.ts +124 -0
- package/src/errors.ts +62 -0
- package/src/explore.ts +45 -0
- package/src/export.ts +56 -0
- package/src/help.ts +46 -0
- package/src/history.ts +91 -0
- package/src/init.ts +47 -0
- package/src/normalizer.ts +66 -0
- package/src/notifications.ts +69 -0
- package/src/output.ts +74 -0
- package/src/pipe.ts +65 -0
- package/src/provider.ts +142 -0
- package/src/query.ts +251 -0
- package/src/ranking.ts +55 -0
- package/src/saved-searches.ts +58 -0
- package/src/session.ts +35 -0
- package/src/share.ts +49 -0
- package/src/status.ts +61 -0
- package/src/themes.ts +52 -0
- package/src/tips.ts +27 -0
- package/src/trending.ts +588 -0
- package/src/tui.ts +1463 -0
- package/src/types.ts +148 -0
- package/src/watch.ts +53 -0
package/src/history.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent search history — append-only JSONL with rotation.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, appendFileSync, writeFileSync, unlinkSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import type { HistoryEntry } from "./types.ts";
|
|
7
|
+
import { stateDir } from "./config.ts";
|
|
8
|
+
|
|
9
|
+
const HISTORY_FILE = join(stateDir(), "history.jsonl");
|
|
10
|
+
const MAX_ENTRIES = 500;
|
|
11
|
+
|
|
12
|
+
function ensureDir() {
|
|
13
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Append one entry to the history log. */
|
|
17
|
+
export function appendHistory(entry: HistoryEntry): void {
|
|
18
|
+
ensureDir();
|
|
19
|
+
const line = JSON.stringify(entry) + "\n";
|
|
20
|
+
try {
|
|
21
|
+
appendFileSync(HISTORY_FILE, line, "utf-8");
|
|
22
|
+
} catch {
|
|
23
|
+
// non-critical
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Read all history entries, newest first, deduplicating consecutive identical queries. */
|
|
28
|
+
export function readHistory(): HistoryEntry[] {
|
|
29
|
+
try {
|
|
30
|
+
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
31
|
+
if (!raw) return [];
|
|
32
|
+
const lines = raw.split("\n").filter(Boolean);
|
|
33
|
+
const entries: HistoryEntry[] = lines.map((l) => JSON.parse(l) as HistoryEntry);
|
|
34
|
+
|
|
35
|
+
// Sorted oldest-first in file; reverse to newest-first
|
|
36
|
+
entries.reverse();
|
|
37
|
+
|
|
38
|
+
// Deduplicate consecutive identical queries (keep newest)
|
|
39
|
+
const deduped: HistoryEntry[] = [];
|
|
40
|
+
let lastQuery: string | undefined;
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
if (e.query !== lastQuery) {
|
|
43
|
+
deduped.push(e);
|
|
44
|
+
lastQuery = e.query;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return deduped;
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Delete a single history entry by index (0 = newest). */
|
|
54
|
+
export function deleteHistoryEntry(index: number): void {
|
|
55
|
+
const entries = readHistory();
|
|
56
|
+
if (index < 0 || index >= entries.length) return;
|
|
57
|
+
const removed = entries[index];
|
|
58
|
+
// Rewrite file without the removed entry
|
|
59
|
+
const remaining = entries.filter((e) => e.timestamp !== removed.timestamp || e.query !== removed.query);
|
|
60
|
+
ensureDir();
|
|
61
|
+
// Reverse back to oldest-first for file order
|
|
62
|
+
writeFileSync(HISTORY_FILE, remaining.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Clear all history. */
|
|
66
|
+
export function clearHistory(): void {
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(HISTORY_FILE, "");
|
|
69
|
+
} catch {
|
|
70
|
+
// non-critical
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Rotate history to max entries (trim oldest). */
|
|
75
|
+
export function rotateHistory(): void {
|
|
76
|
+
const entries = readHistory();
|
|
77
|
+
if (entries.length <= MAX_ENTRIES) return;
|
|
78
|
+
const trimmed = entries.slice(0, MAX_ENTRIES);
|
|
79
|
+
ensureDir();
|
|
80
|
+
writeFileSync(HISTORY_FILE, trimmed.reverse().map((e) => JSON.stringify(e)).join("\n") + "\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Count entries in history file. */
|
|
84
|
+
export function historyCount(): number {
|
|
85
|
+
try {
|
|
86
|
+
const raw = readFileSync(HISTORY_FILE, "utf-8").trim();
|
|
87
|
+
return raw ? raw.split("\n").length : 0;
|
|
88
|
+
} catch {
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `search-cli init` wizard — interactive one-time config setup.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: simple stdin prompts, no curses/readline UI.
|
|
5
|
+
*/
|
|
6
|
+
import { createInterface } from "readline";
|
|
7
|
+
import { saveConfig } from "./config.ts";
|
|
8
|
+
import type { Config, SortStrategy } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
function ask(query: string): Promise<string> {
|
|
11
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
rl.question(query, (answer) => {
|
|
14
|
+
rl.close();
|
|
15
|
+
resolve(answer.trim());
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runInitWizard(): Promise<void> {
|
|
21
|
+
console.log("");
|
|
22
|
+
console.log(" search-cli setup wizard");
|
|
23
|
+
console.log(" ──────────────────────");
|
|
24
|
+
console.log("");
|
|
25
|
+
|
|
26
|
+
const token = await ask(" GitHub token (optional, press Enter to skip): ");
|
|
27
|
+
const sortStr = await ask(" Default sort [best-match/stars/updated/forks] (best-match): ");
|
|
28
|
+
const limitStr = await ask(" Default result limit [10-100] (50): ");
|
|
29
|
+
|
|
30
|
+
const config: Config = {
|
|
31
|
+
defaultSort: (["best-match", "stars", "updated", "forks"].includes(sortStr)
|
|
32
|
+
? sortStr
|
|
33
|
+
: "best-match") as SortStrategy,
|
|
34
|
+
defaultLimit: Math.min(100, Math.max(10, parseInt(limitStr) || 50)),
|
|
35
|
+
theme: "tokyo-night",
|
|
36
|
+
cacheTtlSeconds: 300,
|
|
37
|
+
defaultTab: "search",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
if (token) config.githubToken = token;
|
|
41
|
+
|
|
42
|
+
saveConfig(config);
|
|
43
|
+
const { configPath } = await import("./config.ts");
|
|
44
|
+
console.log(` ✓ Config saved to ${configPath()}`);
|
|
45
|
+
console.log(" Run 'search-cli' to start browsing!");
|
|
46
|
+
console.log("");
|
|
47
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result normalizer: convert a raw GitHub API item into our stable {@link Repo}
|
|
3
|
+
* shape. Keeping this isolated means a different provider (or a cached/offline
|
|
4
|
+
* source) only needs to produce the same `Repo` struct for the rest of the
|
|
5
|
+
* pipeline to work unchanged.
|
|
6
|
+
*/
|
|
7
|
+
import type { Repo } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/** The subset of the GitHub "search repositories" item we care about. */
|
|
10
|
+
export interface GitHubApiItem {
|
|
11
|
+
id: number;
|
|
12
|
+
name: string;
|
|
13
|
+
full_name: string;
|
|
14
|
+
owner?: { login?: string };
|
|
15
|
+
description: string | null;
|
|
16
|
+
html_url: string;
|
|
17
|
+
stargazers_count: number;
|
|
18
|
+
forks_count: number;
|
|
19
|
+
watchers_count: number;
|
|
20
|
+
language: string | null;
|
|
21
|
+
topics?: string[];
|
|
22
|
+
archived: boolean;
|
|
23
|
+
fork: boolean;
|
|
24
|
+
private: boolean;
|
|
25
|
+
created_at: string;
|
|
26
|
+
updated_at: string;
|
|
27
|
+
pushed_at: string;
|
|
28
|
+
score: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Raw GitHub search response envelope. */
|
|
32
|
+
export interface GitHubSearchEnvelope {
|
|
33
|
+
total_count: number;
|
|
34
|
+
incomplete_results: boolean;
|
|
35
|
+
items: GitHubApiItem[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Convert a single GitHub API item into a {@link Repo}. */
|
|
39
|
+
export function normalizeRepo(item: GitHubApiItem): Repo {
|
|
40
|
+
const owner = item.owner?.login ?? item.full_name.split("/")[0] ?? "";
|
|
41
|
+
return {
|
|
42
|
+
id: item.id,
|
|
43
|
+
fullName: item.full_name,
|
|
44
|
+
name: item.name,
|
|
45
|
+
owner,
|
|
46
|
+
description: item.description,
|
|
47
|
+
url: item.html_url,
|
|
48
|
+
stars: item.stargazers_count,
|
|
49
|
+
forks: item.forks_count,
|
|
50
|
+
watchers: item.watchers_count,
|
|
51
|
+
language: item.language,
|
|
52
|
+
topics: item.topics ?? [],
|
|
53
|
+
archived: item.archived,
|
|
54
|
+
isFork: item.fork,
|
|
55
|
+
private: item.private,
|
|
56
|
+
createdAt: item.created_at,
|
|
57
|
+
updatedAt: item.updated_at,
|
|
58
|
+
pushedAt: item.pushed_at,
|
|
59
|
+
score: item.score,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Normalize a full GitHub search envelope into {@link Repo} list. */
|
|
64
|
+
export function normalizeEnvelope(env: GitHubSearchEnvelope): Repo[] {
|
|
65
|
+
return (env.items ?? []).map(normalizeRepo);
|
|
66
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification center — ephemeral alerts for rate limits, watch changes, etc.
|
|
3
|
+
*
|
|
4
|
+
* ponytail: in-memory + last-session persistence. Skip 24h auto-clean.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { stateDir } from "./config.ts";
|
|
9
|
+
|
|
10
|
+
export interface Notification {
|
|
11
|
+
id: number;
|
|
12
|
+
type: "info" | "warning" | "rate-limit" | "watch-change" | "upgrade";
|
|
13
|
+
icon: string;
|
|
14
|
+
message: string;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let nextId = 1;
|
|
19
|
+
let notifs: Notification[] = [];
|
|
20
|
+
const MAX_NOTIFS = 50;
|
|
21
|
+
const FILE = join(stateDir(), "notifications.json");
|
|
22
|
+
|
|
23
|
+
function load(): void {
|
|
24
|
+
try {
|
|
25
|
+
notifs = JSON.parse(readFileSync(FILE, "utf-8"));
|
|
26
|
+
nextId = (Math.max(...notifs.map((n) => n.id), 0) + 1);
|
|
27
|
+
} catch {
|
|
28
|
+
notifs = [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function save(): void {
|
|
33
|
+
mkdirSync(stateDir(), { recursive: true });
|
|
34
|
+
writeFileSync(FILE, JSON.stringify(notifs));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function addNotification(type: Notification["type"], message: string): Notification {
|
|
38
|
+
if (notifs.length === 0) load();
|
|
39
|
+
const n: Notification = { id: nextId++, type, icon: iconFor(type), message, timestamp: Date.now() };
|
|
40
|
+
notifs.unshift(n);
|
|
41
|
+
if (notifs.length > MAX_NOTIFS) notifs = notifs.slice(0, MAX_NOTIFS);
|
|
42
|
+
save();
|
|
43
|
+
return n;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function iconFor(type: Notification["type"]): string {
|
|
47
|
+
switch (type) {
|
|
48
|
+
case "info": return "ℹ";
|
|
49
|
+
case "warning": return "⚠";
|
|
50
|
+
case "rate-limit": return "🚫";
|
|
51
|
+
case "watch-change": return "🔔";
|
|
52
|
+
case "upgrade": return "📦";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getNotifications(): Notification[] {
|
|
57
|
+
if (notifs.length === 0) load();
|
|
58
|
+
return [...notifs];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function dismissNotification(id: number): void {
|
|
62
|
+
notifs = notifs.filter((n) => n.id !== id);
|
|
63
|
+
save();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function dismissAll(): void {
|
|
67
|
+
notifs = [];
|
|
68
|
+
save();
|
|
69
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output layer: render ranked results either as machine-readable JSON or as a
|
|
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";
|
|
7
|
+
|
|
8
|
+
/** Format a number compactly (e.g. 1234 -> "1.2k"). */
|
|
9
|
+
export function formatCount(n: number): string {
|
|
10
|
+
if (n < 1000) return String(n);
|
|
11
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
12
|
+
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Format an ISO date as YYYY-MM-DD. */
|
|
16
|
+
export function formatDate(iso: string): string {
|
|
17
|
+
const d = new Date(iso);
|
|
18
|
+
if (isNaN(d.getTime())) return "unknown";
|
|
19
|
+
return d.toISOString().slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Emit stable, parseable JSON. */
|
|
23
|
+
export function renderJson(response: SearchResponse, rawQuery: string): string {
|
|
24
|
+
return JSON.stringify(
|
|
25
|
+
{
|
|
26
|
+
query: rawQuery,
|
|
27
|
+
totalCount: response.totalCount,
|
|
28
|
+
count: response.repos.length,
|
|
29
|
+
rateLimited: response.rateLimited,
|
|
30
|
+
results: response.repos,
|
|
31
|
+
},
|
|
32
|
+
null,
|
|
33
|
+
2,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Emit a concise, readable list for terminals. */
|
|
38
|
+
export function renderHuman(response: SearchResponse, rawQuery: string): string {
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
lines.push(`Query: ${rawQuery}`);
|
|
41
|
+
lines.push(`Matches: ${response.repos.length} (total available: ${response.totalCount})`);
|
|
42
|
+
if (response.rateLimited) {
|
|
43
|
+
lines.push("⚠ Rate limit hit — results may be incomplete. Set GITHUB_TOKEN to raise limits.");
|
|
44
|
+
}
|
|
45
|
+
lines.push("");
|
|
46
|
+
|
|
47
|
+
if (response.repos.length === 0) {
|
|
48
|
+
lines.push("No repositories found.");
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
response.repos.forEach((repo, i) => {
|
|
53
|
+
lines.push(renderRepo(i + 1, repo));
|
|
54
|
+
lines.push("");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return lines.join("\n").trimEnd();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderRepo(index: number, repo: Repo): string {
|
|
61
|
+
const lang = repo.language ?? "—";
|
|
62
|
+
const stars = formatCount(repo.stars);
|
|
63
|
+
const updated = formatDate(repo.updatedAt);
|
|
64
|
+
const tags: string[] = [];
|
|
65
|
+
if (repo.archived) tags.push("archived");
|
|
66
|
+
if (repo.isFork) tags.push("fork");
|
|
67
|
+
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
68
|
+
|
|
69
|
+
const header = `${index}. ${repo.fullName}${tagStr}`;
|
|
70
|
+
const meta = ` ★ ${stars} • ${lang} • updated ${updated}`;
|
|
71
|
+
const desc = repo.description ? ` ${repo.description}` : " (no description)";
|
|
72
|
+
const url = ` ${repo.url}`;
|
|
73
|
+
return [header, meta, desc, url].join("\n");
|
|
74
|
+
}
|
package/src/pipe.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipe-friendly format modifiers — output repo data as lines for shell piping.
|
|
3
|
+
*/
|
|
4
|
+
import type { Repo } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export type FormatLine = "urls" | "names" | "ssh-urls" | "clone-commands" | "ids";
|
|
7
|
+
|
|
8
|
+
/** Format repos as one URL per line. */
|
|
9
|
+
export function formatUrls(repos: Repo[]): string {
|
|
10
|
+
return repos.map((r) => r.url).join("\n");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Format repos as one owner/repo per line. */
|
|
14
|
+
export function formatNames(repos: Repo[]): string {
|
|
15
|
+
return repos.map((r) => r.fullName).join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Format repos as one git@github.com URL per line. */
|
|
19
|
+
export function formatSshUrls(repos: Repo[]): string {
|
|
20
|
+
return repos.map((r) => `git@github.com:${r.fullName}.git`).join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Format repos as ready-to-paste git clone commands. */
|
|
24
|
+
export function formatCloneCommands(repos: Repo[]): string {
|
|
25
|
+
return repos.map((r) => `git clone git@github.com:${r.fullName}.git`).join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Format repos as GitHub repo IDs, one per line. */
|
|
29
|
+
export function formatIds(repos: Repo[]): string {
|
|
30
|
+
return repos.map((r) => String(r.id)).join("\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Format repos in the given line format. */
|
|
34
|
+
export function formatLines(repos: Repo[], format: FormatLine): string {
|
|
35
|
+
switch (format) {
|
|
36
|
+
case "urls": return formatUrls(repos);
|
|
37
|
+
case "names": return formatNames(repos);
|
|
38
|
+
case "ssh-urls": return formatSshUrls(repos);
|
|
39
|
+
case "clone-commands": return formatCloneCommands(repos);
|
|
40
|
+
case "ids": return formatIds(repos);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Execute a pipe target (clone, open) on repos. */
|
|
45
|
+
export async function pipeExec(repos: Repo[], target: string): Promise<void> {
|
|
46
|
+
for (const repo of repos) {
|
|
47
|
+
const url = repo.url;
|
|
48
|
+
switch (target) {
|
|
49
|
+
case "open":
|
|
50
|
+
await openUrl(url);
|
|
51
|
+
break;
|
|
52
|
+
case "clone":
|
|
53
|
+
console.log(`git clone git@github.com:${repo.fullName}.git`);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function openUrl(url: string): Promise<void> {
|
|
60
|
+
const p = process.platform;
|
|
61
|
+
const args = p === "win32" ? ["cmd", "/c", "start", "", url]
|
|
62
|
+
: p === "darwin" ? ["open", url]
|
|
63
|
+
: ["xdg-open", url];
|
|
64
|
+
Bun.spawn(args);
|
|
65
|
+
}
|
package/src/provider.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
}
|