@felix-ayush/openhearth 2.0.0 → 2.1.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.
Files changed (3) hide show
  1. package/README.md +117 -0
  2. package/dist/cli.js +574 -134
  3. package/package.json +26 -11
package/README.md ADDED
@@ -0,0 +1,117 @@
1
+ # OpenHearth
2
+
3
+ **Find every GitHub contribution your profile activity feed hides.**
4
+
5
+ OpenHearth is a CLI that audits pull requests, issues, and reviews via the GitHub Search API — then reports what GitHub’s truncated activity sidebar doesn’t show (“51 repositories not shown”).
6
+
7
+ Built for heavy open-source contributors in the AI era, when monthly PR counts can hit hundreds across dozens of repos.
8
+
9
+ ## The problem
10
+
11
+ GitHub’s profile **Contribution activity** is not a complete ledger:
12
+
13
+ - Repos are grouped and truncated (“**N repositories not shown**”)
14
+ - The green graph counts commits, not PRs across repos
15
+ - Search shows lists; it doesn’t audit or compare against the feed
16
+
17
+ OpenHearth gives you the **full inventory** — grouped by repo, with merged / open / closed counts and export.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npx @felix-ayush/openhearth audit USERNAME --month 2026-07
23
+ ```
24
+
25
+ Or install globally:
26
+
27
+ ```bash
28
+ npm install -g @felix-ayush/openhearth
29
+ openhearth audit USERNAME --month 2026-07
30
+ ```
31
+
32
+ **Requires Node.js 20+**
33
+
34
+ ## Quick start
35
+
36
+ ```bash
37
+ # Full audit — PRs + issues + reviews
38
+ openhearth audit Ayush7614 --month 2026-07
39
+
40
+ # Quick report: repos likely hidden from your activity sidebar
41
+ openhearth hidden Ayush7614 --month 2026-07
42
+
43
+ # Export for spreadsheets or dashboards
44
+ openhearth audit Ayush7614 --month 2026-07 --json report.json
45
+ openhearth audit Ayush7614 --month 2026-07 --csv report.csv
46
+ ```
47
+
48
+ ## Commands
49
+
50
+ | Command | Description |
51
+ |---------|-------------|
52
+ | `openhearth audit <user>` | Full PR + issue + review audit (default) |
53
+ | `openhearth hidden <user>` | Hidden-repo report vs activity sidebar |
54
+ | `--month YYYY-MM` | Audit a calendar month |
55
+ | `--from YYYY-MM-DD` | Custom range start |
56
+ | `--to YYYY-MM-DD` | Custom range end |
57
+ | `--kind pr\|issue\|review\|all` | Filter by contribution type |
58
+ | `--token TOKEN` | GitHub PAT (or use `GITHUB_TOKEN` env) |
59
+ | `--json [file]` | Export JSON (`stdout` if no file) |
60
+ | `--csv [file]` | Export CSV |
61
+ | `--quiet` | Minimal terminal output |
62
+
63
+ ## What makes OpenHearth different
64
+
65
+ - **Hidden repo detection** — estimates repos your profile sidebar truncates (~25 visible before “not shown”)
66
+ - **Full Search API pagination** — fetches all results, not just the first page
67
+ - **Auto date-splitting** — when a month exceeds GitHub’s 1000-result search cap, splits the range automatically
68
+ - **One-command full audit** — PRs, issues, and reviews together
69
+ - **Insights** — merge rate, busiest day, top repositories
70
+ - **Export** — JSON and CSV for your own tooling
71
+
72
+ ## Authentication (recommended)
73
+
74
+ Without a token, the unauthenticated Search API is limited (~10 requests/minute).
75
+
76
+ ```bash
77
+ export GITHUB_TOKEN=ghp_your_token_here
78
+ openhearth audit USERNAME --month 2026-07
79
+ ```
80
+
81
+ A classic PAT with **public read** access is enough. The token is sent only to `api.github.com`.
82
+
83
+ ## Example output
84
+
85
+ ```
86
+ OpenHearth · contribution audit
87
+
88
+ Summary · @Ayush7614 · 2026-07
89
+
90
+ Total contributions 412
91
+ Unique repositories 76
92
+ PR merge rate 84%
93
+ By kind PRs 394 · Issues 12 · Reviews 6
94
+
95
+ ⚠ Hidden by activity feed
96
+ Feed shows ~25 repos; Search API found 76.
97
+ ~51 repositories may not appear on your profile sidebar.
98
+
99
+ Top repositories
100
+ · KovaMD/Kova 38
101
+ · repowise-dev/repowise 33
102
+ ...
103
+ ```
104
+
105
+ ## Links
106
+
107
+ - **Website & docs:** [ayush7614.github.io/OpenHearth](https://ayush7614.github.io/OpenHearth/)
108
+ - **Source:** [github.com/Ayush7614/OpenHearth](https://github.com/Ayush7614/OpenHearth)
109
+ - **Issues:** [github.com/Ayush7614/OpenHearth/issues](https://github.com/Ayush7614/OpenHearth/issues)
110
+
111
+ ## Author
112
+
113
+ **Ayush Kumar** — [@Ayush7614](https://github.com/Ayush7614)
114
+
115
+ ## License
116
+
117
+ MIT
package/dist/cli.js CHANGED
@@ -1,10 +1,457 @@
1
1
  #!/usr/bin/env node
2
+
3
+ // src/cli.ts
2
4
  import { writeFileSync } from "node:fs";
3
- import { APP_NAME, auditToCsv, auditToJson, fullAuditToCsv, fullAuditToJson, parseMonth, runAudit, runFullAudit, setAuthToken, } from "@felix-ayush/openhearth-core";
4
- import { printAuditSection, printBanner, printError, printFullReport, printInsights, printProgress, } from "./format.js";
5
+
6
+ // ../core/dist/github.js
7
+ var ACTIVITY_FEED_REPO_CAP = 25;
8
+ var authToken = "";
9
+ function setAuthToken(token) {
10
+ authToken = token.trim();
11
+ }
12
+ function getAuthToken() {
13
+ return authToken || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "";
14
+ }
15
+ var lastRateLimit = {
16
+ remaining: -1,
17
+ limit: -1,
18
+ reset: 0
19
+ };
20
+ function updateRateLimit(res) {
21
+ const remaining = res.headers.get("X-RateLimit-Remaining");
22
+ const limit = res.headers.get("X-RateLimit-Limit");
23
+ const reset = res.headers.get("X-RateLimit-Reset");
24
+ if (remaining != null)
25
+ lastRateLimit.remaining = Number(remaining);
26
+ if (limit != null)
27
+ lastRateLimit.limit = Number(limit);
28
+ if (reset != null)
29
+ lastRateLimit.reset = Number(reset);
30
+ }
31
+ var GitHubApiError = class extends Error {
32
+ status;
33
+ constructor(message, status) {
34
+ super(message);
35
+ this.name = "GitHubApiError";
36
+ this.status = status;
37
+ }
38
+ };
39
+ async function githubFetch(url) {
40
+ const headers = {
41
+ Accept: "application/vnd.github+json",
42
+ "X-GitHub-Api-Version": "2022-11-28"
43
+ };
44
+ const token = getAuthToken();
45
+ if (token) {
46
+ headers.Authorization = `Bearer ${token}`;
47
+ }
48
+ const res = await fetch(url, { headers });
49
+ updateRateLimit(res);
50
+ if (res.status === 403 || res.status === 429) {
51
+ const body = await res.json().catch(() => ({}));
52
+ const msg = body.message ?? "GitHub rate limit exceeded. Set GITHUB_TOKEN and try again.";
53
+ throw new GitHubApiError(msg, res.status);
54
+ }
55
+ if (!res.ok) {
56
+ const body = await res.json().catch(() => ({}));
57
+ const msg = body.message ?? `GitHub API error (${res.status})`;
58
+ throw new GitHubApiError(msg, res.status);
59
+ }
60
+ return res;
61
+ }
62
+ async function searchIssuesPage(query, page) {
63
+ const params = new URLSearchParams({
64
+ q: query,
65
+ per_page: "100",
66
+ page: String(page),
67
+ sort: "created",
68
+ order: "desc"
69
+ });
70
+ const res = await githubFetch(`https://api.github.com/search/issues?${params.toString()}`);
71
+ return await res.json();
72
+ }
73
+ async function searchIssuesAll(query, onProgress) {
74
+ const first = await searchIssuesPage(query, 1);
75
+ const total = first.total_count;
76
+ const items = [...first.items];
77
+ onProgress?.(items.length, Math.min(total, 1e3));
78
+ if (total === 0) {
79
+ return { items, total_count: 0 };
80
+ }
81
+ const pagesNeeded = Math.min(Math.ceil(Math.min(total, 1e3) / 100), 10);
82
+ for (let page = 2; page <= pagesNeeded; page++) {
83
+ const next = await searchIssuesPage(query, page);
84
+ items.push(...next.items);
85
+ onProgress?.(items.length, Math.min(total, 1e3));
86
+ }
87
+ return { items, total_count: total };
88
+ }
89
+ function repoFullNameFromUrl(repositoryUrl) {
90
+ const parts = repositoryUrl.replace(/\/$/, "").split("/");
91
+ const name = parts.pop() ?? "";
92
+ const owner = parts.pop() ?? "";
93
+ return `${owner}/${name}`;
94
+ }
95
+
96
+ // ../core/dist/queries.js
97
+ function pad(n) {
98
+ return String(n).padStart(2, "0");
99
+ }
100
+ function formatDate(d) {
101
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
102
+ }
103
+ function monthRange(year, month) {
104
+ const from = new Date(year, month - 1, 1);
105
+ const to = new Date(year, month, 0);
106
+ return { from: formatDate(from), to: formatDate(to) };
107
+ }
108
+ function parseMonth(value) {
109
+ const match = /^(\d{4})-(\d{2})$/.exec(value.trim());
110
+ if (!match) {
111
+ throw new Error(`Invalid month "${value}". Use YYYY-MM (e.g. 2026-07).`);
112
+ }
113
+ const year = Number(match[1]);
114
+ const month = Number(match[2]);
115
+ if (month < 1 || month > 12) {
116
+ throw new Error(`Invalid month "${value}". Month must be 01\u201312.`);
117
+ }
118
+ return monthRange(year, month);
119
+ }
120
+ function buildSearchQuery(username, kind, range) {
121
+ const created = `created:${range.from}..${range.to}`;
122
+ switch (kind) {
123
+ case "pr":
124
+ return `author:${username} is:pr ${created}`;
125
+ case "issue":
126
+ return `author:${username} is:issue ${created}`;
127
+ case "review":
128
+ return `reviewed-by:${username} is:pr ${created}`;
129
+ }
130
+ }
131
+ function splitRange(range) {
132
+ const start = /* @__PURE__ */ new Date(range.from + "T00:00:00");
133
+ const end = /* @__PURE__ */ new Date(range.to + "T00:00:00");
134
+ const midMs = start.getTime() + Math.floor((end.getTime() - start.getTime()) / 2);
135
+ const mid = new Date(midMs);
136
+ const midNext = new Date(mid);
137
+ midNext.setDate(midNext.getDate() + 1);
138
+ return [
139
+ { from: formatDate(start), to: formatDate(mid) },
140
+ { from: formatDate(midNext), to: formatDate(end) }
141
+ ];
142
+ }
143
+ function rangeDayCount(range) {
144
+ const start = /* @__PURE__ */ new Date(range.from + "T00:00:00");
145
+ const end = /* @__PURE__ */ new Date(range.to + "T00:00:00");
146
+ return Math.round((end.getTime() - start.getTime()) / 864e5) + 1;
147
+ }
148
+
149
+ // ../core/dist/aggregate.js
150
+ function classifyState(item, kind) {
151
+ if (kind === "pr" || kind === "review") {
152
+ if (item.pull_request?.merged_at)
153
+ return "merged";
154
+ if (item.state === "open")
155
+ return "open";
156
+ return "closed";
157
+ }
158
+ return item.state === "open" ? "open" : "closed";
159
+ }
160
+ function toContribution(item, kind) {
161
+ return {
162
+ id: item.id,
163
+ number: item.number,
164
+ title: item.title,
165
+ url: item.html_url,
166
+ state: classifyState(item, kind),
167
+ createdAt: item.created_at,
168
+ closedAt: item.closed_at,
169
+ repo: repoFullNameFromUrl(item.repository_url)
170
+ };
171
+ }
172
+ function aggregateByRepo(items) {
173
+ const map = /* @__PURE__ */ new Map();
174
+ for (const item of items) {
175
+ let bucket = map.get(item.repo);
176
+ if (!bucket) {
177
+ bucket = { repo: item.repo, open: 0, merged: 0, closed: 0, items: [] };
178
+ map.set(item.repo, bucket);
179
+ }
180
+ bucket.items.push(item);
181
+ if (item.state === "open")
182
+ bucket.open++;
183
+ else if (item.state === "merged")
184
+ bucket.merged++;
185
+ else
186
+ bucket.closed++;
187
+ }
188
+ return [...map.values()].sort((a, b) => b.items.length - a.items.length);
189
+ }
190
+ async function fetchAllForRange(username, kind, range, onProgress) {
191
+ const query = buildSearchQuery(username, kind, range);
192
+ onProgress?.(`Searching: ${query}`);
193
+ const { items, total_count } = await searchIssuesAll(query, (fetched, capped) => {
194
+ onProgress?.(`Fetched ${fetched} / ${capped}`);
195
+ });
196
+ if (total_count <= 1e3) {
197
+ return items;
198
+ }
199
+ if (rangeDayCount(range) < 2) {
200
+ onProgress?.(`Warning: ${total_count} results in one day; only first 1000 returned for ${range.from}`);
201
+ return items;
202
+ }
203
+ const [left, right] = splitRange(range);
204
+ onProgress?.(`Over 1000 results (${total_count}). Splitting ${range.from}..${range.to}`);
205
+ const leftItems = await fetchAllForRange(username, kind, left, onProgress);
206
+ const rightItems = await fetchAllForRange(username, kind, right, onProgress);
207
+ const seen = /* @__PURE__ */ new Set();
208
+ const merged = [];
209
+ for (const item of [...leftItems, ...rightItems]) {
210
+ if (seen.has(item.id))
211
+ continue;
212
+ seen.add(item.id);
213
+ merged.push(item);
214
+ }
215
+ return merged;
216
+ }
217
+ async function runAudit(username, kind, range, onProgress) {
218
+ const raw = await fetchAllForRange(username, kind, range, onProgress);
219
+ const items = raw.map((i) => toContribution(i, kind));
220
+ items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
221
+ const repos = aggregateByRepo(items);
222
+ return {
223
+ kind,
224
+ username,
225
+ range,
226
+ total: items.length,
227
+ repos,
228
+ items
229
+ };
230
+ }
231
+
232
+ // ../core/dist/insights.js
233
+ function countByDay(items) {
234
+ const map = /* @__PURE__ */ new Map();
235
+ for (const item of items) {
236
+ const day = item.createdAt.slice(0, 10);
237
+ map.set(day, (map.get(day) ?? 0) + 1);
238
+ }
239
+ return map;
240
+ }
241
+ function busiestDay(items) {
242
+ const map = countByDay(items);
243
+ let best = null;
244
+ let bestCount = 0;
245
+ for (const [day, count] of map) {
246
+ if (count > bestCount) {
247
+ best = day;
248
+ bestCount = count;
249
+ }
250
+ }
251
+ return best;
252
+ }
253
+ function buildInsights(pr, issue, review) {
254
+ const allItems = [...pr.items, ...issue.items, ...review.items];
255
+ const repoTotals = /* @__PURE__ */ new Map();
256
+ for (const item of allItems) {
257
+ repoTotals.set(item.repo, (repoTotals.get(item.repo) ?? 0) + 1);
258
+ }
259
+ const uniqueRepos = repoTotals.size;
260
+ const reposHiddenByFeed = Math.max(0, uniqueRepos - ACTIVITY_FEED_REPO_CAP);
261
+ const reposVisibleOnFeed = Math.min(uniqueRepos, ACTIVITY_FEED_REPO_CAP);
262
+ const topRepos = [...repoTotals.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([repo, count]) => ({ repo, count }));
263
+ const merged = pr.items.filter((i) => i.state === "merged").length;
264
+ const mergeRate = pr.total > 0 ? Math.round(merged / pr.total * 100) : 0;
265
+ let feedTruncationNote = "Activity feed likely shows all repos for this range.";
266
+ if (reposHiddenByFeed > 0) {
267
+ feedTruncationNote = `GitHub's activity sidebar typically lists ~${ACTIVITY_FEED_REPO_CAP} repos before "N repositories not shown". Search API found ${uniqueRepos} repos \u2014 about ${reposHiddenByFeed} may be hidden on your profile feed.`;
268
+ }
269
+ return {
270
+ totalContributions: pr.total + issue.total + review.total,
271
+ uniqueRepos,
272
+ reposVisibleOnFeed,
273
+ reposHiddenByFeed,
274
+ feedTruncationNote,
275
+ topRepos,
276
+ busiestDay: busiestDay(allItems),
277
+ mergeRate,
278
+ byKind: { pr: pr.total, issue: issue.total, review: review.total }
279
+ };
280
+ }
281
+ async function runFullAudit(username, range, runAuditFn, onProgress) {
282
+ onProgress?.(`Auditing pull requests for @${username}\u2026`);
283
+ const pullRequests = await runAuditFn(username, "pr", range, onProgress);
284
+ onProgress?.(`Auditing issues for @${username}\u2026`);
285
+ const issues = await runAuditFn(username, "issue", range, onProgress);
286
+ onProgress?.(`Auditing reviews for @${username}\u2026`);
287
+ const reviews = await runAuditFn(username, "review", range, onProgress);
288
+ const insights = buildInsights(pullRequests, issues, reviews);
289
+ return {
290
+ username,
291
+ range,
292
+ pullRequests,
293
+ issues,
294
+ reviews,
295
+ insights
296
+ };
297
+ }
298
+
299
+ // ../core/dist/export.js
300
+ function escapeCsv(value) {
301
+ if (/[",\n\r]/.test(value)) {
302
+ return `"${value.replace(/"/g, '""')}"`;
303
+ }
304
+ return value;
305
+ }
306
+ function auditToJson(result) {
307
+ return JSON.stringify({
308
+ username: result.username,
309
+ kind: result.kind,
310
+ range: result.range,
311
+ total: result.total,
312
+ repoCount: result.repos.length,
313
+ repos: result.repos.map((r) => ({
314
+ repo: r.repo,
315
+ open: r.open,
316
+ merged: r.merged,
317
+ closed: r.closed,
318
+ count: r.items.length
319
+ })),
320
+ items: result.items
321
+ }, null, 2);
322
+ }
323
+ function fullAuditToJson(result) {
324
+ return JSON.stringify({
325
+ username: result.username,
326
+ range: result.range,
327
+ insights: result.insights,
328
+ pullRequests: {
329
+ total: result.pullRequests.total,
330
+ repos: result.pullRequests.repos.length
331
+ },
332
+ issues: { total: result.issues.total, repos: result.issues.repos.length },
333
+ reviews: { total: result.reviews.total, repos: result.reviews.repos.length },
334
+ pullRequestsItems: result.pullRequests.items,
335
+ issuesItems: result.issues.items,
336
+ reviewsItems: result.reviews.items
337
+ }, null, 2);
338
+ }
339
+ function auditToCsv(result) {
340
+ const header = ["repo", "number", "title", "state", "created_at", "closed_at", "url"];
341
+ const rows = result.items.map((item) => [
342
+ item.repo,
343
+ String(item.number),
344
+ item.title,
345
+ item.state,
346
+ item.createdAt,
347
+ item.closedAt ?? "",
348
+ item.url
349
+ ].map(escapeCsv).join(","));
350
+ return [header.join(","), ...rows].join("\n");
351
+ }
352
+ function fullAuditToCsv(result) {
353
+ const header = ["kind", "repo", "number", "title", "state", "created_at", "closed_at", "url"];
354
+ const rows = [];
355
+ for (const [kind, audit] of [
356
+ ["pr", result.pullRequests],
357
+ ["issue", result.issues],
358
+ ["review", result.reviews]
359
+ ]) {
360
+ for (const item of audit.items) {
361
+ rows.push([kind, item.repo, String(item.number), item.title, item.state, item.createdAt, item.closedAt ?? "", item.url].map(escapeCsv).join(","));
362
+ }
363
+ }
364
+ return [header.join(","), ...rows].join("\n");
365
+ }
366
+
367
+ // ../core/dist/index.js
368
+ var APP_NAME = "OpenHearth";
369
+
370
+ // src/format.ts
371
+ var dim = (s) => `\x1B[2m${s}\x1B[0m`;
372
+ var bold = (s) => `\x1B[1m${s}\x1B[0m`;
373
+ var amber = (s) => `\x1B[33m${s}\x1B[0m`;
374
+ var green = (s) => `\x1B[32m${s}\x1B[0m`;
375
+ var red = (s) => `\x1B[31m${s}\x1B[0m`;
376
+ function printBanner() {
377
+ console.log("");
378
+ console.log(bold(" OpenHearth") + dim(" \xB7 contribution audit"));
379
+ console.log("");
380
+ }
381
+ function printInsights(insights, rangeLabel2) {
382
+ console.log(bold(" Summary") + dim(` \xB7 ${rangeLabel2}`));
383
+ console.log("");
384
+ console.log(` Total contributions ${bold(String(insights.totalContributions))}`);
385
+ console.log(` Unique repositories ${bold(String(insights.uniqueRepos))}`);
386
+ console.log(` PR merge rate ${bold(`${insights.mergeRate}%`)}`);
387
+ console.log(
388
+ ` By kind PRs ${insights.byKind.pr} \xB7 Issues ${insights.byKind.issue} \xB7 Reviews ${insights.byKind.review}`
389
+ );
390
+ if (insights.busiestDay) {
391
+ console.log(` Busiest day ${insights.busiestDay}`);
392
+ }
393
+ console.log("");
394
+ if (insights.reposHiddenByFeed > 0) {
395
+ console.log(amber(" \u26A0 Hidden by activity feed"));
396
+ console.log(
397
+ dim(
398
+ ` Feed shows ~${insights.reposVisibleOnFeed} repos; Search API found ${insights.uniqueRepos}.`
399
+ )
400
+ );
401
+ console.log(
402
+ dim(` ~${insights.reposHiddenByFeed} repositories may not appear on your profile sidebar.`)
403
+ );
404
+ console.log("");
405
+ }
406
+ if (insights.topRepos.length > 0) {
407
+ console.log(bold(" Top repositories"));
408
+ for (const { repo, count } of insights.topRepos.slice(0, 8)) {
409
+ console.log(` ${dim("\xB7")} ${repo} ${dim(String(count))}`);
410
+ }
411
+ console.log("");
412
+ }
413
+ }
414
+ function printAuditSection(title, result) {
415
+ console.log(bold(` ${title}`) + dim(` \xB7 ${result.total} across ${result.repos.length} repos`));
416
+ if (result.repos.length === 0) {
417
+ console.log(dim(" (none)"));
418
+ console.log("");
419
+ return;
420
+ }
421
+ for (const repo of result.repos.slice(0, 6)) {
422
+ const parts = [
423
+ repo.merged ? green(`${repo.merged} merged`) : "",
424
+ repo.open ? `${repo.open} open` : "",
425
+ repo.closed ? dim(`${repo.closed} closed`) : ""
426
+ ].filter(Boolean);
427
+ console.log(` ${dim("\u25B8")} ${repo.repo} ${dim(parts.join(" \xB7 "))}`);
428
+ }
429
+ if (result.repos.length > 6) {
430
+ console.log(dim(` \u2026 and ${result.repos.length - 6} more repositories`));
431
+ }
432
+ console.log("");
433
+ }
434
+ function printFullReport(result, rangeLabel2) {
435
+ printInsights(result.insights, `@${result.username} \xB7 ${rangeLabel2}`);
436
+ printAuditSection("Pull requests", result.pullRequests);
437
+ printAuditSection("Issues", result.issues);
438
+ printAuditSection("Reviews", result.reviews);
439
+ console.log(dim(` ${result.insights.feedTruncationNote}`));
440
+ console.log("");
441
+ }
442
+ function printError(message) {
443
+ console.error(red(`
444
+ Error: ${message}
445
+ `));
446
+ }
447
+ function printProgress(message) {
448
+ console.error(dim(` ${message}`));
449
+ }
450
+
451
+ // src/cli.ts
5
452
  function usage() {
6
- return `
7
- ${APP_NAME} audit GitHub contributions the activity feed hides
453
+ return `
454
+ ${APP_NAME} \u2014 audit GitHub contributions the activity feed hides
8
455
 
9
456
  Usage:
10
457
  openhearth audit <username> [options]
@@ -31,147 +478,140 @@ Examples:
31
478
  npx @felix-ayush/openhearth audit torvalds --from 2026-01-01 --to 2026-01-31 --json report.json
32
479
 
33
480
  Unique features:
34
- · Finds ALL repos via Search API (not truncated like github.com activity)
35
- · Reports how many repos the profile sidebar likely hides
36
- · Auto-splits date ranges past GitHub's 1000-result search cap
37
- · Full audit: PRs + issues + reviews in one run
481
+ \xB7 Finds ALL repos via Search API (not truncated like github.com activity)
482
+ \xB7 Reports how many repos the profile sidebar likely hides
483
+ \xB7 Auto-splits date ranges past GitHub's 1000-result search cap
484
+ \xB7 Full audit: PRs + issues + reviews in one run
38
485
  `;
39
486
  }
40
487
  function parseArgs(argv) {
41
- const opts = {
42
- command: "audit",
43
- username: "",
44
- kind: "all",
45
- quiet: false,
46
- hidden: false,
47
- help: false,
48
- };
49
- const positional = [];
50
- for (let i = 0; i < argv.length; i++) {
51
- const arg = argv[i];
52
- if (arg === "-h" || arg === "--help") {
53
- opts.help = true;
54
- continue;
55
- }
56
- if (arg === "--quiet") {
57
- opts.quiet = true;
58
- continue;
59
- }
60
- if (arg === "--month" && argv[i + 1]) {
61
- opts.month = argv[++i];
62
- continue;
63
- }
64
- if (arg === "--from" && argv[i + 1]) {
65
- opts.from = argv[++i];
66
- continue;
67
- }
68
- if (arg === "--to" && argv[i + 1]) {
69
- opts.to = argv[++i];
70
- continue;
71
- }
72
- if (arg === "--token" && argv[i + 1]) {
73
- opts.token = argv[++i];
74
- continue;
75
- }
76
- if (arg === "--kind" && argv[i + 1]) {
77
- opts.kind = argv[++i];
78
- continue;
79
- }
80
- if (arg === "--json") {
81
- opts.json = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : "-";
82
- continue;
83
- }
84
- if (arg === "--csv") {
85
- opts.csv = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : "-";
86
- continue;
87
- }
88
- if (!arg.startsWith("-")) {
89
- positional.push(arg);
90
- }
91
- }
92
- if (positional[0] === "audit" || positional[0] === "hidden") {
93
- opts.command = positional[0];
94
- opts.username = (positional[1] ?? "").replace(/^@/, "");
95
- if (opts.command === "hidden")
96
- opts.hidden = true;
97
- }
98
- else {
99
- opts.username = (positional[0] ?? "").replace(/^@/, "");
100
- }
101
- return opts;
102
- }
103
- function resolveRange(opts) {
104
- if (opts.month) {
105
- return parseMonth(opts.month);
488
+ const opts = {
489
+ command: "audit",
490
+ username: "",
491
+ kind: "all",
492
+ quiet: false,
493
+ hidden: false,
494
+ help: false
495
+ };
496
+ const positional = [];
497
+ for (let i = 0; i < argv.length; i++) {
498
+ const arg = argv[i];
499
+ if (arg === "-h" || arg === "--help") {
500
+ opts.help = true;
501
+ continue;
502
+ }
503
+ if (arg === "--quiet") {
504
+ opts.quiet = true;
505
+ continue;
506
+ }
507
+ if (arg === "--month" && argv[i + 1]) {
508
+ opts.month = argv[++i];
509
+ continue;
510
+ }
511
+ if (arg === "--from" && argv[i + 1]) {
512
+ opts.from = argv[++i];
513
+ continue;
514
+ }
515
+ if (arg === "--to" && argv[i + 1]) {
516
+ opts.to = argv[++i];
517
+ continue;
518
+ }
519
+ if (arg === "--token" && argv[i + 1]) {
520
+ opts.token = argv[++i];
521
+ continue;
522
+ }
523
+ if (arg === "--kind" && argv[i + 1]) {
524
+ opts.kind = argv[++i];
525
+ continue;
106
526
  }
107
- if (opts.from && opts.to) {
108
- return { from: opts.from, to: opts.to };
527
+ if (arg === "--json") {
528
+ opts.json = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : "-";
529
+ continue;
109
530
  }
110
- const now = new Date();
111
- const year = now.getFullYear();
112
- const month = now.getMonth() + 1;
113
- return parseMonth(`${year}-${String(month).padStart(2, "0")}`);
531
+ if (arg === "--csv") {
532
+ opts.csv = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : "-";
533
+ continue;
534
+ }
535
+ if (!arg.startsWith("-")) {
536
+ positional.push(arg);
537
+ }
538
+ }
539
+ if (positional[0] === "audit" || positional[0] === "hidden") {
540
+ opts.command = positional[0];
541
+ opts.username = (positional[1] ?? "").replace(/^@/, "");
542
+ if (opts.command === "hidden") opts.hidden = true;
543
+ } else {
544
+ opts.username = (positional[0] ?? "").replace(/^@/, "");
545
+ }
546
+ return opts;
547
+ }
548
+ function resolveRange(opts) {
549
+ if (opts.month) {
550
+ return parseMonth(opts.month);
551
+ }
552
+ if (opts.from && opts.to) {
553
+ return { from: opts.from, to: opts.to };
554
+ }
555
+ const now = /* @__PURE__ */ new Date();
556
+ const year = now.getFullYear();
557
+ const month = now.getMonth() + 1;
558
+ return parseMonth(`${year}-${String(month).padStart(2, "0")}`);
114
559
  }
115
560
  function rangeLabel(range, month) {
116
- if (month)
117
- return month;
118
- return `${range.from} → ${range.to}`;
561
+ if (month) return month;
562
+ return `${range.from} \u2192 ${range.to}`;
119
563
  }
120
564
  function writeOutput(content, path) {
121
- if (!path || path === "-") {
122
- console.log(content);
123
- return;
124
- }
125
- writeFileSync(path, content, "utf8");
126
- console.error(` Wrote ${path}`);
565
+ if (!path || path === "-") {
566
+ console.log(content);
567
+ return;
568
+ }
569
+ writeFileSync(path, content, "utf8");
570
+ console.error(` Wrote ${path}`);
127
571
  }
128
572
  async function main() {
129
- const opts = parseArgs(process.argv.slice(2));
130
- if (opts.help || !opts.username) {
131
- console.log(usage());
132
- process.exit(opts.help ? 0 : 1);
133
- }
134
- if (opts.token)
135
- setAuthToken(opts.token);
136
- const range = resolveRange(opts);
137
- const label = rangeLabel(range, opts.month);
138
- const onProgress = opts.quiet ? undefined : printProgress;
139
- if (!opts.quiet)
140
- printBanner();
141
- try {
142
- if (opts.hidden || opts.command === "hidden") {
143
- const full = await runFullAudit(opts.username, range, runAudit, onProgress);
144
- if (!opts.quiet) {
145
- printInsights(full.insights, `@${opts.username} · ${label}`);
146
- console.log(` ${full.insights.feedTruncationNote}\n`);
147
- }
148
- if (opts.json)
149
- writeOutput(fullAuditToJson(full), opts.json);
150
- return;
151
- }
152
- if (opts.kind === "all") {
153
- const full = await runFullAudit(opts.username, range, runAudit, onProgress);
154
- if (opts.json)
155
- writeOutput(fullAuditToJson(full), opts.json);
156
- if (opts.csv)
157
- writeOutput(fullAuditToCsv(full), opts.csv);
158
- if (!opts.quiet && !opts.json && !opts.csv) {
159
- printFullReport(full, label);
160
- }
161
- return;
162
- }
163
- const result = await runAudit(opts.username, opts.kind, range, onProgress);
164
- if (opts.json)
165
- writeOutput(auditToJson(result), opts.json);
166
- if (opts.csv)
167
- writeOutput(auditToCsv(result), opts.csv);
168
- if (!opts.quiet && !opts.json && !opts.csv) {
169
- printAuditSection(opts.kind === "pr" ? "Pull requests" : opts.kind === "issue" ? "Issues" : "Reviews", result);
170
- }
171
- }
172
- catch (err) {
173
- printError(err instanceof Error ? err.message : String(err));
174
- process.exit(1);
573
+ const opts = parseArgs(process.argv.slice(2));
574
+ if (opts.help || !opts.username) {
575
+ console.log(usage());
576
+ process.exit(opts.help ? 0 : 1);
577
+ }
578
+ if (opts.token) setAuthToken(opts.token);
579
+ const range = resolveRange(opts);
580
+ const label = rangeLabel(range, opts.month);
581
+ const onProgress = opts.quiet ? void 0 : printProgress;
582
+ if (!opts.quiet) printBanner();
583
+ try {
584
+ if (opts.hidden || opts.command === "hidden") {
585
+ const full = await runFullAudit(opts.username, range, runAudit, onProgress);
586
+ if (!opts.quiet) {
587
+ printInsights(full.insights, `@${opts.username} \xB7 ${label}`);
588
+ console.log(` ${full.insights.feedTruncationNote}
589
+ `);
590
+ }
591
+ if (opts.json) writeOutput(fullAuditToJson(full), opts.json);
592
+ return;
593
+ }
594
+ if (opts.kind === "all") {
595
+ const full = await runFullAudit(opts.username, range, runAudit, onProgress);
596
+ if (opts.json) writeOutput(fullAuditToJson(full), opts.json);
597
+ if (opts.csv) writeOutput(fullAuditToCsv(full), opts.csv);
598
+ if (!opts.quiet && !opts.json && !opts.csv) {
599
+ printFullReport(full, label);
600
+ }
601
+ return;
602
+ }
603
+ const result = await runAudit(opts.username, opts.kind, range, onProgress);
604
+ if (opts.json) writeOutput(auditToJson(result), opts.json);
605
+ if (opts.csv) writeOutput(auditToCsv(result), opts.csv);
606
+ if (!opts.quiet && !opts.json && !opts.csv) {
607
+ printAuditSection(
608
+ opts.kind === "pr" ? "Pull requests" : opts.kind === "issue" ? "Issues" : "Reviews",
609
+ result
610
+ );
175
611
  }
612
+ } catch (err) {
613
+ printError(err instanceof Error ? err.message : String(err));
614
+ process.exit(1);
615
+ }
176
616
  }
177
617
  main();
package/package.json CHANGED
@@ -1,31 +1,47 @@
1
1
  {
2
2
  "name": "@felix-ayush/openhearth",
3
- "version": "2.0.0",
4
- "description": "CLI to audit every GitHub PR, issue, and review — including what the activity feed hides",
3
+ "version": "2.1.0",
4
+ "description": "CLI to audit every GitHub PR, issue, and review — find repos hidden from your activity feed (\"N repositories not shown\")",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "openhearth": "./dist/cli.js"
8
8
  },
9
- "files": ["dist"],
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
10
13
  "scripts": {
11
- "build": "tsc",
12
- "dev": "tsc --watch",
14
+ "build": "node build.mjs",
15
+ "dev": "node build.mjs && node dist/cli.js",
13
16
  "start": "node dist/cli.js",
14
17
  "prepublishOnly": "npm run build"
15
18
  },
16
19
  "keywords": [
17
20
  "github",
21
+ "github-contributions",
18
22
  "contributions",
19
23
  "pull-requests",
24
+ "issues",
25
+ "code-review",
20
26
  "open-source",
27
+ "oss",
21
28
  "audit",
22
- "cli"
29
+ "cli",
30
+ "developer-tools",
31
+ "activity-feed",
32
+ "contribution-graph",
33
+ "search-api",
34
+ "openhearth"
23
35
  ],
24
36
  "license": "MIT",
25
- "author": "Ayush Kumar",
37
+ "author": {
38
+ "name": "Ayush Kumar",
39
+ "url": "https://github.com/Ayush7614"
40
+ },
26
41
  "repository": {
27
42
  "type": "git",
28
- "url": "https://github.com/Ayush7614/OpenHearth.git"
43
+ "url": "git+https://github.com/Ayush7614/OpenHearth.git",
44
+ "directory": "packages/cli"
29
45
  },
30
46
  "homepage": "https://ayush7614.github.io/OpenHearth/",
31
47
  "publishConfig": {
@@ -37,11 +53,10 @@
37
53
  "engines": {
38
54
  "node": ">=20"
39
55
  },
40
- "dependencies": {
41
- "@felix-ayush/openhearth-core": "2.0.0"
42
- },
43
56
  "devDependencies": {
57
+ "@felix-ayush/openhearth-core": "2.0.1",
44
58
  "@types/node": "^22.13.10",
59
+ "esbuild": "^0.25.0",
45
60
  "typescript": "^5.8.2"
46
61
  }
47
62
  }