@felix-ayush/openhearth 2.1.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 +31 -16
  2. package/dist/cli.js +180 -17
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -37,9 +37,12 @@ openhearth audit USERNAME --month 2026-07
37
37
  # Full audit — PRs + issues + reviews
38
38
  openhearth audit Ayush7614 --month 2026-07
39
39
 
40
- # Quick report: repos likely hidden from your activity sidebar
40
+ # Ranked report: repos likely hidden from your activity sidebar
41
41
  openhearth hidden Ayush7614 --month 2026-07
42
42
 
43
+ # Check auth + Search API quota
44
+ openhearth doctor
45
+
43
46
  # Export for spreadsheets or dashboards
44
47
  openhearth audit Ayush7614 --month 2026-07 --json report.json
45
48
  openhearth audit Ayush7614 --month 2026-07 --csv report.csv
@@ -50,7 +53,8 @@ openhearth audit Ayush7614 --month 2026-07 --csv report.csv
50
53
  | Command | Description |
51
54
  |---------|-------------|
52
55
  | `openhearth audit <user>` | Full PR + issue + review audit (default) |
53
- | `openhearth hidden <user>` | Hidden-repo report vs activity sidebar |
56
+ | `openhearth hidden <user>` | Ranked likely-hidden repos vs activity sidebar |
57
+ | `openhearth doctor` | Auth + GitHub rate-limit status |
54
58
  | `--month YYYY-MM` | Audit a calendar month |
55
59
  | `--from YYYY-MM-DD` | Custom range start |
56
60
  | `--to YYYY-MM-DD` | Custom range end |
@@ -59,19 +63,21 @@ openhearth audit Ayush7614 --month 2026-07 --csv report.csv
59
63
  | `--json [file]` | Export JSON (`stdout` if no file) |
60
64
  | `--csv [file]` | Export CSV |
61
65
  | `--quiet` | Minimal terminal output |
66
+ | `-V, --version` | Print CLI version |
62
67
 
63
68
  ## What makes OpenHearth different
64
69
 
65
- - **Hidden repo detection** — estimates repos your profile sidebar truncates (~25 visible before “not shown”)
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
66
74
  - **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
75
+ - **Auto date-splitting** — when a month exceeds GitHub’s 1000-result search cap
70
76
  - **Export** — JSON and CSV for your own tooling
71
77
 
72
78
  ## Authentication (recommended)
73
79
 
74
- Without a token, the unauthenticated Search API is limited (~10 requests/minute).
80
+ Without a token, the unauthenticated Search API is limited (~60 requests/hour).
75
81
 
76
82
  ```bash
77
83
  export GITHUB_TOKEN=ghp_your_token_here
@@ -80,6 +86,16 @@ openhearth audit USERNAME --month 2026-07
80
86
 
81
87
  A classic PAT with **public read** access is enough. The token is sent only to `api.github.com`.
82
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
+
83
99
  ## Example output
84
100
 
85
101
  ```
@@ -87,18 +103,17 @@ A classic PAT with **public read** access is enough. The token is sent only to `
87
103
 
88
104
  Summary · @Ayush7614 · 2026-07
89
105
 
90
- Total contributions 412
91
- Unique repositories 76
92
- PR merge rate 84%
93
- By kind PRs 394 · Issues 12 · Reviews 6
106
+ Total contributions 494
107
+ Unique repositories 78
108
+ PR merge rate 51%
94
109
 
95
110
  ⚠ Hidden by activity feed
96
- Feed shows ~25 repos; Search API found 76.
97
- ~51 repositories may not appear on your profile sidebar.
111
+ Feed shows ~25 busiest repos; Search API found 78.
112
+ ~53 lower-activity repositories are likely truncated.
98
113
 
99
- Top repositories
100
- · KovaMD/Kova 38
101
- · repowise-dev/repowise 33
114
+ Likely hidden repositories · least activity first
115
+ · small-org/side-project 1
116
+ · another/low-activity 2
102
117
  ...
103
118
  ```
104
119
 
package/dist/cli.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
+ import { readFileSync } from "node:fs";
4
5
  import { writeFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { dirname, join } from "node:path";
5
8
 
6
9
  // ../core/dist/github.js
7
10
  var ACTIVITY_FEED_REPO_CAP = 25;
@@ -10,7 +13,12 @@ function setAuthToken(token) {
10
13
  authToken = token.trim();
11
14
  }
12
15
  function getAuthToken() {
13
- return authToken || process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "";
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 "";
14
22
  }
15
23
  var lastRateLimit = {
16
24
  remaining: -1,
@@ -36,6 +44,22 @@ var GitHubApiError = class extends Error {
36
44
  this.status = status;
37
45
  }
38
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
+ }
39
63
  async function githubFetch(url) {
40
64
  const headers = {
41
65
  Accept: "application/vnd.github+json",
@@ -49,8 +73,10 @@ async function githubFetch(url) {
49
73
  updateRateLimit(res);
50
74
  if (res.status === 403 || res.status === 429) {
51
75
  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);
76
+ const apiMsg = body.message ?? "GitHub rate limit exceeded.";
77
+ throw new GitHubApiError(`${apiMsg}
78
+
79
+ ${formatRateLimitHint()}`, res.status);
54
80
  }
55
81
  if (!res.ok) {
56
82
  const body = await res.json().catch(() => ({}));
@@ -59,6 +85,23 @@ async function githubFetch(url) {
59
85
  }
60
86
  return res;
61
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
+ }
62
105
  async function searchIssuesPage(query, page) {
63
106
  const params = new URLSearchParams({
64
107
  q: query,
@@ -259,12 +302,22 @@ function buildInsights(pr, issue, review) {
259
302
  const uniqueRepos = repoTotals.size;
260
303
  const reposHiddenByFeed = Math.max(0, uniqueRepos - ACTIVITY_FEED_REPO_CAP);
261
304
  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 }));
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
+ });
263
316
  const merged = pr.items.filter((i) => i.state === "merged").length;
264
317
  const mergeRate = pr.total > 0 ? Math.round(merged / pr.total * 100) : 0;
265
318
  let feedTruncationNote = "Activity feed likely shows all repos for this range.";
266
319
  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.`;
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).`;
268
321
  }
269
322
  return {
270
323
  totalContributions: pr.total + issue.total + review.total,
@@ -273,6 +326,7 @@ function buildInsights(pr, issue, review) {
273
326
  reposHiddenByFeed,
274
327
  feedTruncationNote,
275
328
  topRepos,
329
+ likelyHiddenRepos,
276
330
  busiestDay: busiestDay(allItems),
277
331
  mergeRate,
278
332
  byKind: { pr: pr.total, issue: issue.total, review: review.total }
@@ -395,22 +449,37 @@ function printInsights(insights, rangeLabel2) {
395
449
  console.log(amber(" \u26A0 Hidden by activity feed"));
396
450
  console.log(
397
451
  dim(
398
- ` Feed shows ~${insights.reposVisibleOnFeed} repos; Search API found ${insights.uniqueRepos}.`
452
+ ` Feed shows ~${insights.reposVisibleOnFeed} busiest repos; Search API found ${insights.uniqueRepos}.`
399
453
  )
400
454
  );
401
455
  console.log(
402
- dim(` ~${insights.reposHiddenByFeed} repositories may not appear on your profile sidebar.`)
456
+ dim(` ~${insights.reposHiddenByFeed} lower-activity repositories are likely truncated.`)
403
457
  );
404
458
  console.log("");
405
459
  }
406
460
  if (insights.topRepos.length > 0) {
407
- console.log(bold(" Top repositories"));
461
+ console.log(bold(" Top repositories") + dim(" \xB7 likely visible on feed"));
408
462
  for (const { repo, count } of insights.topRepos.slice(0, 8)) {
409
463
  console.log(` ${dim("\xB7")} ${repo} ${dim(String(count))}`);
410
464
  }
411
465
  console.log("");
412
466
  }
413
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
+ }
414
483
  function printAuditSection(title, result) {
415
484
  console.log(bold(` ${title}`) + dim(` \xB7 ${result.total} across ${result.repos.length} repos`));
416
485
  if (result.repos.length === 0) {
@@ -433,6 +502,7 @@ function printAuditSection(title, result) {
433
502
  }
434
503
  function printFullReport(result, rangeLabel2) {
435
504
  printInsights(result.insights, `@${result.username} \xB7 ${rangeLabel2}`);
505
+ printLikelyHidden(result.insights, 12);
436
506
  printAuditSection("Pull requests", result.pullRequests);
437
507
  printAuditSection("Issues", result.issues);
438
508
  printAuditSection("Reviews", result.reviews);
@@ -440,15 +510,58 @@ function printFullReport(result, rangeLabel2) {
440
510
  console.log("");
441
511
  }
442
512
  function printError(message) {
443
- console.error(red(`
444
- Error: ${message}
445
- `));
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("");
446
520
  }
447
521
  function printProgress(message) {
448
522
  console.error(dim(` ${message}`));
449
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
+ }
450
552
 
451
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
+ }
452
565
  function usage() {
453
566
  return `
454
567
  ${APP_NAME} \u2014 audit GitHub contributions the activity feed hides
@@ -456,10 +569,13 @@ ${APP_NAME} \u2014 audit GitHub contributions the activity feed hides
456
569
  Usage:
457
570
  openhearth audit <username> [options]
458
571
  openhearth hidden <username> [options]
572
+ openhearth doctor
573
+ openhearth --version
459
574
 
460
575
  Commands:
461
576
  audit Full PR + issue + review audit (default)
462
- 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
463
579
 
464
580
  Options:
465
581
  --month YYYY-MM Audit a calendar month (e.g. 2026-07)
@@ -470,16 +586,18 @@ Options:
470
586
  --json [file] Export JSON (stdout if no file)
471
587
  --csv [file] Export CSV (stdout if no file)
472
588
  --quiet Minimal output
589
+ -V, --version Print CLI version
473
590
  -h, --help Show help
474
591
 
475
592
  Examples:
476
593
  npx @felix-ayush/openhearth audit Ayush7614 --month 2026-07
477
594
  npx @felix-ayush/openhearth hidden Ayush7614 --month 2026-07
595
+ npx @felix-ayush/openhearth doctor
478
596
  npx @felix-ayush/openhearth audit torvalds --from 2026-01-01 --to 2026-01-31 --json report.json
479
597
 
480
598
  Unique features:
481
599
  \xB7 Finds ALL repos via Search API (not truncated like github.com activity)
482
- \xB7 Reports how many repos the profile sidebar likely hides
600
+ \xB7 Ranks likely-hidden repos (lowest activity past the ~25 sidebar cap)
483
601
  \xB7 Auto-splits date ranges past GitHub's 1000-result search cap
484
602
  \xB7 Full audit: PRs + issues + reviews in one run
485
603
  `;
@@ -491,7 +609,9 @@ function parseArgs(argv) {
491
609
  kind: "all",
492
610
  quiet: false,
493
611
  hidden: false,
494
- help: false
612
+ help: false,
613
+ version: false,
614
+ doctor: false
495
615
  };
496
616
  const positional = [];
497
617
  for (let i = 0; i < argv.length; i++) {
@@ -500,6 +620,10 @@ function parseArgs(argv) {
500
620
  opts.help = true;
501
621
  continue;
502
622
  }
623
+ if (arg === "-V" || arg === "--version") {
624
+ opts.version = true;
625
+ continue;
626
+ }
503
627
  if (arg === "--quiet") {
504
628
  opts.quiet = true;
505
629
  continue;
@@ -536,10 +660,11 @@ function parseArgs(argv) {
536
660
  positional.push(arg);
537
661
  }
538
662
  }
539
- if (positional[0] === "audit" || positional[0] === "hidden") {
663
+ if (positional[0] === "audit" || positional[0] === "hidden" || positional[0] === "doctor") {
540
664
  opts.command = positional[0];
541
665
  opts.username = (positional[1] ?? "").replace(/^@/, "");
542
666
  if (opts.command === "hidden") opts.hidden = true;
667
+ if (opts.command === "doctor") opts.doctor = true;
543
668
  } else {
544
669
  opts.username = (positional[0] ?? "").replace(/^@/, "");
545
670
  }
@@ -569,13 +694,46 @@ function writeOutput(content, path) {
569
694
  writeFileSync(path, content, "utf8");
570
695
  console.error(` Wrote ${path}`);
571
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
+ });
712
+ }
572
713
  async function main() {
573
714
  const opts = parseArgs(process.argv.slice(2));
574
- if (opts.help || !opts.username) {
715
+ if (opts.version) {
716
+ console.log(cliVersion());
717
+ process.exit(0);
718
+ }
719
+ if (opts.help) {
575
720
  console.log(usage());
576
- process.exit(opts.help ? 0 : 1);
721
+ process.exit(0);
577
722
  }
578
723
  if (opts.token) setAuthToken(opts.token);
724
+ if (opts.doctor || opts.command === "doctor") {
725
+ try {
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
+ }
579
737
  const range = resolveRange(opts);
580
738
  const label = rangeLabel(range, opts.month);
581
739
  const onProgress = opts.quiet ? void 0 : printProgress;
@@ -585,10 +743,12 @@ async function main() {
585
743
  const full = await runFullAudit(opts.username, range, runAudit, onProgress);
586
744
  if (!opts.quiet) {
587
745
  printInsights(full.insights, `@${opts.username} \xB7 ${label}`);
746
+ printLikelyHidden(full.insights);
588
747
  console.log(` ${full.insights.feedTruncationNote}
589
748
  `);
590
749
  }
591
750
  if (opts.json) writeOutput(fullAuditToJson(full), opts.json);
751
+ if (opts.csv) writeOutput(fullAuditToCsv(full), opts.csv);
592
752
  return;
593
753
  }
594
754
  if (opts.kind === "all") {
@@ -611,6 +771,9 @@ async function main() {
611
771
  }
612
772
  } catch (err) {
613
773
  printError(err instanceof Error ? err.message : String(err));
774
+ if (!getAuthToken()) {
775
+ printProgress("Hint: run `openhearth doctor` after setting GITHUB_TOKEN.");
776
+ }
614
777
  process.exit(1);
615
778
  }
616
779
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@felix-ayush/openhearth",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
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": {
@@ -54,7 +54,7 @@
54
54
  "node": ">=20"
55
55
  },
56
56
  "devDependencies": {
57
- "@felix-ayush/openhearth-core": "2.0.1",
57
+ "@felix-ayush/openhearth-core": "*",
58
58
  "@types/node": "^22.13.10",
59
59
  "esbuild": "^0.25.0",
60
60
  "typescript": "^5.8.2"