chapa-cli 0.1.2 → 0.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 +27 -16
- package/dist/index.js +170 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,44 +4,55 @@ Merge GitHub EMU (Enterprise Managed User) contributions into your [Chapa](https
|
|
|
4
4
|
|
|
5
5
|
If your employer uses GitHub EMU accounts, your work contributions are invisible on your personal profile. This CLI fetches your EMU stats and uploads them to Chapa so your badge reflects your full impact.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Quick start
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
# 1. Authenticate with Chapa (opens browser)
|
|
11
|
+
npx chapa-cli login
|
|
12
|
+
|
|
13
|
+
# 2. Merge your EMU stats
|
|
14
|
+
npx chapa-cli merge --emu-handle your-emu-handle
|
|
13
15
|
```
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
That's it! Your personal handle and auth token are auto-detected from the login step.
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
| Command | Description |
|
|
22
|
+
|---------|-------------|
|
|
23
|
+
| `chapa login` | Authenticate via browser (like `npm login`) |
|
|
24
|
+
| `chapa logout` | Clear stored credentials |
|
|
25
|
+
| `chapa merge` | Fetch EMU stats and upload to Chapa |
|
|
26
|
+
|
|
27
|
+
## Options (for merge)
|
|
16
28
|
|
|
17
29
|
| Flag | Description | Required |
|
|
18
30
|
|------|-------------|----------|
|
|
19
|
-
| `--handle <handle>` | Your personal GitHub handle | Yes |
|
|
20
31
|
| `--emu-handle <handle>` | Your EMU GitHub handle | Yes |
|
|
21
32
|
| `--emu-token <token>` | EMU GitHub token | No* |
|
|
22
|
-
| `--
|
|
33
|
+
| `--handle <handle>` | Override personal handle | No |
|
|
34
|
+
| `--token <token>` | Override auth token | No |
|
|
23
35
|
| `--server <url>` | Chapa server URL | No |
|
|
24
36
|
| `--version`, `-v` | Show version number | |
|
|
25
37
|
| `--help`, `-h` | Show help message | |
|
|
26
38
|
|
|
27
|
-
*
|
|
39
|
+
*EMU token is resolved from: flag > `GITHUB_EMU_TOKEN` env var.
|
|
28
40
|
|
|
29
|
-
##
|
|
41
|
+
## EMU token setup
|
|
30
42
|
|
|
31
|
-
|
|
43
|
+
You need one token from your EMU (work) GitHub account with `read:user` scope:
|
|
32
44
|
|
|
33
45
|
```bash
|
|
34
|
-
export GITHUB_TOKEN=ghp_your_personal_token
|
|
35
46
|
export GITHUB_EMU_TOKEN=ghp_your_emu_token
|
|
36
47
|
```
|
|
37
48
|
|
|
38
|
-
Tokens need the `read:user` scope at minimum.
|
|
39
|
-
|
|
40
49
|
## How it works
|
|
41
50
|
|
|
42
|
-
1.
|
|
43
|
-
2.
|
|
44
|
-
3.
|
|
51
|
+
1. `chapa login` opens your browser for OAuth authentication with Chapa
|
|
52
|
+
2. After approval, a signed CLI token is saved to `~/.chapa/credentials.json`
|
|
53
|
+
3. `chapa merge` fetches your last 12 months of EMU activity via GitHub GraphQL API
|
|
54
|
+
4. Stats (commits, PRs merged, reviews) are uploaded to the Chapa server
|
|
55
|
+
5. Your badge recalculates on next refresh, combining personal + EMU contributions
|
|
45
56
|
|
|
46
57
|
## License
|
|
47
58
|
|
package/dist/index.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { parseArgs as nodeParseArgs } from "util";
|
|
5
5
|
var DEFAULT_SERVER = "https://chapa.thecreativetoken.com";
|
|
6
|
+
var VALID_COMMANDS = ["merge", "login", "logout"];
|
|
6
7
|
function parseArgs(argv) {
|
|
7
|
-
const positional = argv.find((a) => !a.startsWith("--"));
|
|
8
|
-
const command = positional
|
|
8
|
+
const positional = argv.find((a) => !a.startsWith("--") && !a.startsWith("-"));
|
|
9
|
+
const command = VALID_COMMANDS.includes(positional) ? positional : null;
|
|
9
10
|
const flagArgs = argv.filter((a) => a !== positional || a.startsWith("--"));
|
|
10
11
|
const { values } = nodeParseArgs({
|
|
11
12
|
args: flagArgs,
|
|
@@ -90,6 +91,9 @@ query($login: String!, $since: DateTime!, $until: DateTime!, $historySince: GitT
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
}
|
|
94
|
+
ownedRepos: repositories(ownerAffiliations: OWNER, first: 100, orderBy: {field: STARGAZERS, direction: DESC}) {
|
|
95
|
+
nodes { stargazerCount forkCount watchers { totalCount } }
|
|
96
|
+
}
|
|
93
97
|
}
|
|
94
98
|
}
|
|
95
99
|
`;
|
|
@@ -100,8 +104,12 @@ function computePrWeight(pr) {
|
|
|
100
104
|
return Math.min(w, 3);
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
// ../shared/src/constants.ts
|
|
108
|
+
var SCORING_WINDOW_DAYS = 365;
|
|
109
|
+
var PR_WEIGHT_AGG_CAP = 120;
|
|
110
|
+
|
|
103
111
|
// ../shared/src/stats-aggregation.ts
|
|
104
|
-
function
|
|
112
|
+
function buildStatsFromRaw(raw) {
|
|
105
113
|
const heatmapData = [];
|
|
106
114
|
for (const week of raw.contributionCalendar.weeks) {
|
|
107
115
|
for (const day of week.contributionDays) {
|
|
@@ -114,7 +122,7 @@ function buildStats90dFromRaw(raw) {
|
|
|
114
122
|
const prsMergedCount = mergedPRs.length;
|
|
115
123
|
const prsMergedWeight = Math.min(
|
|
116
124
|
mergedPRs.reduce((sum, pr) => sum + computePrWeight(pr), 0),
|
|
117
|
-
|
|
125
|
+
PR_WEIGHT_AGG_CAP
|
|
118
126
|
);
|
|
119
127
|
const linesAdded = mergedPRs.reduce((sum, pr) => sum + pr.additions, 0);
|
|
120
128
|
const linesDeleted = mergedPRs.reduce((sum, pr) => sum + pr.deletions, 0);
|
|
@@ -129,6 +137,18 @@ function buildStats90dFromRaw(raw) {
|
|
|
129
137
|
const topRepoShare = totalRepoCommits > 0 ? Math.max(...repoCommits.map((r) => r.commits)) / totalRepoCommits : 0;
|
|
130
138
|
const maxDailyCount = Math.max(...heatmapData.map((d) => d.count), 0);
|
|
131
139
|
const maxCommitsIn10Min = maxDailyCount >= 30 ? maxDailyCount : 0;
|
|
140
|
+
const totalStars = raw.ownedRepoStars.nodes.reduce(
|
|
141
|
+
(sum, r) => sum + r.stargazerCount,
|
|
142
|
+
0
|
|
143
|
+
);
|
|
144
|
+
const totalForks = raw.ownedRepoStars.nodes.reduce(
|
|
145
|
+
(sum, r) => sum + r.forkCount,
|
|
146
|
+
0
|
|
147
|
+
);
|
|
148
|
+
const totalWatchers = raw.ownedRepoStars.nodes.reduce(
|
|
149
|
+
(sum, r) => sum + r.watchers.totalCount,
|
|
150
|
+
0
|
|
151
|
+
);
|
|
132
152
|
return {
|
|
133
153
|
handle: raw.login,
|
|
134
154
|
displayName: raw.name ?? void 0,
|
|
@@ -144,16 +164,19 @@ function buildStats90dFromRaw(raw) {
|
|
|
144
164
|
reposContributed,
|
|
145
165
|
topRepoShare,
|
|
146
166
|
maxCommitsIn10Min,
|
|
167
|
+
totalStars,
|
|
168
|
+
totalForks,
|
|
169
|
+
totalWatchers,
|
|
147
170
|
heatmapData,
|
|
148
171
|
fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
149
172
|
};
|
|
150
173
|
}
|
|
151
174
|
|
|
152
175
|
// src/fetch-emu.ts
|
|
153
|
-
async function fetchEmuStats(
|
|
176
|
+
async function fetchEmuStats(login2, emuToken) {
|
|
154
177
|
const now = /* @__PURE__ */ new Date();
|
|
155
178
|
const since = new Date(now);
|
|
156
|
-
since.setDate(since.getDate() -
|
|
179
|
+
since.setDate(since.getDate() - SCORING_WINDOW_DAYS);
|
|
157
180
|
try {
|
|
158
181
|
const res = await fetch("https://api.github.com/graphql", {
|
|
159
182
|
method: "POST",
|
|
@@ -165,7 +188,7 @@ async function fetchEmuStats(login, emuToken) {
|
|
|
165
188
|
body: JSON.stringify({
|
|
166
189
|
query: CONTRIBUTION_QUERY,
|
|
167
190
|
variables: {
|
|
168
|
-
login,
|
|
191
|
+
login: login2,
|
|
169
192
|
since: since.toISOString(),
|
|
170
193
|
until: now.toISOString(),
|
|
171
194
|
historySince: since.toISOString(),
|
|
@@ -179,6 +202,9 @@ async function fetchEmuStats(login, emuToken) {
|
|
|
179
202
|
return null;
|
|
180
203
|
}
|
|
181
204
|
const json = await res.json();
|
|
205
|
+
if (json.errors) {
|
|
206
|
+
console.error(`[cli] GraphQL errors for ${login2}:`, json.errors);
|
|
207
|
+
}
|
|
182
208
|
if (!json.data?.user) return null;
|
|
183
209
|
const user = json.data.user;
|
|
184
210
|
const cc = user.contributionsCollection;
|
|
@@ -201,9 +227,12 @@ async function fetchEmuStats(login, emuToken) {
|
|
|
201
227
|
repositories: {
|
|
202
228
|
totalCount: user.repositories.totalCount,
|
|
203
229
|
nodes: user.repositories.nodes
|
|
230
|
+
},
|
|
231
|
+
ownedRepoStars: {
|
|
232
|
+
nodes: (user.ownedRepos?.nodes ?? []).filter((n) => n != null).map((n) => ({ stargazerCount: n.stargazerCount, forkCount: n.forkCount, watchers: { totalCount: n.watchers.totalCount } }))
|
|
204
233
|
}
|
|
205
234
|
};
|
|
206
|
-
return
|
|
235
|
+
return buildStatsFromRaw(raw);
|
|
207
236
|
} catch (err) {
|
|
208
237
|
console.error(`[cli] fetch error:`, err);
|
|
209
238
|
return null;
|
|
@@ -243,20 +272,117 @@ async function uploadSupplementalStats(opts) {
|
|
|
243
272
|
}
|
|
244
273
|
}
|
|
245
274
|
|
|
275
|
+
// src/config.ts
|
|
276
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
277
|
+
import { join } from "path";
|
|
278
|
+
import { homedir } from "os";
|
|
279
|
+
function configDir() {
|
|
280
|
+
return join(homedir(), ".chapa");
|
|
281
|
+
}
|
|
282
|
+
function configPath() {
|
|
283
|
+
return join(configDir(), "credentials.json");
|
|
284
|
+
}
|
|
285
|
+
function loadConfig() {
|
|
286
|
+
const path = configPath();
|
|
287
|
+
if (!existsSync(path)) return null;
|
|
288
|
+
try {
|
|
289
|
+
const raw = readFileSync(path, "utf8");
|
|
290
|
+
const data = JSON.parse(raw);
|
|
291
|
+
if (data.token && data.handle && data.server) {
|
|
292
|
+
return data;
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function saveConfig(config) {
|
|
300
|
+
const dir = configDir();
|
|
301
|
+
if (!existsSync(dir)) {
|
|
302
|
+
mkdirSync(dir, { mode: 448, recursive: true });
|
|
303
|
+
}
|
|
304
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2) + "\n", {
|
|
305
|
+
mode: 384
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
function deleteConfig() {
|
|
309
|
+
const path = configPath();
|
|
310
|
+
if (!existsSync(path)) return false;
|
|
311
|
+
unlinkSync(path);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// src/login.ts
|
|
316
|
+
import { randomUUID } from "crypto";
|
|
317
|
+
import { exec } from "child_process";
|
|
318
|
+
var POLL_INTERVAL_MS = 2e3;
|
|
319
|
+
var MAX_POLL_ATTEMPTS = 150;
|
|
320
|
+
function openBrowser(url) {
|
|
321
|
+
const cmd = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
|
|
322
|
+
exec(cmd, () => {
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
function sleep(ms) {
|
|
326
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
327
|
+
}
|
|
328
|
+
async function login(serverUrl) {
|
|
329
|
+
const baseUrl = serverUrl.replace(/\/+$/, "");
|
|
330
|
+
const sessionId = randomUUID();
|
|
331
|
+
const authorizeUrl = `${baseUrl}/cli/authorize?session=${sessionId}`;
|
|
332
|
+
console.log("Opening browser for authentication...");
|
|
333
|
+
console.log(`If your browser didn't open, visit:
|
|
334
|
+
${authorizeUrl}
|
|
335
|
+
`);
|
|
336
|
+
openBrowser(authorizeUrl);
|
|
337
|
+
console.log("Waiting for approval...");
|
|
338
|
+
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
|
|
339
|
+
await sleep(POLL_INTERVAL_MS);
|
|
340
|
+
let data = null;
|
|
341
|
+
try {
|
|
342
|
+
const res = await fetch(
|
|
343
|
+
`${baseUrl}/api/cli/auth/poll?session=${sessionId}`
|
|
344
|
+
);
|
|
345
|
+
if (!res.ok) continue;
|
|
346
|
+
data = await res.json();
|
|
347
|
+
} catch {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (data?.status === "approved" && data.token && data.handle) {
|
|
351
|
+
saveConfig({
|
|
352
|
+
token: data.token,
|
|
353
|
+
handle: data.handle,
|
|
354
|
+
server: baseUrl
|
|
355
|
+
});
|
|
356
|
+
console.log(`
|
|
357
|
+
Logged in as ${data.handle}!`);
|
|
358
|
+
console.log("Credentials saved to ~/.chapa/credentials.json");
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (data?.status === "expired") {
|
|
362
|
+
console.error("\nSession expired. Please try again.");
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
console.error("\nTimed out waiting for approval. Please try again.");
|
|
367
|
+
process.exit(1);
|
|
368
|
+
}
|
|
369
|
+
|
|
246
370
|
// src/index.ts
|
|
247
|
-
var VERSION = true ? "0.
|
|
371
|
+
var VERSION = true ? "0.2.0" : "0.0.0-dev";
|
|
248
372
|
var HELP_TEXT = `chapa-cli v${VERSION}
|
|
249
373
|
|
|
250
374
|
Merge GitHub EMU (Enterprise Managed User) contributions into your Chapa badge.
|
|
251
375
|
|
|
252
|
-
|
|
253
|
-
chapa
|
|
376
|
+
Commands:
|
|
377
|
+
chapa login Authenticate with Chapa (opens browser)
|
|
378
|
+
chapa logout Clear stored credentials
|
|
379
|
+
chapa merge --emu-handle <emu> Merge EMU stats into your badge
|
|
254
380
|
|
|
255
381
|
Options:
|
|
256
|
-
--handle <handle>
|
|
257
|
-
--emu-handle <handle> Your EMU GitHub handle (required)
|
|
382
|
+
--emu-handle <handle> Your EMU GitHub handle (required for merge)
|
|
258
383
|
--emu-token <token> EMU GitHub token (or set GITHUB_EMU_TOKEN)
|
|
259
|
-
--
|
|
384
|
+
--handle <handle> Override personal handle (auto-detected from login)
|
|
385
|
+
--token <token> Override auth token (auto-detected from login)
|
|
260
386
|
--server <url> Chapa server URL (default: https://chapa.thecreativetoken.com)
|
|
261
387
|
--version, -v Show version number
|
|
262
388
|
--help, -h Show this help message
|
|
@@ -271,25 +397,43 @@ async function main() {
|
|
|
271
397
|
console.log(HELP_TEXT);
|
|
272
398
|
process.exit(0);
|
|
273
399
|
}
|
|
400
|
+
if (args.command === "login") {
|
|
401
|
+
await login(args.server);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (args.command === "logout") {
|
|
405
|
+
const removed = deleteConfig();
|
|
406
|
+
if (removed) {
|
|
407
|
+
console.log("Logged out. Credentials removed from ~/.chapa/credentials.json");
|
|
408
|
+
} else {
|
|
409
|
+
console.log("Not logged in (no credentials found).");
|
|
410
|
+
}
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
274
413
|
if (args.command !== "merge") {
|
|
275
|
-
console.error("Usage: chapa
|
|
414
|
+
console.error("Usage: chapa <login | logout | merge> [options]");
|
|
276
415
|
console.error("\nRun 'chapa --help' for more information.");
|
|
277
416
|
process.exit(1);
|
|
278
417
|
}
|
|
279
|
-
const
|
|
418
|
+
const config = loadConfig();
|
|
419
|
+
const handle = args.handle ?? config?.handle;
|
|
280
420
|
const emuHandle = args.emuHandle;
|
|
281
|
-
if (!
|
|
282
|
-
console.error("Error: --
|
|
421
|
+
if (!emuHandle) {
|
|
422
|
+
console.error("Error: --emu-handle is required.");
|
|
423
|
+
process.exit(1);
|
|
424
|
+
}
|
|
425
|
+
if (!handle) {
|
|
426
|
+
console.error("Error: No personal handle found. Run 'chapa login' first, or pass --handle.");
|
|
283
427
|
process.exit(1);
|
|
284
428
|
}
|
|
285
429
|
const emuToken = resolveToken(args.emuToken, "GITHUB_EMU_TOKEN");
|
|
286
430
|
if (!emuToken) {
|
|
287
|
-
console.error("Error: EMU token required. Use --emu-token
|
|
431
|
+
console.error("Error: EMU token required. Use --emu-token or set GITHUB_EMU_TOKEN.");
|
|
288
432
|
process.exit(1);
|
|
289
433
|
}
|
|
290
|
-
const
|
|
291
|
-
if (!
|
|
292
|
-
console.error("Error:
|
|
434
|
+
const authToken = args.token ?? config?.token;
|
|
435
|
+
if (!authToken) {
|
|
436
|
+
console.error("Error: Not authenticated. Run 'chapa login' first, or pass --token.");
|
|
293
437
|
process.exit(1);
|
|
294
438
|
}
|
|
295
439
|
console.log(`Fetching stats for EMU account: ${emuHandle}...`);
|
|
@@ -299,13 +443,14 @@ async function main() {
|
|
|
299
443
|
process.exit(1);
|
|
300
444
|
}
|
|
301
445
|
console.log(`Found: ${emuStats.commitsTotal} commits, ${emuStats.prsMergedCount} PRs merged, ${emuStats.reviewsSubmittedCount} reviews`);
|
|
302
|
-
|
|
446
|
+
const serverUrl = args.server !== "https://chapa.thecreativetoken.com" ? args.server : config?.server ?? args.server;
|
|
447
|
+
console.log(`Uploading supplemental stats to ${serverUrl}...`);
|
|
303
448
|
const result = await uploadSupplementalStats({
|
|
304
449
|
targetHandle: handle,
|
|
305
450
|
sourceHandle: emuHandle,
|
|
306
451
|
stats: emuStats,
|
|
307
|
-
token:
|
|
308
|
-
serverUrl
|
|
452
|
+
token: authToken,
|
|
453
|
+
serverUrl
|
|
309
454
|
});
|
|
310
455
|
if (!result.success) {
|
|
311
456
|
console.error(`Error: ${result.error}`);
|