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