@felix-ayush/openhearth 2.0.0 → 2.2.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 +132 -0
  2. package/dist/cli.js +737 -134
  3. package/package.json +26 -11
package/README.md ADDED
@@ -0,0 +1,132 @@
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
+ # Ranked report: repos likely hidden from your activity sidebar
41
+ openhearth hidden Ayush7614 --month 2026-07
42
+
43
+ # Check auth + Search API quota
44
+ openhearth doctor
45
+
46
+ # Export for spreadsheets or dashboards
47
+ openhearth audit Ayush7614 --month 2026-07 --json report.json
48
+ openhearth audit Ayush7614 --month 2026-07 --csv report.csv
49
+ ```
50
+
51
+ ## Commands
52
+
53
+ | Command | Description |
54
+ |---------|-------------|
55
+ | `openhearth audit <user>` | Full PR + issue + review audit (default) |
56
+ | `openhearth hidden <user>` | Ranked likely-hidden repos vs activity sidebar |
57
+ | `openhearth doctor` | Auth + GitHub rate-limit status |
58
+ | `--month YYYY-MM` | Audit a calendar month |
59
+ | `--from YYYY-MM-DD` | Custom range start |
60
+ | `--to YYYY-MM-DD` | Custom range end |
61
+ | `--kind pr\|issue\|review\|all` | Filter by contribution type |
62
+ | `--token TOKEN` | GitHub PAT (or use `GITHUB_TOKEN` env) |
63
+ | `--json [file]` | Export JSON (`stdout` if no file) |
64
+ | `--csv [file]` | Export CSV |
65
+ | `--quiet` | Minimal terminal output |
66
+ | `-V, --version` | Print CLI version |
67
+
68
+ ## What makes OpenHearth different
69
+
70
+ - **Ranked hidden repos** — lower-activity repos past the ~25 sidebar cap, least activity first
71
+ - **Clear rate-limit errors** — tells you to set `GITHUB_TOKEN` (or when quota resets)
72
+ - **Doctor** — `openhearth doctor` checks auth and Search/Core API limits
73
+ - **GitHub Action** — monthly/on-demand audits with JSON/CSV artifacts
74
+ - **Full Search API pagination** — fetches all results, not just the first page
75
+ - **Auto date-splitting** — when a month exceeds GitHub’s 1000-result search cap
76
+ - **Export** — JSON and CSV for your own tooling
77
+
78
+ ## Authentication (recommended)
79
+
80
+ Without a token, the unauthenticated Search API is limited (~60 requests/hour).
81
+
82
+ ```bash
83
+ export GITHUB_TOKEN=ghp_your_token_here
84
+ openhearth audit USERNAME --month 2026-07
85
+ ```
86
+
87
+ A classic PAT with **public read** access is enough. The token is sent only to `api.github.com`.
88
+
89
+ If you hit a limit, the CLI prints a fix hint — or run `openhearth doctor`.
90
+
91
+ ## GitHub Action
92
+
93
+ This repository includes `.github/workflows/audit.yml`:
94
+
95
+ - **workflow_dispatch** — pick username + month
96
+ - **schedule** — 1st of each month (previous calendar month)
97
+ - Uploads JSON/CSV artifacts from `audit` and `hidden`
98
+
99
+ ## Example output
100
+
101
+ ```
102
+ OpenHearth · contribution audit
103
+
104
+ Summary · @Ayush7614 · 2026-07
105
+
106
+ Total contributions 494
107
+ Unique repositories 78
108
+ PR merge rate 51%
109
+
110
+ ⚠ Hidden by activity feed
111
+ Feed shows ~25 busiest repos; Search API found 78.
112
+ ~53 lower-activity repositories are likely truncated.
113
+
114
+ Likely hidden repositories · least activity first
115
+ · small-org/side-project 1
116
+ · another/low-activity 2
117
+ ...
118
+ ```
119
+
120
+ ## Links
121
+
122
+ - **Website & docs:** [ayush7614.github.io/OpenHearth](https://ayush7614.github.io/OpenHearth/)
123
+ - **Source:** [github.com/Ayush7614/OpenHearth](https://github.com/Ayush7614/OpenHearth)
124
+ - **Issues:** [github.com/Ayush7614/OpenHearth/issues](https://github.com/Ayush7614/OpenHearth/issues)
125
+
126
+ ## Author
127
+
128
+ **Ayush Kumar** — [@Ayush7614](https://github.com/Ayush7614)
129
+
130
+ ## License
131
+
132
+ MIT
package/dist/cli.js CHANGED
@@ -1,18 +1,581 @@
1
1
  #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { readFileSync } from "node:fs";
2
5
  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";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
8
+
9
+ // ../core/dist/github.js
10
+ var ACTIVITY_FEED_REPO_CAP = 25;
11
+ var authToken = "";
12
+ function setAuthToken(token) {
13
+ authToken = token.trim();
14
+ }
15
+ function getAuthToken() {
16
+ if (authToken)
17
+ return authToken;
18
+ if (typeof process !== "undefined" && process.env) {
19
+ return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "";
20
+ }
21
+ return "";
22
+ }
23
+ var lastRateLimit = {
24
+ remaining: -1,
25
+ limit: -1,
26
+ reset: 0
27
+ };
28
+ function updateRateLimit(res) {
29
+ const remaining = res.headers.get("X-RateLimit-Remaining");
30
+ const limit = res.headers.get("X-RateLimit-Limit");
31
+ const reset = res.headers.get("X-RateLimit-Reset");
32
+ if (remaining != null)
33
+ lastRateLimit.remaining = Number(remaining);
34
+ if (limit != null)
35
+ lastRateLimit.limit = Number(limit);
36
+ if (reset != null)
37
+ lastRateLimit.reset = Number(reset);
38
+ }
39
+ var GitHubApiError = class extends Error {
40
+ status;
41
+ constructor(message, status) {
42
+ super(message);
43
+ this.name = "GitHubApiError";
44
+ this.status = status;
45
+ }
46
+ };
47
+ function formatRateLimitHint() {
48
+ const hasToken = Boolean(getAuthToken());
49
+ const { remaining, limit, reset } = lastRateLimit;
50
+ const lines = [];
51
+ if (!hasToken) {
52
+ lines.push("Unauthenticated Search API is limited (~60 requests/hour).", "Set GITHUB_TOKEN or pass --token for ~5,000 requests/hour.", "Create a token: https://github.com/settings/tokens", "Then: export GITHUB_TOKEN=YOUR_TOKEN");
53
+ } else if (reset > 0) {
54
+ const resetAt = new Date(reset * 1e3).toISOString().replace(/\.\d{3}Z$/, "Z");
55
+ const quota = remaining >= 0 && limit > 0 ? `${remaining}/${limit} remaining` : "quota exhausted";
56
+ lines.push(`Authenticated rate limit: ${quota}. Resets at ${resetAt}.`);
57
+ lines.push("Wait for the reset, or retry with openhearth doctor to check status.");
58
+ } else {
59
+ lines.push("Set GITHUB_TOKEN or pass --token, then retry.");
60
+ }
61
+ return lines.map((l) => ` ${l}`).join("\n");
62
+ }
63
+ async function githubFetch(url) {
64
+ const headers = {
65
+ Accept: "application/vnd.github+json",
66
+ "X-GitHub-Api-Version": "2022-11-28"
67
+ };
68
+ const token = getAuthToken();
69
+ if (token) {
70
+ headers.Authorization = `Bearer ${token}`;
71
+ }
72
+ const res = await fetch(url, { headers });
73
+ updateRateLimit(res);
74
+ if (res.status === 403 || res.status === 429) {
75
+ const body = await res.json().catch(() => ({}));
76
+ const apiMsg = body.message ?? "GitHub rate limit exceeded.";
77
+ throw new GitHubApiError(`${apiMsg}
78
+
79
+ ${formatRateLimitHint()}`, res.status);
80
+ }
81
+ if (!res.ok) {
82
+ const body = await res.json().catch(() => ({}));
83
+ const msg = body.message ?? `GitHub API error (${res.status})`;
84
+ throw new GitHubApiError(msg, res.status);
85
+ }
86
+ return res;
87
+ }
88
+ async function checkRateLimit() {
89
+ const res = await githubFetch("https://api.github.com/rate_limit");
90
+ const data = await res.json();
91
+ return {
92
+ authenticated: Boolean(getAuthToken()),
93
+ core: {
94
+ remaining: data.resources.core.remaining,
95
+ limit: data.resources.core.limit,
96
+ reset: data.resources.core.reset
97
+ },
98
+ search: {
99
+ remaining: data.resources.search.remaining,
100
+ limit: data.resources.search.limit,
101
+ reset: data.resources.search.reset
102
+ }
103
+ };
104
+ }
105
+ async function searchIssuesPage(query, page) {
106
+ const params = new URLSearchParams({
107
+ q: query,
108
+ per_page: "100",
109
+ page: String(page),
110
+ sort: "created",
111
+ order: "desc"
112
+ });
113
+ const res = await githubFetch(`https://api.github.com/search/issues?${params.toString()}`);
114
+ return await res.json();
115
+ }
116
+ async function searchIssuesAll(query, onProgress) {
117
+ const first = await searchIssuesPage(query, 1);
118
+ const total = first.total_count;
119
+ const items = [...first.items];
120
+ onProgress?.(items.length, Math.min(total, 1e3));
121
+ if (total === 0) {
122
+ return { items, total_count: 0 };
123
+ }
124
+ const pagesNeeded = Math.min(Math.ceil(Math.min(total, 1e3) / 100), 10);
125
+ for (let page = 2; page <= pagesNeeded; page++) {
126
+ const next = await searchIssuesPage(query, page);
127
+ items.push(...next.items);
128
+ onProgress?.(items.length, Math.min(total, 1e3));
129
+ }
130
+ return { items, total_count: total };
131
+ }
132
+ function repoFullNameFromUrl(repositoryUrl) {
133
+ const parts = repositoryUrl.replace(/\/$/, "").split("/");
134
+ const name = parts.pop() ?? "";
135
+ const owner = parts.pop() ?? "";
136
+ return `${owner}/${name}`;
137
+ }
138
+
139
+ // ../core/dist/queries.js
140
+ function pad(n) {
141
+ return String(n).padStart(2, "0");
142
+ }
143
+ function formatDate(d) {
144
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
145
+ }
146
+ function monthRange(year, month) {
147
+ const from = new Date(year, month - 1, 1);
148
+ const to = new Date(year, month, 0);
149
+ return { from: formatDate(from), to: formatDate(to) };
150
+ }
151
+ function parseMonth(value) {
152
+ const match = /^(\d{4})-(\d{2})$/.exec(value.trim());
153
+ if (!match) {
154
+ throw new Error(`Invalid month "${value}". Use YYYY-MM (e.g. 2026-07).`);
155
+ }
156
+ const year = Number(match[1]);
157
+ const month = Number(match[2]);
158
+ if (month < 1 || month > 12) {
159
+ throw new Error(`Invalid month "${value}". Month must be 01\u201312.`);
160
+ }
161
+ return monthRange(year, month);
162
+ }
163
+ function buildSearchQuery(username, kind, range) {
164
+ const created = `created:${range.from}..${range.to}`;
165
+ switch (kind) {
166
+ case "pr":
167
+ return `author:${username} is:pr ${created}`;
168
+ case "issue":
169
+ return `author:${username} is:issue ${created}`;
170
+ case "review":
171
+ return `reviewed-by:${username} is:pr ${created}`;
172
+ }
173
+ }
174
+ function splitRange(range) {
175
+ const start = /* @__PURE__ */ new Date(range.from + "T00:00:00");
176
+ const end = /* @__PURE__ */ new Date(range.to + "T00:00:00");
177
+ const midMs = start.getTime() + Math.floor((end.getTime() - start.getTime()) / 2);
178
+ const mid = new Date(midMs);
179
+ const midNext = new Date(mid);
180
+ midNext.setDate(midNext.getDate() + 1);
181
+ return [
182
+ { from: formatDate(start), to: formatDate(mid) },
183
+ { from: formatDate(midNext), to: formatDate(end) }
184
+ ];
185
+ }
186
+ function rangeDayCount(range) {
187
+ const start = /* @__PURE__ */ new Date(range.from + "T00:00:00");
188
+ const end = /* @__PURE__ */ new Date(range.to + "T00:00:00");
189
+ return Math.round((end.getTime() - start.getTime()) / 864e5) + 1;
190
+ }
191
+
192
+ // ../core/dist/aggregate.js
193
+ function classifyState(item, kind) {
194
+ if (kind === "pr" || kind === "review") {
195
+ if (item.pull_request?.merged_at)
196
+ return "merged";
197
+ if (item.state === "open")
198
+ return "open";
199
+ return "closed";
200
+ }
201
+ return item.state === "open" ? "open" : "closed";
202
+ }
203
+ function toContribution(item, kind) {
204
+ return {
205
+ id: item.id,
206
+ number: item.number,
207
+ title: item.title,
208
+ url: item.html_url,
209
+ state: classifyState(item, kind),
210
+ createdAt: item.created_at,
211
+ closedAt: item.closed_at,
212
+ repo: repoFullNameFromUrl(item.repository_url)
213
+ };
214
+ }
215
+ function aggregateByRepo(items) {
216
+ const map = /* @__PURE__ */ new Map();
217
+ for (const item of items) {
218
+ let bucket = map.get(item.repo);
219
+ if (!bucket) {
220
+ bucket = { repo: item.repo, open: 0, merged: 0, closed: 0, items: [] };
221
+ map.set(item.repo, bucket);
222
+ }
223
+ bucket.items.push(item);
224
+ if (item.state === "open")
225
+ bucket.open++;
226
+ else if (item.state === "merged")
227
+ bucket.merged++;
228
+ else
229
+ bucket.closed++;
230
+ }
231
+ return [...map.values()].sort((a, b) => b.items.length - a.items.length);
232
+ }
233
+ async function fetchAllForRange(username, kind, range, onProgress) {
234
+ const query = buildSearchQuery(username, kind, range);
235
+ onProgress?.(`Searching: ${query}`);
236
+ const { items, total_count } = await searchIssuesAll(query, (fetched, capped) => {
237
+ onProgress?.(`Fetched ${fetched} / ${capped}`);
238
+ });
239
+ if (total_count <= 1e3) {
240
+ return items;
241
+ }
242
+ if (rangeDayCount(range) < 2) {
243
+ onProgress?.(`Warning: ${total_count} results in one day; only first 1000 returned for ${range.from}`);
244
+ return items;
245
+ }
246
+ const [left, right] = splitRange(range);
247
+ onProgress?.(`Over 1000 results (${total_count}). Splitting ${range.from}..${range.to}`);
248
+ const leftItems = await fetchAllForRange(username, kind, left, onProgress);
249
+ const rightItems = await fetchAllForRange(username, kind, right, onProgress);
250
+ const seen = /* @__PURE__ */ new Set();
251
+ const merged = [];
252
+ for (const item of [...leftItems, ...rightItems]) {
253
+ if (seen.has(item.id))
254
+ continue;
255
+ seen.add(item.id);
256
+ merged.push(item);
257
+ }
258
+ return merged;
259
+ }
260
+ async function runAudit(username, kind, range, onProgress) {
261
+ const raw = await fetchAllForRange(username, kind, range, onProgress);
262
+ const items = raw.map((i) => toContribution(i, kind));
263
+ items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
264
+ const repos = aggregateByRepo(items);
265
+ return {
266
+ kind,
267
+ username,
268
+ range,
269
+ total: items.length,
270
+ repos,
271
+ items
272
+ };
273
+ }
274
+
275
+ // ../core/dist/insights.js
276
+ function countByDay(items) {
277
+ const map = /* @__PURE__ */ new Map();
278
+ for (const item of items) {
279
+ const day = item.createdAt.slice(0, 10);
280
+ map.set(day, (map.get(day) ?? 0) + 1);
281
+ }
282
+ return map;
283
+ }
284
+ function busiestDay(items) {
285
+ const map = countByDay(items);
286
+ let best = null;
287
+ let bestCount = 0;
288
+ for (const [day, count] of map) {
289
+ if (count > bestCount) {
290
+ best = day;
291
+ bestCount = count;
292
+ }
293
+ }
294
+ return best;
295
+ }
296
+ function buildInsights(pr, issue, review) {
297
+ const allItems = [...pr.items, ...issue.items, ...review.items];
298
+ const repoTotals = /* @__PURE__ */ new Map();
299
+ for (const item of allItems) {
300
+ repoTotals.set(item.repo, (repoTotals.get(item.repo) ?? 0) + 1);
301
+ }
302
+ const uniqueRepos = repoTotals.size;
303
+ const reposHiddenByFeed = Math.max(0, uniqueRepos - ACTIVITY_FEED_REPO_CAP);
304
+ const reposVisibleOnFeed = Math.min(uniqueRepos, ACTIVITY_FEED_REPO_CAP);
305
+ const ranked = [...repoTotals.entries()].sort((a, b) => {
306
+ if (b[1] !== a[1])
307
+ return b[1] - a[1];
308
+ return a[0].localeCompare(b[0]);
309
+ });
310
+ const topRepos = ranked.slice(0, 10).map(([repo, count]) => ({ repo, count }));
311
+ const likelyHiddenRepos = ranked.slice(ACTIVITY_FEED_REPO_CAP).map(([repo, count]) => ({ repo, count })).sort((a, b) => {
312
+ if (a.count !== b.count)
313
+ return a.count - b.count;
314
+ return a.repo.localeCompare(b.repo);
315
+ });
316
+ const merged = pr.items.filter((i) => i.state === "merged").length;
317
+ const mergeRate = pr.total > 0 ? Math.round(merged / pr.total * 100) : 0;
318
+ let feedTruncationNote = "Activity feed likely shows all repos for this range.";
319
+ if (reposHiddenByFeed > 0) {
320
+ feedTruncationNote = `GitHub's activity sidebar typically lists ~${ACTIVITY_FEED_REPO_CAP} busiest repos before "N repositories not shown". Search API found ${uniqueRepos} repos \u2014 about ${reposHiddenByFeed} lower-activity repos are likely hidden. Listed below as \u201Clikely hidden\u201D (least activity first).`;
321
+ }
322
+ return {
323
+ totalContributions: pr.total + issue.total + review.total,
324
+ uniqueRepos,
325
+ reposVisibleOnFeed,
326
+ reposHiddenByFeed,
327
+ feedTruncationNote,
328
+ topRepos,
329
+ likelyHiddenRepos,
330
+ busiestDay: busiestDay(allItems),
331
+ mergeRate,
332
+ byKind: { pr: pr.total, issue: issue.total, review: review.total }
333
+ };
334
+ }
335
+ async function runFullAudit(username, range, runAuditFn, onProgress) {
336
+ onProgress?.(`Auditing pull requests for @${username}\u2026`);
337
+ const pullRequests = await runAuditFn(username, "pr", range, onProgress);
338
+ onProgress?.(`Auditing issues for @${username}\u2026`);
339
+ const issues = await runAuditFn(username, "issue", range, onProgress);
340
+ onProgress?.(`Auditing reviews for @${username}\u2026`);
341
+ const reviews = await runAuditFn(username, "review", range, onProgress);
342
+ const insights = buildInsights(pullRequests, issues, reviews);
343
+ return {
344
+ username,
345
+ range,
346
+ pullRequests,
347
+ issues,
348
+ reviews,
349
+ insights
350
+ };
351
+ }
352
+
353
+ // ../core/dist/export.js
354
+ function escapeCsv(value) {
355
+ if (/[",\n\r]/.test(value)) {
356
+ return `"${value.replace(/"/g, '""')}"`;
357
+ }
358
+ return value;
359
+ }
360
+ function auditToJson(result) {
361
+ return JSON.stringify({
362
+ username: result.username,
363
+ kind: result.kind,
364
+ range: result.range,
365
+ total: result.total,
366
+ repoCount: result.repos.length,
367
+ repos: result.repos.map((r) => ({
368
+ repo: r.repo,
369
+ open: r.open,
370
+ merged: r.merged,
371
+ closed: r.closed,
372
+ count: r.items.length
373
+ })),
374
+ items: result.items
375
+ }, null, 2);
376
+ }
377
+ function fullAuditToJson(result) {
378
+ return JSON.stringify({
379
+ username: result.username,
380
+ range: result.range,
381
+ insights: result.insights,
382
+ pullRequests: {
383
+ total: result.pullRequests.total,
384
+ repos: result.pullRequests.repos.length
385
+ },
386
+ issues: { total: result.issues.total, repos: result.issues.repos.length },
387
+ reviews: { total: result.reviews.total, repos: result.reviews.repos.length },
388
+ pullRequestsItems: result.pullRequests.items,
389
+ issuesItems: result.issues.items,
390
+ reviewsItems: result.reviews.items
391
+ }, null, 2);
392
+ }
393
+ function auditToCsv(result) {
394
+ const header = ["repo", "number", "title", "state", "created_at", "closed_at", "url"];
395
+ const rows = result.items.map((item) => [
396
+ item.repo,
397
+ String(item.number),
398
+ item.title,
399
+ item.state,
400
+ item.createdAt,
401
+ item.closedAt ?? "",
402
+ item.url
403
+ ].map(escapeCsv).join(","));
404
+ return [header.join(","), ...rows].join("\n");
405
+ }
406
+ function fullAuditToCsv(result) {
407
+ const header = ["kind", "repo", "number", "title", "state", "created_at", "closed_at", "url"];
408
+ const rows = [];
409
+ for (const [kind, audit] of [
410
+ ["pr", result.pullRequests],
411
+ ["issue", result.issues],
412
+ ["review", result.reviews]
413
+ ]) {
414
+ for (const item of audit.items) {
415
+ rows.push([kind, item.repo, String(item.number), item.title, item.state, item.createdAt, item.closedAt ?? "", item.url].map(escapeCsv).join(","));
416
+ }
417
+ }
418
+ return [header.join(","), ...rows].join("\n");
419
+ }
420
+
421
+ // ../core/dist/index.js
422
+ var APP_NAME = "OpenHearth";
423
+
424
+ // src/format.ts
425
+ var dim = (s) => `\x1B[2m${s}\x1B[0m`;
426
+ var bold = (s) => `\x1B[1m${s}\x1B[0m`;
427
+ var amber = (s) => `\x1B[33m${s}\x1B[0m`;
428
+ var green = (s) => `\x1B[32m${s}\x1B[0m`;
429
+ var red = (s) => `\x1B[31m${s}\x1B[0m`;
430
+ function printBanner() {
431
+ console.log("");
432
+ console.log(bold(" OpenHearth") + dim(" \xB7 contribution audit"));
433
+ console.log("");
434
+ }
435
+ function printInsights(insights, rangeLabel2) {
436
+ console.log(bold(" Summary") + dim(` \xB7 ${rangeLabel2}`));
437
+ console.log("");
438
+ console.log(` Total contributions ${bold(String(insights.totalContributions))}`);
439
+ console.log(` Unique repositories ${bold(String(insights.uniqueRepos))}`);
440
+ console.log(` PR merge rate ${bold(`${insights.mergeRate}%`)}`);
441
+ console.log(
442
+ ` By kind PRs ${insights.byKind.pr} \xB7 Issues ${insights.byKind.issue} \xB7 Reviews ${insights.byKind.review}`
443
+ );
444
+ if (insights.busiestDay) {
445
+ console.log(` Busiest day ${insights.busiestDay}`);
446
+ }
447
+ console.log("");
448
+ if (insights.reposHiddenByFeed > 0) {
449
+ console.log(amber(" \u26A0 Hidden by activity feed"));
450
+ console.log(
451
+ dim(
452
+ ` Feed shows ~${insights.reposVisibleOnFeed} busiest repos; Search API found ${insights.uniqueRepos}.`
453
+ )
454
+ );
455
+ console.log(
456
+ dim(` ~${insights.reposHiddenByFeed} lower-activity repositories are likely truncated.`)
457
+ );
458
+ console.log("");
459
+ }
460
+ if (insights.topRepos.length > 0) {
461
+ console.log(bold(" Top repositories") + dim(" \xB7 likely visible on feed"));
462
+ for (const { repo, count } of insights.topRepos.slice(0, 8)) {
463
+ console.log(` ${dim("\xB7")} ${repo} ${dim(String(count))}`);
464
+ }
465
+ console.log("");
466
+ }
467
+ }
468
+ function printLikelyHidden(insights, limit = 20) {
469
+ if (insights.likelyHiddenRepos.length === 0) return;
470
+ console.log(
471
+ bold(" Likely hidden repositories") + dim(` \xB7 ${insights.likelyHiddenRepos.length} past the ~${insights.reposVisibleOnFeed} sidebar cap`)
472
+ );
473
+ console.log(dim(" Ranked least activity first (most likely truncated)."));
474
+ console.log("");
475
+ for (const { repo, count } of insights.likelyHiddenRepos.slice(0, limit)) {
476
+ console.log(` ${dim("\xB7")} ${repo} ${dim(String(count))}`);
477
+ }
478
+ if (insights.likelyHiddenRepos.length > limit) {
479
+ console.log(dim(` \u2026 and ${insights.likelyHiddenRepos.length - limit} more`));
480
+ }
481
+ console.log("");
482
+ }
483
+ function printAuditSection(title, result) {
484
+ console.log(bold(` ${title}`) + dim(` \xB7 ${result.total} across ${result.repos.length} repos`));
485
+ if (result.repos.length === 0) {
486
+ console.log(dim(" (none)"));
487
+ console.log("");
488
+ return;
489
+ }
490
+ for (const repo of result.repos.slice(0, 6)) {
491
+ const parts = [
492
+ repo.merged ? green(`${repo.merged} merged`) : "",
493
+ repo.open ? `${repo.open} open` : "",
494
+ repo.closed ? dim(`${repo.closed} closed`) : ""
495
+ ].filter(Boolean);
496
+ console.log(` ${dim("\u25B8")} ${repo.repo} ${dim(parts.join(" \xB7 "))}`);
497
+ }
498
+ if (result.repos.length > 6) {
499
+ console.log(dim(` \u2026 and ${result.repos.length - 6} more repositories`));
500
+ }
501
+ console.log("");
502
+ }
503
+ function printFullReport(result, rangeLabel2) {
504
+ printInsights(result.insights, `@${result.username} \xB7 ${rangeLabel2}`);
505
+ printLikelyHidden(result.insights, 12);
506
+ printAuditSection("Pull requests", result.pullRequests);
507
+ printAuditSection("Issues", result.issues);
508
+ printAuditSection("Reviews", result.reviews);
509
+ console.log(dim(` ${result.insights.feedTruncationNote}`));
510
+ console.log("");
511
+ }
512
+ function printError(message) {
513
+ const lines = message.split("\n");
514
+ console.error("");
515
+ console.error(red(` Error: ${lines[0]}`));
516
+ for (const line of lines.slice(1)) {
517
+ console.error(line.length ? dim(` ${line}`) : "");
518
+ }
519
+ console.error("");
520
+ }
521
+ function printProgress(message) {
522
+ console.error(dim(` ${message}`));
523
+ }
524
+ function formatReset(reset) {
525
+ if (!reset) return "unknown";
526
+ return new Date(reset * 1e3).toISOString().replace(/\.\d{3}Z$/, "Z");
527
+ }
528
+ function printDoctor(report) {
529
+ printBanner();
530
+ console.log(bold(" Doctor") + dim(" \xB7 environment check"));
531
+ console.log("");
532
+ console.log(` CLI version ${report.version}`);
533
+ console.log(` Node.js ${report.node}`);
534
+ console.log(
535
+ ` Auth ${report.authenticated ? green(`yes (${report.tokenSource})`) : amber("no \u2014 unauthenticated")}`
536
+ );
537
+ console.log("");
538
+ console.log(bold(" Rate limits"));
539
+ console.log(
540
+ ` Core API ${report.core.remaining}/${report.core.limit} reset ${formatReset(report.core.reset)}`
541
+ );
542
+ console.log(
543
+ ` Search API ${report.search.remaining}/${report.search.limit} reset ${formatReset(report.search.reset)}`
544
+ );
545
+ console.log("");
546
+ if (!report.authenticated) {
547
+ console.log(amber(" Tip: export GITHUB_TOKEN=\u2026 before running audits."));
548
+ console.log(dim(" https://github.com/settings/tokens"));
549
+ console.log("");
550
+ }
551
+ }
552
+
553
+ // src/cli.ts
554
+ function cliVersion() {
555
+ const injected = true ? "2.2.0" : "";
556
+ if (injected) return injected;
557
+ try {
558
+ const here = dirname(fileURLToPath(import.meta.url));
559
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
560
+ return pkg.version;
561
+ } catch {
562
+ return "0.0.0";
563
+ }
564
+ }
5
565
  function usage() {
6
- return `
7
- ${APP_NAME} audit GitHub contributions the activity feed hides
566
+ return `
567
+ ${APP_NAME} \u2014 audit GitHub contributions the activity feed hides
8
568
 
9
569
  Usage:
10
570
  openhearth audit <username> [options]
11
571
  openhearth hidden <username> [options]
572
+ openhearth doctor
573
+ openhearth --version
12
574
 
13
575
  Commands:
14
576
  audit Full PR + issue + review audit (default)
15
- hidden Quick report: repos hidden from the activity sidebar
577
+ hidden Hidden-repo report vs activity sidebar (ranked)
578
+ doctor Check auth + GitHub rate-limit status
16
579
 
17
580
  Options:
18
581
  --month YYYY-MM Audit a calendar month (e.g. 2026-07)
@@ -23,155 +586,195 @@ Options:
23
586
  --json [file] Export JSON (stdout if no file)
24
587
  --csv [file] Export CSV (stdout if no file)
25
588
  --quiet Minimal output
589
+ -V, --version Print CLI version
26
590
  -h, --help Show help
27
591
 
28
592
  Examples:
29
593
  npx @felix-ayush/openhearth audit Ayush7614 --month 2026-07
30
594
  npx @felix-ayush/openhearth hidden Ayush7614 --month 2026-07
595
+ npx @felix-ayush/openhearth doctor
31
596
  npx @felix-ayush/openhearth audit torvalds --from 2026-01-01 --to 2026-01-31 --json report.json
32
597
 
33
598
  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
599
+ \xB7 Finds ALL repos via Search API (not truncated like github.com activity)
600
+ \xB7 Ranks likely-hidden repos (lowest activity past the ~25 sidebar cap)
601
+ \xB7 Auto-splits date ranges past GitHub's 1000-result search cap
602
+ \xB7 Full audit: PRs + issues + reviews in one run
38
603
  `;
39
604
  }
40
605
  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);
606
+ const opts = {
607
+ command: "audit",
608
+ username: "",
609
+ kind: "all",
610
+ quiet: false,
611
+ hidden: false,
612
+ help: false,
613
+ version: false,
614
+ doctor: false
615
+ };
616
+ const positional = [];
617
+ for (let i = 0; i < argv.length; i++) {
618
+ const arg = argv[i];
619
+ if (arg === "-h" || arg === "--help") {
620
+ opts.help = true;
621
+ continue;
106
622
  }
107
- if (opts.from && opts.to) {
108
- return { from: opts.from, to: opts.to };
623
+ if (arg === "-V" || arg === "--version") {
624
+ opts.version = true;
625
+ continue;
109
626
  }
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")}`);
627
+ if (arg === "--quiet") {
628
+ opts.quiet = true;
629
+ continue;
630
+ }
631
+ if (arg === "--month" && argv[i + 1]) {
632
+ opts.month = argv[++i];
633
+ continue;
634
+ }
635
+ if (arg === "--from" && argv[i + 1]) {
636
+ opts.from = argv[++i];
637
+ continue;
638
+ }
639
+ if (arg === "--to" && argv[i + 1]) {
640
+ opts.to = argv[++i];
641
+ continue;
642
+ }
643
+ if (arg === "--token" && argv[i + 1]) {
644
+ opts.token = argv[++i];
645
+ continue;
646
+ }
647
+ if (arg === "--kind" && argv[i + 1]) {
648
+ opts.kind = argv[++i];
649
+ continue;
650
+ }
651
+ if (arg === "--json") {
652
+ opts.json = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : "-";
653
+ continue;
654
+ }
655
+ if (arg === "--csv") {
656
+ opts.csv = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : "-";
657
+ continue;
658
+ }
659
+ if (!arg.startsWith("-")) {
660
+ positional.push(arg);
661
+ }
662
+ }
663
+ if (positional[0] === "audit" || positional[0] === "hidden" || positional[0] === "doctor") {
664
+ opts.command = positional[0];
665
+ opts.username = (positional[1] ?? "").replace(/^@/, "");
666
+ if (opts.command === "hidden") opts.hidden = true;
667
+ if (opts.command === "doctor") opts.doctor = true;
668
+ } else {
669
+ opts.username = (positional[0] ?? "").replace(/^@/, "");
670
+ }
671
+ return opts;
672
+ }
673
+ function resolveRange(opts) {
674
+ if (opts.month) {
675
+ return parseMonth(opts.month);
676
+ }
677
+ if (opts.from && opts.to) {
678
+ return { from: opts.from, to: opts.to };
679
+ }
680
+ const now = /* @__PURE__ */ new Date();
681
+ const year = now.getFullYear();
682
+ const month = now.getMonth() + 1;
683
+ return parseMonth(`${year}-${String(month).padStart(2, "0")}`);
114
684
  }
115
685
  function rangeLabel(range, month) {
116
- if (month)
117
- return month;
118
- return `${range.from} → ${range.to}`;
686
+ if (month) return month;
687
+ return `${range.from} \u2192 ${range.to}`;
119
688
  }
120
689
  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}`);
690
+ if (!path || path === "-") {
691
+ console.log(content);
692
+ return;
693
+ }
694
+ writeFileSync(path, content, "utf8");
695
+ console.error(` Wrote ${path}`);
696
+ }
697
+ function tokenSourceLabel() {
698
+ if (process.env.GITHUB_TOKEN) return "GITHUB_TOKEN";
699
+ if (process.env.GH_TOKEN) return "GH_TOKEN";
700
+ return "--token";
701
+ }
702
+ async function runDoctor() {
703
+ const status = await checkRateLimit();
704
+ printDoctor({
705
+ version: cliVersion(),
706
+ node: process.version,
707
+ authenticated: status.authenticated,
708
+ tokenSource: status.authenticated ? tokenSourceLabel() : "none",
709
+ core: status.core,
710
+ search: status.search
711
+ });
127
712
  }
128
713
  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();
714
+ const opts = parseArgs(process.argv.slice(2));
715
+ if (opts.version) {
716
+ console.log(cliVersion());
717
+ process.exit(0);
718
+ }
719
+ if (opts.help) {
720
+ console.log(usage());
721
+ process.exit(0);
722
+ }
723
+ if (opts.token) setAuthToken(opts.token);
724
+ if (opts.doctor || opts.command === "doctor") {
141
725
  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);
726
+ await runDoctor();
727
+ } catch (err) {
728
+ printError(err instanceof Error ? err.message : String(err));
729
+ process.exit(1);
730
+ }
731
+ return;
732
+ }
733
+ if (!opts.username) {
734
+ console.log(usage());
735
+ process.exit(1);
736
+ }
737
+ const range = resolveRange(opts);
738
+ const label = rangeLabel(range, opts.month);
739
+ const onProgress = opts.quiet ? void 0 : printProgress;
740
+ if (!opts.quiet) printBanner();
741
+ try {
742
+ if (opts.hidden || opts.command === "hidden") {
743
+ const full = await runFullAudit(opts.username, range, runAudit, onProgress);
744
+ if (!opts.quiet) {
745
+ printInsights(full.insights, `@${opts.username} \xB7 ${label}`);
746
+ printLikelyHidden(full.insights);
747
+ console.log(` ${full.insights.feedTruncationNote}
748
+ `);
749
+ }
750
+ if (opts.json) writeOutput(fullAuditToJson(full), opts.json);
751
+ if (opts.csv) writeOutput(fullAuditToCsv(full), opts.csv);
752
+ return;
753
+ }
754
+ if (opts.kind === "all") {
755
+ const full = await runFullAudit(opts.username, range, runAudit, onProgress);
756
+ if (opts.json) writeOutput(fullAuditToJson(full), opts.json);
757
+ if (opts.csv) writeOutput(fullAuditToCsv(full), opts.csv);
758
+ if (!opts.quiet && !opts.json && !opts.csv) {
759
+ printFullReport(full, label);
760
+ }
761
+ return;
762
+ }
763
+ const result = await runAudit(opts.username, opts.kind, range, onProgress);
764
+ if (opts.json) writeOutput(auditToJson(result), opts.json);
765
+ if (opts.csv) writeOutput(auditToCsv(result), opts.csv);
766
+ if (!opts.quiet && !opts.json && !opts.csv) {
767
+ printAuditSection(
768
+ opts.kind === "pr" ? "Pull requests" : opts.kind === "issue" ? "Issues" : "Reviews",
769
+ result
770
+ );
771
+ }
772
+ } catch (err) {
773
+ printError(err instanceof Error ? err.message : String(err));
774
+ if (!getAuthToken()) {
775
+ printProgress("Hint: run `openhearth doctor` after setting GITHUB_TOKEN.");
175
776
  }
777
+ process.exit(1);
778
+ }
176
779
  }
177
780
  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.2.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": "*",
44
58
  "@types/node": "^22.13.10",
59
+ "esbuild": "^0.25.0",
45
60
  "typescript": "^5.8.2"
46
61
  }
47
62
  }