github-search-cli 9.4.3 → 9.5.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 +85 -21
- package/README.md +35 -10
- package/dist/cli.js +425 -86
- package/package.json +1 -1
- package/release-notes/9.5.0.md +30 -0
- package/src/cli.ts +55 -2
- package/src/help.ts +4 -0
- package/src/tui.ts +108 -28
- package/src/user.ts +356 -0
package/src/user.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User profile module — `ghfind user <name>`.
|
|
3
|
+
*
|
|
4
|
+
* Fetches a GitHub user's metadata and public repos from the GitHub REST API,
|
|
5
|
+
* aggregates them into a single UserProfile summary (repo count, total stars,
|
|
6
|
+
* top languages, most-starred repos), and renders it as text/JSON/CSV/Markdown.
|
|
7
|
+
*
|
|
8
|
+
* Shape mirrors the org-profile module: a pure aggregate function +
|
|
9
|
+
* normalize function that tests can drive with fixtures, and thin formatters.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface UserProfile {
|
|
13
|
+
login: string;
|
|
14
|
+
name: string | null;
|
|
15
|
+
type: string;
|
|
16
|
+
/** Site-admin flag; shown as a badge in text output. */
|
|
17
|
+
siteAdmin: boolean;
|
|
18
|
+
/** Human-readable profile kind: "User", "Organization", "Bot". */
|
|
19
|
+
kind: string;
|
|
20
|
+
bio: string | null;
|
|
21
|
+
url: string;
|
|
22
|
+
blog: string | null;
|
|
23
|
+
location: string | null;
|
|
24
|
+
twitter: string | null;
|
|
25
|
+
company: string | null;
|
|
26
|
+
createdAt: string;
|
|
27
|
+
followers: number;
|
|
28
|
+
following: number;
|
|
29
|
+
/** public_repos as reported by the user payload (all public repos, not just fetched). */
|
|
30
|
+
publicRepoCount: number;
|
|
31
|
+
/** Number of repos actually fetched and aggregated (bounded by the fetch limit). */
|
|
32
|
+
fetchedRepoCount: number;
|
|
33
|
+
totalStars: number;
|
|
34
|
+
totalForks: number;
|
|
35
|
+
/** Top languages by repo count, most-used first. */
|
|
36
|
+
topLanguages: { language: string; repos: number }[];
|
|
37
|
+
/** Most-starred fetched repos, best first. */
|
|
38
|
+
topRepos: UserRepo[];
|
|
39
|
+
/** Recently pushed fetched repos, most recent first. */
|
|
40
|
+
activeRepos: UserRepo[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface UserRepo {
|
|
44
|
+
fullName: string;
|
|
45
|
+
url: string;
|
|
46
|
+
description: string | null;
|
|
47
|
+
language: string | null;
|
|
48
|
+
stars: number;
|
|
49
|
+
forks: number;
|
|
50
|
+
pushedAt: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Raw shapes from the GitHub REST API (only the fields we read). */
|
|
54
|
+
interface GitHubUserPayload {
|
|
55
|
+
login: string;
|
|
56
|
+
name?: string | null;
|
|
57
|
+
type: string;
|
|
58
|
+
site_admin: boolean;
|
|
59
|
+
bio?: string | null;
|
|
60
|
+
html_url: string;
|
|
61
|
+
blog?: string | null;
|
|
62
|
+
location?: string | null;
|
|
63
|
+
twitter_username?: string | null;
|
|
64
|
+
company?: string | null;
|
|
65
|
+
created_at: string;
|
|
66
|
+
followers: number;
|
|
67
|
+
following: number;
|
|
68
|
+
public_repos: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface GitHubRepoPayload {
|
|
72
|
+
full_name: string;
|
|
73
|
+
html_url: string;
|
|
74
|
+
description: string | null;
|
|
75
|
+
language: string | null;
|
|
76
|
+
stargazers_count: number;
|
|
77
|
+
forks_count: number;
|
|
78
|
+
pushed_at: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface FetchUserOptions {
|
|
82
|
+
token?: string;
|
|
83
|
+
/** How many repos to fetch & aggregate (default 100 — one request). */
|
|
84
|
+
limit?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function apiHeaders(token?: string): Record<string, string> {
|
|
88
|
+
return {
|
|
89
|
+
Accept: "application/vnd.github+json",
|
|
90
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
91
|
+
"User-Agent": "ghfind",
|
|
92
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Map a GitHub user `type` to a human-readable profile kind. */
|
|
97
|
+
export function userKind(type: string): string {
|
|
98
|
+
switch (type) {
|
|
99
|
+
case "Bot":
|
|
100
|
+
return "Bot";
|
|
101
|
+
case "Organization":
|
|
102
|
+
return "Organization";
|
|
103
|
+
case "User":
|
|
104
|
+
return "User";
|
|
105
|
+
default:
|
|
106
|
+
return type || "Unknown";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function normalizeUserRepo(item: GitHubRepoPayload): UserRepo {
|
|
111
|
+
return {
|
|
112
|
+
fullName: item.full_name ?? "",
|
|
113
|
+
url: item.html_url ?? "",
|
|
114
|
+
description: item.description ?? null,
|
|
115
|
+
language: item.language ?? null,
|
|
116
|
+
stars: item.stargazers_count ?? 0,
|
|
117
|
+
forks: item.forks_count ?? 0,
|
|
118
|
+
pushedAt: item.pushed_at ?? "",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function normalizeUser(
|
|
123
|
+
payload: GitHubUserPayload,
|
|
124
|
+
repos: GitHubRepoPayload[],
|
|
125
|
+
): UserProfile {
|
|
126
|
+
const userRepos = (repos ?? []).map(normalizeUserRepo);
|
|
127
|
+
|
|
128
|
+
const langCounts = new Map<string, number>();
|
|
129
|
+
for (const r of userRepos) {
|
|
130
|
+
if (!r.language) continue;
|
|
131
|
+
langCounts.set(r.language, (langCounts.get(r.language) ?? 0) + 1);
|
|
132
|
+
}
|
|
133
|
+
const topLanguages = [...langCounts.entries()]
|
|
134
|
+
.map(([language, count]) => ({ language, repos: count }))
|
|
135
|
+
.sort((a, b) => b.repos - a.repos || a.language.localeCompare(b.language))
|
|
136
|
+
.slice(0, 5);
|
|
137
|
+
|
|
138
|
+
const byStars = [...userRepos].sort(
|
|
139
|
+
(a, b) => b.stars - a.stars || a.fullName.localeCompare(b.fullName),
|
|
140
|
+
);
|
|
141
|
+
const byPushed = [...userRepos]
|
|
142
|
+
.filter((r) => r.pushedAt)
|
|
143
|
+
.sort((a, b) => Date.parse(b.pushedAt) - Date.parse(a.pushedAt));
|
|
144
|
+
|
|
145
|
+
const type = payload.type || "";
|
|
146
|
+
return {
|
|
147
|
+
login: payload.login ?? "",
|
|
148
|
+
name: payload.name ?? null,
|
|
149
|
+
type,
|
|
150
|
+
siteAdmin: payload.site_admin ?? false,
|
|
151
|
+
kind: userKind(type),
|
|
152
|
+
bio: payload.bio ?? null,
|
|
153
|
+
url: payload.html_url ?? "",
|
|
154
|
+
blog: nullIfEmpty(payload.blog),
|
|
155
|
+
location: nullIfEmpty(payload.location),
|
|
156
|
+
twitter: nullIfEmpty(payload.twitter_username),
|
|
157
|
+
company: nullIfEmpty(payload.company),
|
|
158
|
+
createdAt: payload.created_at ?? "",
|
|
159
|
+
followers: payload.followers ?? 0,
|
|
160
|
+
following: payload.following ?? 0,
|
|
161
|
+
publicRepoCount: payload.public_repos ?? 0,
|
|
162
|
+
fetchedRepoCount: userRepos.length,
|
|
163
|
+
totalStars: userRepos.reduce((sum, r) => sum + r.stars, 0),
|
|
164
|
+
totalForks: userRepos.reduce((sum, r) => sum + r.forks, 0),
|
|
165
|
+
topLanguages,
|
|
166
|
+
topRepos: byStars.slice(0, 5),
|
|
167
|
+
activeRepos: byPushed.slice(0, 5),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function nullIfEmpty(v: string | null | undefined): string | null {
|
|
172
|
+
return v == null || v === "" ? null : v;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Fetch a user's profile: metadata + up to `limit` public repos, aggregated. */
|
|
176
|
+
export async function fetchUserProfile(
|
|
177
|
+
user: string,
|
|
178
|
+
options: FetchUserOptions = {},
|
|
179
|
+
): Promise<UserProfile> {
|
|
180
|
+
const name = user.trim().replace(/^@/, "");
|
|
181
|
+
if (!name) throw new Error("Usage: ghfind user <username>");
|
|
182
|
+
|
|
183
|
+
const token = options.token;
|
|
184
|
+
const limit = Math.min(Math.max(options.limit ?? 100, 1), 500);
|
|
185
|
+
|
|
186
|
+
let userPayload: GitHubUserPayload;
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(
|
|
189
|
+
`https://api.github.com/users/${encodeURIComponent(name)}`,
|
|
190
|
+
{
|
|
191
|
+
headers: apiHeaders(token),
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
if (res.status === 404) {
|
|
195
|
+
throw new Error(`User "${name}" not found.`);
|
|
196
|
+
}
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
const body = await res.text().catch(() => "");
|
|
199
|
+
throw new Error(`GitHub API error ${res.status}: ${body.slice(0, 200)}`);
|
|
200
|
+
}
|
|
201
|
+
userPayload = (await res.json()) as GitHubUserPayload;
|
|
202
|
+
} catch (err) {
|
|
203
|
+
if (err instanceof Error && err.message.includes(`User "${name}"`))
|
|
204
|
+
throw err;
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Failed to fetch user "${name}": ${err instanceof Error ? err.message : String(err)}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Users with no public repos don't need the repos request.
|
|
211
|
+
const repos: GitHubRepoPayload[] = [];
|
|
212
|
+
if (userPayload.public_repos > 0) {
|
|
213
|
+
const perPage = Math.min(limit, 100);
|
|
214
|
+
const maxPages = Math.ceil(limit / perPage);
|
|
215
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
216
|
+
const url =
|
|
217
|
+
`https://api.github.com/users/${encodeURIComponent(name)}/repos` +
|
|
218
|
+
`?per_page=${perPage}&sort=pushed&page=${page}`;
|
|
219
|
+
const res = await fetch(url, { headers: apiHeaders(token) });
|
|
220
|
+
if (!res.ok) {
|
|
221
|
+
// User metadata succeeded; degraded summary beats a hard failure.
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
const items = (await res.json()) as GitHubRepoPayload[];
|
|
225
|
+
if (!Array.isArray(items)) break; // unexpected shape — degrade gracefully
|
|
226
|
+
repos.push(...items);
|
|
227
|
+
if (repos.length >= limit || items.length < perPage) break;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return normalizeUser(userPayload, repos.slice(0, limit));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ─── Formatters ────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
export function formatUserText(p: UserProfile): string {
|
|
237
|
+
const lines: string[] = [];
|
|
238
|
+
const badges: string[] = [];
|
|
239
|
+
if (p.kind === "Organization") badges.push("org");
|
|
240
|
+
if (p.kind === "Bot") badges.push("bot");
|
|
241
|
+
if (p.siteAdmin) badges.push("staff");
|
|
242
|
+
const badge = badges.length > 0 ? ` [${badges.join(", ")}]` : "";
|
|
243
|
+
const title = p.name
|
|
244
|
+
? `${p.login} — ${p.name}${badge}`
|
|
245
|
+
: `${p.login}${badge}`;
|
|
246
|
+
lines.push(title);
|
|
247
|
+
if (p.bio) lines.push(p.bio);
|
|
248
|
+
|
|
249
|
+
const meta: string[] = [];
|
|
250
|
+
if (p.company) meta.push(`Company ${p.company}`);
|
|
251
|
+
if (p.location) meta.push(`Location ${p.location}`);
|
|
252
|
+
if (p.blog) meta.push(`Web ${p.blog}`);
|
|
253
|
+
if (p.twitter) meta.push(`Twitter @${p.twitter}`);
|
|
254
|
+
if (p.createdAt) meta.push(`Joined ${p.createdAt.slice(0, 10)}`);
|
|
255
|
+
if (meta.length > 0) lines.push(meta.join("\n"));
|
|
256
|
+
|
|
257
|
+
lines.push(
|
|
258
|
+
`Repos ${p.publicRepoCount.toLocaleString()} public${p.fetchedRepoCount < p.publicRepoCount ? ` (aggregated ${p.fetchedRepoCount})` : ""}`,
|
|
259
|
+
);
|
|
260
|
+
lines.push(
|
|
261
|
+
`Stars ${p.totalStars.toLocaleString()} (sum of aggregated repos)`,
|
|
262
|
+
);
|
|
263
|
+
lines.push(
|
|
264
|
+
`Forks ${p.totalForks.toLocaleString()} (sum of aggregated repos)`,
|
|
265
|
+
);
|
|
266
|
+
lines.push(
|
|
267
|
+
`Followers ${p.followers.toLocaleString()} · following ${p.following.toLocaleString()}`,
|
|
268
|
+
);
|
|
269
|
+
lines.push(`URL ${p.url}`);
|
|
270
|
+
|
|
271
|
+
if (p.topLanguages.length > 0) {
|
|
272
|
+
const langs = p.topLanguages
|
|
273
|
+
.map((l) => `${l.language} (${l.repos})`)
|
|
274
|
+
.join(", ");
|
|
275
|
+
lines.push(`Languages ${langs}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (p.topRepos.length > 0) {
|
|
279
|
+
lines.push("");
|
|
280
|
+
lines.push("Top repos by stars:");
|
|
281
|
+
for (const r of p.topRepos) {
|
|
282
|
+
lines.push(
|
|
283
|
+
` ${r.fullName} ★ ${r.stars.toLocaleString()} ${r.language ?? ""}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (p.activeRepos.length > 0) {
|
|
288
|
+
lines.push("");
|
|
289
|
+
lines.push("Recently active:");
|
|
290
|
+
for (const r of p.activeRepos) {
|
|
291
|
+
lines.push(` ${r.fullName} pushed ${r.pushedAt.slice(0, 10)}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return lines.join("\n");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function formatUserJson(p: UserProfile): string {
|
|
298
|
+
return JSON.stringify(p, null, 2);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function formatUserCsv(p: UserProfile): string {
|
|
302
|
+
const esc = (v: string) =>
|
|
303
|
+
/[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
|
|
304
|
+
const row = [
|
|
305
|
+
p.login,
|
|
306
|
+
p.name ?? "",
|
|
307
|
+
p.type,
|
|
308
|
+
p.url,
|
|
309
|
+
String(p.publicRepoCount),
|
|
310
|
+
String(p.totalStars),
|
|
311
|
+
String(p.totalForks),
|
|
312
|
+
String(p.followers),
|
|
313
|
+
p.topLanguages.map((l) => l.language).join(";"),
|
|
314
|
+
p.createdAt,
|
|
315
|
+
].map(esc);
|
|
316
|
+
return [
|
|
317
|
+
"login,name,type,url,public_repos,total_stars,total_forks,followers,top_languages,created_at",
|
|
318
|
+
row.join(","),
|
|
319
|
+
].join("\n");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function formatUserMarkdown(p: UserProfile): string {
|
|
323
|
+
const lines: string[] = [];
|
|
324
|
+
lines.push(`# ${p.login}${p.name ? ` — ${p.name}` : ""}`);
|
|
325
|
+
if (p.bio) lines.push("", p.bio);
|
|
326
|
+
lines.push(
|
|
327
|
+
"",
|
|
328
|
+
"| Metric | Value |",
|
|
329
|
+
"| --- | --- |",
|
|
330
|
+
`| Type | ${p.kind}${p.siteAdmin ? " · staff" : ""} |`,
|
|
331
|
+
`| Public repos | ${p.publicRepoCount} |`,
|
|
332
|
+
`| Total stars (aggregated) | ${p.totalStars.toLocaleString()} |`,
|
|
333
|
+
`| Total forks (aggregated) | ${p.totalForks.toLocaleString()} |`,
|
|
334
|
+
`| Followers | ${p.followers.toLocaleString()} |`,
|
|
335
|
+
);
|
|
336
|
+
if (p.topLanguages.length > 0) {
|
|
337
|
+
lines.push(
|
|
338
|
+
`| Top languages | ${p.topLanguages.map((l) => `${l.language} (${l.repos})`).join(", ")} |`,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (p.topRepos.length > 0) {
|
|
342
|
+
lines.push(
|
|
343
|
+
"",
|
|
344
|
+
"## Top repos by stars",
|
|
345
|
+
"",
|
|
346
|
+
"| Repo | Stars | Language |",
|
|
347
|
+
"| --- | --- | --- |",
|
|
348
|
+
);
|
|
349
|
+
for (const r of p.topRepos) {
|
|
350
|
+
lines.push(
|
|
351
|
+
`| [${r.fullName}](${r.url}) | ${r.stars.toLocaleString()} | ${r.language ?? "—"} |`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return lines.join("\n");
|
|
356
|
+
}
|