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/src/types.ts CHANGED
@@ -1,12 +1,4 @@
1
- /**
2
- * Core domain types shared across the CLI.
3
- *
4
- * The architecture is intentionally split into layers:
5
- * CLI -> QueryBuilder -> SearchProvider -> Normalizer -> Ranking -> Output
6
- * Each layer depends only on the types defined here, so an AI layer can be
7
- * slotted in later (e.g. a query rewriter before QueryBuilder, or a reranker
8
- * after Ranking) without touching the rest of the code.
9
- */
1
+ /** Core domain types shared across the CLI. */
10
2
 
11
3
  /** A single parsed search qualifier (e.g. `language:Rust`). */
12
4
  export interface Qualifier {
@@ -126,7 +118,7 @@ export interface CacheEntry<T> {
126
118
  ttlMs: number;
127
119
  }
128
120
 
129
- /** Application config, persisted to ~/.config/search-cli/config.json. */
121
+ /** Application config, persisted to ~/.config/ghfind/config.json. */
130
122
  export interface Config {
131
123
  githubToken?: string;
132
124
  githubTokens?: string[];
@@ -140,7 +132,7 @@ export interface Config {
140
132
  bookmarkFile?: string;
141
133
  }
142
134
 
143
- /** Abstract search provider. New sources (GitLab, local index, AI, ...) can
135
+ /** Abstract search provider. New sources (GitLab, local index, etc.) can
144
136
  * implement this interface without changing any other layer. */
145
137
  export interface SearchProvider {
146
138
  readonly name: string;
package/src/watch.ts CHANGED
@@ -1,12 +1,9 @@
1
1
  /**
2
2
  * Watch mode — periodically re-run a search and show changes.
3
3
  *
4
- * ponytail: simple poll + diff. No notifications (P4 cross-ref).
5
4
  */
6
- import type { ParsedQuery, SearchOptions, Repo } from "./types.ts";
7
- import { GitHubSearchProvider } from "./provider.ts";
8
- import { parseQuery, applyFlagFilters } from "./query.ts";
9
- import { rankRepos } from "./ranking.ts";
5
+ import type { ParsedQuery, SearchOptions, Repo } from "./types";
6
+ import { parseQuery, applyFlagFilters, rankRepos, createGitHubSearch } from "./search";
10
7
 
11
8
  export interface WatchOptions {
12
9
  query: string;
@@ -32,7 +29,7 @@ export async function runWatch(
32
29
  const tick = async () => {
33
30
  try {
34
31
  const parsed = applyFlagFilters(parseQuery(opts.query), {});
35
- const provider = new GitHubSearchProvider(undefined, opts.token ? [opts.token] : []);
32
+ const provider = createGitHubSearch(undefined, opts.token ? [opts.token] : []);
36
33
  const options: SearchOptions = {
37
34
  limit: opts.limit,
38
35
  sort: opts.sort,
package/vendor/bun.exe ADDED
Binary file
package/bin/search-cli.js DELETED
@@ -1,17 +0,0 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * search-cli — npm bin entry point.
4
- * Requires Bun (https://bun.sh) to run.
5
- */
6
- import { join, dirname } from "path";
7
- import { fileURLToPath } from "url";
8
- const __dirname = dirname(fileURLToPath(import.meta.url));
9
- const cli = join(__dirname, "..", "src", "cli.ts");
10
-
11
- try {
12
- await import(cli);
13
- } catch (err) {
14
- console.error("Failed to start search-cli. Is Bun installed? (https://bun.sh)");
15
- console.error(err instanceof Error ? err.message : String(err));
16
- process.exit(1);
17
- }
@@ -1,19 +0,0 @@
1
- # search-cli fish completion
2
- # Source: source <(search-cli --completion fish)
3
-
4
- complete -c search-cli -l json -d "JSON output"
5
- complete -c search-cli -l csv -d "CSV output"
6
- complete -c search-cli -l markdown -d "Markdown table output"
7
- complete -c search-cli -l count -d "Only result count"
8
- complete -c search-cli -l limit -d "Max results" -r
9
- complete -c search-cli -l sort -d "Sort strategy" -r -f -a "best-match stars updated forks"
10
- complete -c search-cli -l token -d "GitHub token" -r
11
- complete -c search-cli -l trending -d "Trending mode"
12
- complete -c search-cli -l since -d "Trending period" -r -f -a "daily weekly monthly"
13
- complete -c search-cli -l pipe -d "Pipe target" -r -f -a "clone open"
14
- complete -c search-cli -l format -d "Output format" -r -f -a "urls names ssh-urls clone-commands ids"
15
- complete -c search-cli -l watch -d "Watch mode"
16
- complete -c search-cli -l interval -d "Watch interval seconds" -r
17
- complete -c search-cli -l version -d "Print version"
18
- complete -c search-cli -l help -d "Print help"
19
- complete -c search-cli -l completion -d "Shell" -r -f -a "bash zsh fish"
package/src/cache.ts DELETED
@@ -1,64 +0,0 @@
1
- /**
2
- * In-memory LRU cache with TTL for API responses.
3
- *
4
- * ponytail: in-memory only. Disk cache adds ~70 lines for marginal benefit
5
- * (re-cache on restart rather than re-fetch). Add disk layer if cache-miss
6
- * rate after restart becomes a problem.
7
- */
8
- import type { CacheEntry } from "./types.ts";
9
-
10
- const MAX_SIZE = 50;
11
-
12
- export class MemoryCache<T> {
13
- private cache = new Map<string, CacheEntry<T>>();
14
-
15
- constructor(private defaultTtlMs: number = 300_000) {}
16
-
17
- /** Build a normalized cache key. */
18
- static key(...parts: string[]): string {
19
- return parts.join("::").toLowerCase().replace(/\s+/g, " ");
20
- }
21
-
22
- /** Get entry if not expired. Returns null on miss or expiry. */
23
- get(key: string): T | null {
24
- const entry = this.cache.get(key);
25
- if (!entry) return null;
26
- if (Date.now() - entry.cachedAt > entry.ttlMs) {
27
- this.cache.delete(key);
28
- return null;
29
- }
30
- // Move to most-recently-used position
31
- this.cache.delete(key);
32
- this.cache.set(key, entry);
33
- return entry.data;
34
- }
35
-
36
- /** Set entry with optional per-entry TTL. */
37
- set(key: string, data: T, ttlMs?: number): void {
38
- if (this.cache.size >= MAX_SIZE) {
39
- // Evict least-recently-used (first inserted key)
40
- const firstKey = this.cache.keys().next().value;
41
- if (firstKey !== undefined) this.cache.delete(firstKey);
42
- }
43
- this.cache.set(key, {
44
- data,
45
- cachedAt: Date.now(),
46
- ttlMs: ttlMs ?? this.defaultTtlMs,
47
- });
48
- }
49
-
50
- /** Remove entry. */
51
- delete(key: string): void {
52
- this.cache.delete(key);
53
- }
54
-
55
- /** Clear all entries. */
56
- clear(): void {
57
- this.cache.clear();
58
- }
59
-
60
- /** Number of entries in cache. */
61
- get size(): number {
62
- return this.cache.size;
63
- }
64
- }
package/src/normalizer.ts DELETED
@@ -1,66 +0,0 @@
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 ?? 0,
43
- fullName: item.full_name ?? "",
44
- name: item.name ?? "",
45
- owner,
46
- description: item.description ?? null,
47
- url: item.html_url ?? "",
48
- stars: item.stargazers_count ?? 0,
49
- forks: item.forks_count ?? 0,
50
- watchers: item.watchers_count ?? 0,
51
- language: item.language ?? null,
52
- topics: item.topics ?? [],
53
- archived: item.archived ?? false,
54
- isFork: item.fork ?? false,
55
- private: item.private ?? false,
56
- createdAt: item.created_at ?? "",
57
- updatedAt: item.updated_at ?? "",
58
- pushedAt: item.pushed_at ?? "",
59
- score: item.score ?? 0,
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
- }
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
- }