github-search-cli 9.6.0 → 9.7.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/CHANGELOG.md +42 -0
- package/README.md +25 -21
- package/completions/ghfind.bash +5 -1
- package/completions/ghfind.fish +2 -1
- package/completions/ghfind.zsh +3 -0
- package/dist/cli.js +363 -149
- package/package.json +1 -1
- package/release-notes/9.7.0.md +53 -0
- package/src/agent-skill.ts +7 -6
- package/src/cli.ts +119 -4
- package/src/compare.ts +76 -1
- package/src/deepdive.ts +195 -23
- package/src/mcp-server.ts +26 -55
- package/src/search.ts +19 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# What's New in ghfind 9.7.0
|
|
2
|
+
|
|
3
|
+
CLI parity with the MCP tools — everything `ghfind mcp` exposes to coding
|
|
4
|
+
agents is now a first-class terminal command — plus two fixes: `ghfind mcp`
|
|
5
|
+
compiles again, and deep-dive shows real repo metadata.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
**`ghfind deep-dive <owner/repo>`** — the MCP-only deep dive, on the CLI
|
|
10
|
+
|
|
11
|
+
- Language breakdown with percentages, top contributors, and a README
|
|
12
|
+
excerpt (main → master → README.rst fallback)
|
|
13
|
+
- Previously the agent skill doc literally told agents to `curl` GitHub
|
|
14
|
+
instead — not anymore
|
|
15
|
+
- `--json` exposes a structured payload (language percents, contributor
|
|
16
|
+
counts, raw README) suitable for agents and scripting
|
|
17
|
+
- Resolves real repo metadata first: `ghfind deep-dive vercel/next.js
|
|
18
|
+
--json` reports actual stars, description, topics, and timestamps
|
|
19
|
+
- A 404 exits with `Repo not found: owner/name` instead of silently
|
|
20
|
+
printing empty sections; network failures still degrade gracefully
|
|
21
|
+
|
|
22
|
+
**`ghfind --compare` export formats** — `--json`, `--csv`, `--markdown`
|
|
23
|
+
|
|
24
|
+
- Compare was text-only ("no JSON formatter for it yet"); all three
|
|
25
|
+
formatters now exist, with columns matching the other export modes
|
|
26
|
+
- Repos that fail to resolve are reported in text output and excluded from
|
|
27
|
+
the table; fewer than 2 resolvable repos exits non-zero
|
|
28
|
+
|
|
29
|
+
**Trending language filter on the CLI**
|
|
30
|
+
|
|
31
|
+
- `ghfind trending rust` — the form the README already advertised but the
|
|
32
|
+
parser never handled — and `ghfind --trending rust` both work now
|
|
33
|
+
- `ghfind --trending --language <lang>` names the flag explicitly
|
|
34
|
+
- Unknown filters are rejected with the accepted list, the same validation
|
|
35
|
+
the `ghfind_trending` MCP tool applies
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- The MCP server now shares its repo-ref parsing, trending-language
|
|
40
|
+
validation, and deep-dive data pipeline with the CLI (new seams in
|
|
41
|
+
`deepdive.ts` and `search.ts`) — one source of truth, no protocol
|
|
42
|
+
behavior change
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **`ghfind mcp` failed to typecheck** — the id handed to
|
|
47
|
+
`respond`/`respondError` stayed `JsonRpcId | undefined` because
|
|
48
|
+
TypeScript's aliased-conditional narrowing does not apply through a `let`
|
|
49
|
+
binding; the id is now a `const`. Runtime behavior was already correct —
|
|
50
|
+
all 31 MCP tests pass unchanged.
|
|
51
|
+
- **Deep-dive ran on a zeroed repo stub** — stars, forks, description, and
|
|
52
|
+
topics were all empty in both the CLI and the `ghfind_deep_dive` MCP
|
|
53
|
+
tool; real metadata is now fetched first (see above).
|
package/src/agent-skill.ts
CHANGED
|
@@ -54,7 +54,8 @@ Formats: --json | --csv | --markdown | --count | --format urls|names|ssh-urls|cl
|
|
|
54
54
|
|
|
55
55
|
## Trending repositories
|
|
56
56
|
|
|
57
|
-
ghfind --trending --json [--since daily|weekly|monthly]
|
|
57
|
+
ghfind --trending --json [--since daily|weekly|monthly] [--language rust]
|
|
58
|
+
ghfind trending rust --json # subcommand form of the same thing
|
|
58
59
|
|
|
59
60
|
## npm packages
|
|
60
61
|
|
|
@@ -67,14 +68,14 @@ Formats: --json | --csv | --markdown | --count | --format urls|names|ssh-urls|cl
|
|
|
67
68
|
|
|
68
69
|
## Compare repositories
|
|
69
70
|
|
|
70
|
-
ghfind --compare owner/a owner/b [--json]
|
|
71
|
-
|
|
71
|
+
ghfind --compare owner/a owner/b [--json|--csv|--markdown]
|
|
72
|
+
Errors if fewer than 2 of the requested repos resolve; unresolved names are
|
|
73
|
+
reported in text output and via the error message.
|
|
72
74
|
|
|
73
75
|
## Deep-dive a repo (languages, contributors, README)
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
curl -s https://raw.githubusercontent.com/OWNER/REPO/main/README.md
|
|
77
|
+
ghfind deep-dive owner/repo --json
|
|
78
|
+
Text output (no flag) prints the same sections formatted for the terminal.
|
|
78
79
|
|
|
79
80
|
## Rate limits
|
|
80
81
|
|
package/src/cli.ts
CHANGED
|
@@ -46,7 +46,20 @@ import {
|
|
|
46
46
|
import { checkAndNotify, allCachedReleases } from "./releases";
|
|
47
47
|
import { runInitWizard } from "./init";
|
|
48
48
|
import { runLoginWizard } from "./login";
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
buildComparisonTable,
|
|
51
|
+
comparisonJson,
|
|
52
|
+
comparisonCsv,
|
|
53
|
+
comparisonMarkdown,
|
|
54
|
+
} from "./compare";
|
|
55
|
+
import {
|
|
56
|
+
fetchDeepDiveRaw,
|
|
57
|
+
buildDeepDiveData,
|
|
58
|
+
buildDeepDiveJson,
|
|
59
|
+
buildDeepDiveText,
|
|
60
|
+
resolveRepoFromRef,
|
|
61
|
+
} from "./deepdive";
|
|
62
|
+
import { resolveTrendingLanguage } from "./search";
|
|
50
63
|
import type { Repo } from "./types";
|
|
51
64
|
import { readFileSync } from "fs";
|
|
52
65
|
import { join, dirname } from "path";
|
|
@@ -96,6 +109,8 @@ interface CLIFlags {
|
|
|
96
109
|
user?: string; // user profile lookup (ghfind user <name>)
|
|
97
110
|
mcp: boolean; // run the MCP server (ghfind mcp)
|
|
98
111
|
skill?: string; // agent skill guide (ghfind skill [name])
|
|
112
|
+
deepDive?: string; // repo deep-dive (ghfind deep-dive <owner/repo>)
|
|
113
|
+
language?: string; // trending language filter (--trending rust / ghfind trending rust)
|
|
99
114
|
}
|
|
100
115
|
|
|
101
116
|
function parseArgs(args: string[]): CLIFlags {
|
|
@@ -142,6 +157,23 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
142
157
|
break;
|
|
143
158
|
case "--trending":
|
|
144
159
|
flags.trending = true;
|
|
160
|
+
// `ghfind --trending rust` — first bare word is the language filter
|
|
161
|
+
break;
|
|
162
|
+
case "trending": {
|
|
163
|
+
// `ghfind trending rust` — subcommand form of --trending (README has
|
|
164
|
+
// claimed this works; now it does).
|
|
165
|
+
flags.trending = true;
|
|
166
|
+
if (
|
|
167
|
+
flags.query === "" &&
|
|
168
|
+
i + 1 < args.length &&
|
|
169
|
+
!args[i + 1].startsWith("-")
|
|
170
|
+
) {
|
|
171
|
+
flags.language = args[++i];
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
case "--language":
|
|
176
|
+
flags.language = args[++i];
|
|
145
177
|
break;
|
|
146
178
|
case "--watch":
|
|
147
179
|
flags.watch = true;
|
|
@@ -204,6 +236,15 @@ function parseArgs(args: string[]): CLIFlags {
|
|
|
204
236
|
case "mcp":
|
|
205
237
|
flags.mcp = true;
|
|
206
238
|
break;
|
|
239
|
+
case "deep-dive": {
|
|
240
|
+
// Single repo ref (owner/name); a following flag means none.
|
|
241
|
+
const next = args[i + 1];
|
|
242
|
+
if (next && !next.startsWith("-")) {
|
|
243
|
+
flags.deepDive = next;
|
|
244
|
+
i++;
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
207
248
|
case "skill": {
|
|
208
249
|
// Optional skill id (ids contain no spaces); a following flag means none.
|
|
209
250
|
const next = args[i + 1];
|
|
@@ -284,8 +325,12 @@ Usage:
|
|
|
284
325
|
ghfind <query> --format <fmt> Format lines (urls|names|ssh-urls|clone-commands|ids)
|
|
285
326
|
ghfind <query> --pipe <target> Pipe to clone/open
|
|
286
327
|
ghfind --trending --json Trending repos as JSON
|
|
328
|
+
ghfind trending <lang> Trending repos, optionally by language
|
|
287
329
|
ghfind --releases Check bookmarks for new releases
|
|
288
330
|
ghfind --compare <r1> <r2> Compare two+ repos side-by-side
|
|
331
|
+
ghfind --compare a/b c/d --json Compare as JSON|CSV|Markdown
|
|
332
|
+
ghfind deep-dive <owner/repo> Deep-dive: languages, contributors, README
|
|
333
|
+
ghfind deep-dive a/b --json Deep-dive as JSON
|
|
289
334
|
ghfind org <name> --json Org profile: repos, stars, top languages
|
|
290
335
|
ghfind user <name> --json User profile: repos, stars, top languages
|
|
291
336
|
ghfind pkg <query> --json Search npm packages, output JSON
|
|
@@ -306,6 +351,7 @@ Options:
|
|
|
306
351
|
--sort <strategy> Sort: best-match|stars|updated|forks
|
|
307
352
|
--theme <name> Theme: tokyo-night|premium-dark (default: tokyo-night)
|
|
308
353
|
--trending Trending mode
|
|
354
|
+
--language <lang> Trending language filter (e.g. rust)
|
|
309
355
|
--since <period> Trending period: daily|weekly|monthly
|
|
310
356
|
--pipe <target> Pipe target: clone|open
|
|
311
357
|
--format <fmt> Line format: urls|names|ssh-urls|...
|
|
@@ -384,6 +430,12 @@ Options:
|
|
|
384
430
|
? "markdown"
|
|
385
431
|
: undefined;
|
|
386
432
|
|
|
433
|
+
// Repo deep-dive — parity with the MCP ghfind_deep_dive tool
|
|
434
|
+
if (flags.deepDive !== undefined) {
|
|
435
|
+
await runDeepDive(flags, outputFormat);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
387
439
|
// Org profile mode — independent of repo search handlers
|
|
388
440
|
// (checks presence, not truthiness, so `ghfind org` with no name errors helpfully)
|
|
389
441
|
if (flags.org !== undefined) {
|
|
@@ -403,7 +455,10 @@ Options:
|
|
|
403
455
|
flags.format ||
|
|
404
456
|
flags.pipe ||
|
|
405
457
|
flags.watch ||
|
|
406
|
-
flags.releases
|
|
458
|
+
flags.releases ||
|
|
459
|
+
// `ghfind trending rust` / `ghfind --trending rust` print a list —
|
|
460
|
+
// a language filter implies non-interactive output
|
|
461
|
+
(flags.trending && (!!flags.language || !!flags.query));
|
|
407
462
|
|
|
408
463
|
// Non-interactive mode
|
|
409
464
|
if (isNonInteractive) {
|
|
@@ -506,6 +561,32 @@ async function runOrgProfile(
|
|
|
506
561
|
}
|
|
507
562
|
}
|
|
508
563
|
|
|
564
|
+
/** `ghfind deep-dive <owner/repo>` — rich repo detail: languages, contributors, README. */
|
|
565
|
+
async function runDeepDive(
|
|
566
|
+
flags: CLIFlags,
|
|
567
|
+
outputFormat?: ExportFormat,
|
|
568
|
+
): Promise<void> {
|
|
569
|
+
const ref = flags.deepDive!;
|
|
570
|
+
if (!ref) {
|
|
571
|
+
console.error(
|
|
572
|
+
"Usage: ghfind deep-dive <owner/repo> [--json] [--token <t>]",
|
|
573
|
+
);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
const repo = await resolveRepoFromRef(ref, flags.token); // validates + real metadata
|
|
578
|
+
const raw = await fetchDeepDiveRaw(repo, flags.token);
|
|
579
|
+
if (outputFormat === "json") {
|
|
580
|
+
console.log(buildDeepDiveJson(raw));
|
|
581
|
+
} else {
|
|
582
|
+
console.log(buildDeepDiveText(buildDeepDiveData(repo, raw)));
|
|
583
|
+
}
|
|
584
|
+
} catch (err) {
|
|
585
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
509
590
|
/** `ghfind pkg "<query>"` — npm package search. Pass-through query to the registry. */
|
|
510
591
|
async function runPackageSearch(flags: CLIFlags): Promise<void> {
|
|
511
592
|
if (flags.registry !== "npm") {
|
|
@@ -611,6 +692,20 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
611
692
|
? "monthly"
|
|
612
693
|
: "daily";
|
|
613
694
|
const trendingSearch = createTrendingSearch();
|
|
695
|
+
// Language filter: validated against github.com/trending's accepted
|
|
696
|
+
// set (same validation as the MCP ghfind_trending tool).
|
|
697
|
+
let language: string | undefined;
|
|
698
|
+
try {
|
|
699
|
+
// `ghfind trending rust` puts the language in flags.language;
|
|
700
|
+
// `ghfind --trending rust` leaves it as the query's first word.
|
|
701
|
+
language = resolveTrendingLanguage(
|
|
702
|
+
flags.language ??
|
|
703
|
+
(flags.query ? flags.query.split(/\s+/)[0] : undefined),
|
|
704
|
+
);
|
|
705
|
+
} catch (err) {
|
|
706
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
614
709
|
const response = await trendingSearch.search(
|
|
615
710
|
{ keywords: [], qualifiers: [], raw: "trending" },
|
|
616
711
|
{
|
|
@@ -619,6 +714,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
619
714
|
json: false,
|
|
620
715
|
verbose: false,
|
|
621
716
|
trendingSince: since,
|
|
717
|
+
...(language ? { trendingLanguage: language } : {}),
|
|
622
718
|
},
|
|
623
719
|
);
|
|
624
720
|
if (outputFormat) {
|
|
@@ -710,6 +806,7 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
710
806
|
compare: async (_ctx) => {
|
|
711
807
|
const search = createGitHubSearch(undefined, [flags.token ?? ""]);
|
|
712
808
|
const repos: Repo[] = [];
|
|
809
|
+
const missing: string[] = [];
|
|
713
810
|
for (const fullName of flags.compare) {
|
|
714
811
|
const res = await search.search(
|
|
715
812
|
{ keywords: [fullName], qualifiers: [], raw: fullName },
|
|
@@ -721,9 +818,27 @@ async function runNonInteractive(flags: CLIFlags, outputFormat?: ExportFormat) {
|
|
|
721
818
|
token: flags.token,
|
|
722
819
|
},
|
|
723
820
|
);
|
|
724
|
-
repos.push(...res.repos);
|
|
821
|
+
if (res.repos.length > 0) repos.push(...res.repos);
|
|
822
|
+
else missing.push(fullName);
|
|
823
|
+
}
|
|
824
|
+
if (repos.length < 2) {
|
|
825
|
+
console.error(
|
|
826
|
+
`Could not resolve enough repos to compare (found ${repos.length}${missing.length ? `; not found: ${missing.join(", ")}` : ""}).`,
|
|
827
|
+
);
|
|
828
|
+
process.exit(1);
|
|
829
|
+
}
|
|
830
|
+
const notFound = missing.length
|
|
831
|
+
? `\n\nNot found: ${missing.join(", ")}`
|
|
832
|
+
: "";
|
|
833
|
+
if (outputFormat === "json") {
|
|
834
|
+
console.log(comparisonJson(repos));
|
|
835
|
+
} else if (outputFormat === "csv") {
|
|
836
|
+
console.log(comparisonCsv(repos));
|
|
837
|
+
} else if (outputFormat === "markdown") {
|
|
838
|
+
console.log(comparisonMarkdown(repos));
|
|
839
|
+
} else {
|
|
840
|
+
console.log(buildComparisonTable(repos) + notFound);
|
|
725
841
|
}
|
|
726
|
-
console.log(buildComparisonTable(repos));
|
|
727
842
|
},
|
|
728
843
|
};
|
|
729
844
|
|
package/src/compare.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Repo comparison — side-by-side table for 2+ repos
|
|
2
|
+
* Repo comparison — side-by-side table for 2+ repos, plus JSON/CSV/Markdown
|
|
3
|
+
* exports (shared by the CLI `--compare` formatters and the MCP server).
|
|
3
4
|
*/
|
|
4
5
|
import type { Repo } from "./types";
|
|
5
6
|
|
|
@@ -67,3 +68,77 @@ export function buildComparisonTable(repos: Repo[]): string {
|
|
|
67
68
|
lines.push("└" + sep.map((s) => "─" + s + "─").join("┴") + "┘");
|
|
68
69
|
return lines.join("\n");
|
|
69
70
|
}
|
|
71
|
+
|
|
72
|
+
// ─── Export formatters (parity with org/user/package modules) ──────────
|
|
73
|
+
|
|
74
|
+
/** The repo fields the comparison surface exposes. */
|
|
75
|
+
export interface ComparisonRepo {
|
|
76
|
+
fullName: string;
|
|
77
|
+
url: string;
|
|
78
|
+
description: string | null;
|
|
79
|
+
stars: number;
|
|
80
|
+
forks: number;
|
|
81
|
+
language: string | null;
|
|
82
|
+
createdAt: string;
|
|
83
|
+
updatedAt: string;
|
|
84
|
+
topics: string[];
|
|
85
|
+
archived: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function comparisonRepo(r: Repo): ComparisonRepo {
|
|
89
|
+
return {
|
|
90
|
+
fullName: r.fullName,
|
|
91
|
+
url: r.url,
|
|
92
|
+
description: r.description,
|
|
93
|
+
stars: r.stars,
|
|
94
|
+
forks: r.forks,
|
|
95
|
+
language: r.language,
|
|
96
|
+
createdAt: r.createdAt,
|
|
97
|
+
updatedAt: r.updatedAt,
|
|
98
|
+
topics: r.topics,
|
|
99
|
+
archived: r.archived,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function comparisonJson(repos: Repo[]): string {
|
|
104
|
+
return JSON.stringify(repos.map(comparisonRepo), null, 2);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function csvEscape(val: unknown): string {
|
|
108
|
+
const str = String(val ?? "");
|
|
109
|
+
if (str.includes('"') || str.includes(",") || str.includes("\n")) {
|
|
110
|
+
return `"${str.replace(/"/g, '""')}"`;
|
|
111
|
+
}
|
|
112
|
+
return str;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function comparisonCsv(repos: Repo[]): string {
|
|
116
|
+
const header = "full_name,stars,forks,language,created,updated,archived,url";
|
|
117
|
+
const rows = repos.map((r) =>
|
|
118
|
+
[
|
|
119
|
+
r.fullName,
|
|
120
|
+
r.stars,
|
|
121
|
+
r.forks,
|
|
122
|
+
r.language ?? "",
|
|
123
|
+
r.createdAt?.slice(0, 10) ?? "",
|
|
124
|
+
r.updatedAt?.slice(0, 10) ?? "",
|
|
125
|
+
String(r.archived),
|
|
126
|
+
r.url,
|
|
127
|
+
]
|
|
128
|
+
.map(csvEscape)
|
|
129
|
+
.join(","),
|
|
130
|
+
);
|
|
131
|
+
return [header, ...rows].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function comparisonMarkdown(repos: Repo[]): string {
|
|
135
|
+
const header =
|
|
136
|
+
"| Repo | Stars | Forks | Language | Created | Updated | Topics |";
|
|
137
|
+
const sep =
|
|
138
|
+
"|------|-------|-------|----------|---------|---------|--------|";
|
|
139
|
+
const rows = repos.map(
|
|
140
|
+
(r) =>
|
|
141
|
+
`| [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.forks.toLocaleString()} | ${r.language ?? "—"} | ${r.createdAt?.slice(0, 7) ?? "—"} | ${r.updatedAt?.slice(0, 10) ?? "—"} | ${(r.topics?.slice(0, 5).join(", ") ?? "") || "—"} |`,
|
|
142
|
+
);
|
|
143
|
+
return [header, sep, ...rows].join("\n");
|
|
144
|
+
}
|
package/src/deepdive.ts
CHANGED
|
@@ -10,6 +10,119 @@ function buildHeaders(token?: string): Record<string, string> {
|
|
|
10
10
|
return headers;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// ─── Repo ref parsing (shared by the CLI deep-dive and the MCP server) ─
|
|
14
|
+
|
|
15
|
+
const REPO_REF_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a repo reference ("owner/name") into its parts. Tolerates a trailing
|
|
19
|
+
* `.git` and a leading `@`. Throws with a user-facing message for anything else.
|
|
20
|
+
*/
|
|
21
|
+
export function parseRepoRef(input: string): { owner: string; name: string } {
|
|
22
|
+
const fullName = input
|
|
23
|
+
.trim()
|
|
24
|
+
.replace(/\.git$/, "")
|
|
25
|
+
.replace(/^@/, "");
|
|
26
|
+
if (!REPO_REF_RE.test(fullName)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`"${input}" is not a repo name — use owner/name, e.g. facebook/react.`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const [owner, name] = fullName.split("/");
|
|
32
|
+
return { owner, name };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Minimal Repo for deep-dive (the fetchers only read owner/name). */
|
|
36
|
+
export function repoFromRef(fullName: string): Repo {
|
|
37
|
+
const { owner, name } = parseRepoRef(fullName);
|
|
38
|
+
return {
|
|
39
|
+
id: 0,
|
|
40
|
+
fullName: `${owner}/${name}`,
|
|
41
|
+
name,
|
|
42
|
+
owner,
|
|
43
|
+
description: null,
|
|
44
|
+
url: `https://github.com/${owner}/${name}`,
|
|
45
|
+
stars: 0,
|
|
46
|
+
forks: 0,
|
|
47
|
+
watchers: 0,
|
|
48
|
+
language: null,
|
|
49
|
+
topics: [],
|
|
50
|
+
archived: false,
|
|
51
|
+
isFork: false,
|
|
52
|
+
private: false,
|
|
53
|
+
createdAt: "",
|
|
54
|
+
updatedAt: "",
|
|
55
|
+
pushedAt: "",
|
|
56
|
+
score: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a repo ref to a real Repo by fetching canonical metadata from the
|
|
62
|
+
* GitHub API. Returns a stub via repoFromRef when the metadata fetch fails
|
|
63
|
+
* (offline, rate limit) — the ref is still validated first. Deep-dive
|
|
64
|
+
* sub-fetches tolerate missing data either way.
|
|
65
|
+
*/
|
|
66
|
+
export async function resolveRepoFromRef(
|
|
67
|
+
fullName: string,
|
|
68
|
+
token?: string,
|
|
69
|
+
): Promise<Repo> {
|
|
70
|
+
const ref = parseRepoRef(fullName); // throws for malformed refs
|
|
71
|
+
const canonical = `${ref.owner}/${ref.name}`;
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`https://api.github.com/repos/${canonical}`, {
|
|
74
|
+
headers: buildHeaders(token),
|
|
75
|
+
});
|
|
76
|
+
if (res.status === 404) {
|
|
77
|
+
throw new Error(`Repo not found: ${canonical}`);
|
|
78
|
+
}
|
|
79
|
+
if (res.ok) {
|
|
80
|
+
const api = (await res.json()) as {
|
|
81
|
+
description: string | null;
|
|
82
|
+
html_url: string;
|
|
83
|
+
stargazers_count: number;
|
|
84
|
+
forks_count: number;
|
|
85
|
+
subscribers_count?: number;
|
|
86
|
+
language: string | null;
|
|
87
|
+
topics?: string[];
|
|
88
|
+
archived: boolean;
|
|
89
|
+
fork: boolean;
|
|
90
|
+
created_at: string;
|
|
91
|
+
updated_at: string;
|
|
92
|
+
pushed_at: string;
|
|
93
|
+
};
|
|
94
|
+
return {
|
|
95
|
+
id: 0,
|
|
96
|
+
fullName: canonical,
|
|
97
|
+
name: ref.name,
|
|
98
|
+
owner: ref.owner,
|
|
99
|
+
description: api.description,
|
|
100
|
+
url: api.html_url,
|
|
101
|
+
stars: api.stargazers_count ?? 0,
|
|
102
|
+
forks: api.forks_count ?? 0,
|
|
103
|
+
watchers: api.subscribers_count ?? 0,
|
|
104
|
+
language: api.language,
|
|
105
|
+
topics: api.topics ?? [],
|
|
106
|
+
archived: api.archived ?? false,
|
|
107
|
+
isFork: api.fork ?? false,
|
|
108
|
+
private: false,
|
|
109
|
+
createdAt: api.created_at ?? "",
|
|
110
|
+
updatedAt: api.updated_at ?? "",
|
|
111
|
+
pushedAt: api.pushed_at ?? "",
|
|
112
|
+
score: 0,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// Let the not-found error propagate; only network/parse failures degrade
|
|
117
|
+
// to the stub below.
|
|
118
|
+
if (err instanceof Error && err.message.startsWith("Repo not found")) {
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
// Fall through to the stub.
|
|
122
|
+
}
|
|
123
|
+
return repoFromRef(canonical);
|
|
124
|
+
}
|
|
125
|
+
|
|
13
126
|
/** Language breakdown: { [lang]: bytes } */
|
|
14
127
|
type LanguageMap = Record<string, number>;
|
|
15
128
|
|
|
@@ -27,11 +140,31 @@ export interface DeepDiveData {
|
|
|
27
140
|
readme: string;
|
|
28
141
|
}
|
|
29
142
|
|
|
30
|
-
/**
|
|
31
|
-
export
|
|
143
|
+
/** Structured deep-dive payload — JSON-ready (for `ghfind deep-dive --json`). */
|
|
144
|
+
export interface DeepDiveJson {
|
|
145
|
+
repo: {
|
|
146
|
+
fullName: string;
|
|
147
|
+
url: string;
|
|
148
|
+
description: string | null;
|
|
149
|
+
stars: number;
|
|
150
|
+
forks: number;
|
|
151
|
+
language: string | null;
|
|
152
|
+
topics: string[];
|
|
153
|
+
};
|
|
154
|
+
/** Language breakdown sorted by bytes, descending. */
|
|
155
|
+
languages: { name: string; bytes: number; percent: number }[];
|
|
156
|
+
contributors: { login: string; contributions: number }[];
|
|
157
|
+
/** Raw README markdown (capped at README_MAX_CHARS). */
|
|
158
|
+
readme: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const README_MAX_CHARS = 5_000;
|
|
162
|
+
|
|
163
|
+
/** Fetch deep-dive data as structured JSON: language percents, contributors, raw README. */
|
|
164
|
+
export async function fetchDeepDiveRaw(
|
|
32
165
|
repo: Repo,
|
|
33
166
|
token?: string,
|
|
34
|
-
): Promise<
|
|
167
|
+
): Promise<DeepDiveJson> {
|
|
35
168
|
const headers = buildHeaders(token);
|
|
36
169
|
|
|
37
170
|
const [languagesRes, contributorsRes] = await Promise.all([
|
|
@@ -60,22 +193,64 @@ export async function fetchDeepDive(
|
|
|
60
193
|
}
|
|
61
194
|
}
|
|
62
195
|
|
|
196
|
+
const langEntries: [string, number][] = languagesRes.ok
|
|
197
|
+
? Object.entries(
|
|
198
|
+
(await languagesRes.json().catch(() => ({}))) as LanguageMap,
|
|
199
|
+
)
|
|
200
|
+
: [];
|
|
201
|
+
const totalBytes = langEntries.reduce((s, [, v]) => s + v, 0);
|
|
202
|
+
const contributors: Contributor[] = contributorsRes.ok
|
|
203
|
+
? ((await contributorsRes.json().catch(() => [])) as Contributor[])
|
|
204
|
+
: [];
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
repo: {
|
|
208
|
+
fullName: repo.fullName,
|
|
209
|
+
url: repo.url,
|
|
210
|
+
description: repo.description,
|
|
211
|
+
stars: repo.stars,
|
|
212
|
+
forks: repo.forks,
|
|
213
|
+
language: repo.language,
|
|
214
|
+
topics: repo.topics,
|
|
215
|
+
},
|
|
216
|
+
languages: langEntries
|
|
217
|
+
.sort(([, a], [, b]) => b - a)
|
|
218
|
+
.map(([name, bytes]) => ({
|
|
219
|
+
name,
|
|
220
|
+
bytes,
|
|
221
|
+
percent: totalBytes > 0 ? (bytes / totalBytes) * 100 : 0,
|
|
222
|
+
})),
|
|
223
|
+
contributors: contributors.map((c) => ({
|
|
224
|
+
login: c.login,
|
|
225
|
+
contributions: c.contributions,
|
|
226
|
+
})),
|
|
227
|
+
readme: readmeText.slice(0, README_MAX_CHARS),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** JSON string for `ghfind deep-dive --json`. */
|
|
232
|
+
export function buildDeepDiveJson(data: DeepDiveJson): string {
|
|
233
|
+
return JSON.stringify(data, null, 2);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Format structured deep-dive data into display sections. */
|
|
237
|
+
export function buildDeepDiveData(repo: Repo, raw: DeepDiveJson): DeepDiveData {
|
|
63
238
|
return {
|
|
64
239
|
summary: formatSummary(repo),
|
|
65
|
-
languages: formatLanguages(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
: {},
|
|
69
|
-
),
|
|
70
|
-
contributors: formatContributors(
|
|
71
|
-
contributorsRes.ok
|
|
72
|
-
? ((await contributorsRes.json().catch(() => [])) as Contributor[])
|
|
73
|
-
: [],
|
|
74
|
-
),
|
|
75
|
-
readme: formatReadme(readmeText),
|
|
240
|
+
languages: formatLanguages(raw.languages),
|
|
241
|
+
contributors: formatContributors(raw.contributors),
|
|
242
|
+
readme: formatReadme(raw.readme),
|
|
76
243
|
};
|
|
77
244
|
}
|
|
78
245
|
|
|
246
|
+
/** Fetch all deep-dive data for a repo. Returns formatted sections. */
|
|
247
|
+
export async function fetchDeepDive(
|
|
248
|
+
repo: Repo,
|
|
249
|
+
token?: string,
|
|
250
|
+
): Promise<DeepDiveData> {
|
|
251
|
+
return buildDeepDiveData(repo, await fetchDeepDiveRaw(repo, token));
|
|
252
|
+
}
|
|
253
|
+
|
|
79
254
|
function formatSummary(repo: Repo): string {
|
|
80
255
|
const lines = [
|
|
81
256
|
` ${repo.fullName}`,
|
|
@@ -87,17 +262,14 @@ function formatSummary(repo: Repo): string {
|
|
|
87
262
|
return lines.filter(Boolean).join("\n");
|
|
88
263
|
}
|
|
89
264
|
|
|
90
|
-
function formatLanguages(langs:
|
|
91
|
-
|
|
92
|
-
if (entries.length === 0) return " (no language data)";
|
|
93
|
-
const total = entries.reduce((s, [, v]) => s + v, 0);
|
|
265
|
+
function formatLanguages(langs: DeepDiveJson["languages"]): string {
|
|
266
|
+
if (langs.length === 0) return " (no language data)";
|
|
94
267
|
const barW = 20;
|
|
95
|
-
return
|
|
96
|
-
.sort(([, a], [, b]) => b - a)
|
|
268
|
+
return langs
|
|
97
269
|
.slice(0, 8)
|
|
98
|
-
.map((
|
|
99
|
-
const pct =
|
|
100
|
-
const filled = Math.round((
|
|
270
|
+
.map(({ name, percent }) => {
|
|
271
|
+
const pct = percent.toFixed(1);
|
|
272
|
+
const filled = Math.round((percent / 100) * barW);
|
|
101
273
|
const bar = "█".repeat(filled) + "░".repeat(barW - filled);
|
|
102
274
|
return ` ${name.padEnd(16)} ${bar} ${pct}%`;
|
|
103
275
|
})
|