@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.
- package/README.md +132 -0
- package/dist/cli.js +737 -134
- 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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
7
|
-
${APP_NAME}
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 (
|
|
108
|
-
|
|
623
|
+
if (arg === "-V" || arg === "--version") {
|
|
624
|
+
opts.version = true;
|
|
625
|
+
continue;
|
|
109
626
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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.
|
|
4
|
-
"description": "CLI to audit every GitHub PR, issue, and review —
|
|
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": [
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
10
13
|
"scripts": {
|
|
11
|
-
"build": "
|
|
12
|
-
"dev": "
|
|
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":
|
|
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
|
}
|