chapa-cli 0.1.0 → 0.1.2

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/dist/index.js CHANGED
@@ -1,50 +1,316 @@
1
1
  #!/usr/bin/env node
2
- import { parseArgs } from "./cli.js";
3
- import { resolveToken } from "./auth.js";
4
- import { fetchEmuStats } from "./fetch-emu.js";
5
- import { uploadSupplementalStats } from "./upload.js";
6
- async function main() {
7
- const args = parseArgs(process.argv.slice(2));
8
- if (args.command !== "merge") {
9
- console.error("Usage: chapa merge --handle <personal> --emu-handle <emu> [--emu-token <token>] [--token <token>] [--server <url>]");
10
- process.exit(1);
11
- }
12
- if (!args.handle || !args.emuHandle) {
13
- console.error("Error: --handle and --emu-handle are required.");
14
- process.exit(1);
2
+
3
+ // src/cli.ts
4
+ import { parseArgs as nodeParseArgs } from "util";
5
+ var DEFAULT_SERVER = "https://chapa.thecreativetoken.com";
6
+ function parseArgs(argv) {
7
+ const positional = argv.find((a) => !a.startsWith("--"));
8
+ const command = positional === "merge" ? "merge" : null;
9
+ const flagArgs = argv.filter((a) => a !== positional || a.startsWith("--"));
10
+ const { values } = nodeParseArgs({
11
+ args: flagArgs,
12
+ options: {
13
+ handle: { type: "string" },
14
+ "emu-handle": { type: "string" },
15
+ "emu-token": { type: "string" },
16
+ token: { type: "string" },
17
+ server: { type: "string", default: DEFAULT_SERVER },
18
+ version: { type: "boolean", short: "v", default: false },
19
+ help: { type: "boolean", short: "h", default: false }
20
+ },
21
+ strict: false
22
+ });
23
+ return {
24
+ command,
25
+ handle: values.handle,
26
+ emuHandle: values["emu-handle"],
27
+ emuToken: values["emu-token"],
28
+ token: values.token,
29
+ server: values.server ?? DEFAULT_SERVER,
30
+ version: values.version ?? false,
31
+ help: values.help ?? false
32
+ };
33
+ }
34
+
35
+ // src/auth.ts
36
+ function resolveToken(flag, envVar) {
37
+ if (flag) return flag;
38
+ const env = process.env[envVar]?.trim();
39
+ if (env) return env;
40
+ return null;
41
+ }
42
+
43
+ // ../shared/src/github-query.ts
44
+ var CONTRIBUTION_QUERY = `
45
+ query($login: String!, $since: DateTime!, $until: DateTime!, $historySince: GitTimestamp!, $historyUntil: GitTimestamp!) {
46
+ user(login: $login) {
47
+ login
48
+ name
49
+ avatarUrl
50
+ contributionsCollection(from: $since, to: $until) {
51
+ contributionCalendar {
52
+ totalContributions
53
+ weeks {
54
+ contributionDays {
55
+ date
56
+ contributionCount
57
+ }
58
+ }
59
+ }
60
+ pullRequestContributions(first: 100) {
61
+ totalCount
62
+ nodes {
63
+ pullRequest {
64
+ additions
65
+ deletions
66
+ changedFiles
67
+ merged
68
+ }
69
+ }
70
+ }
71
+ pullRequestReviewContributions(first: 1) {
72
+ totalCount
73
+ }
74
+ issueContributions(first: 1) {
75
+ totalCount
76
+ }
15
77
  }
16
- // Resolve tokens
17
- const emuToken = resolveToken(args.emuToken, "GITHUB_EMU_TOKEN");
18
- if (!emuToken) {
19
- console.error("Error: EMU token required. Use --emu-token, set GITHUB_EMU_TOKEN, or ensure `gh auth token` works.");
20
- process.exit(1);
78
+ repositories(first: 20, ownerAffiliations: [OWNER, COLLABORATOR], orderBy: {field: PUSHED_AT, direction: DESC}) {
79
+ totalCount
80
+ nodes {
81
+ nameWithOwner
82
+ defaultBranchRef {
83
+ target {
84
+ ... on Commit {
85
+ history(since: $historySince, until: $historyUntil) {
86
+ totalCount
87
+ }
88
+ }
89
+ }
90
+ }
91
+ }
21
92
  }
22
- const personalToken = resolveToken(args.token, "GITHUB_TOKEN");
23
- if (!personalToken) {
24
- console.error("Error: Personal token required. Use --token, set GITHUB_TOKEN, or ensure `gh auth token` works.");
25
- process.exit(1);
93
+ }
94
+ }
95
+ `;
96
+
97
+ // ../shared/src/scoring.ts
98
+ function computePrWeight(pr) {
99
+ const w = 0.5 + 0.25 * Math.log(1 + pr.changedFiles) + 0.25 * Math.log(1 + pr.additions + pr.deletions);
100
+ return Math.min(w, 3);
101
+ }
102
+
103
+ // ../shared/src/stats-aggregation.ts
104
+ function buildStats90dFromRaw(raw) {
105
+ const heatmapData = [];
106
+ for (const week of raw.contributionCalendar.weeks) {
107
+ for (const day of week.contributionDays) {
108
+ heatmapData.push({ date: day.date, count: day.contributionCount });
26
109
  }
27
- // Fetch EMU stats
28
- console.log(`Fetching stats for EMU account: ${args.emuHandle}...`);
29
- const emuStats = await fetchEmuStats(args.emuHandle, emuToken);
30
- if (!emuStats) {
31
- console.error("Error: Failed to fetch EMU stats. Check your EMU token and handle.");
32
- process.exit(1);
110
+ }
111
+ const activeDays = heatmapData.filter((d) => d.count > 0).length;
112
+ const commitsTotal = raw.contributionCalendar.totalContributions;
113
+ const mergedPRs = raw.pullRequests.nodes.filter((pr) => pr.merged);
114
+ const prsMergedCount = mergedPRs.length;
115
+ const prsMergedWeight = Math.min(
116
+ mergedPRs.reduce((sum, pr) => sum + computePrWeight(pr), 0),
117
+ 40
118
+ );
119
+ const linesAdded = mergedPRs.reduce((sum, pr) => sum + pr.additions, 0);
120
+ const linesDeleted = mergedPRs.reduce((sum, pr) => sum + pr.deletions, 0);
121
+ const reviewsSubmittedCount = raw.reviews.totalCount;
122
+ const issuesClosedCount = raw.issues.totalCount;
123
+ const repoCommits = raw.repositories.nodes.map((r) => ({
124
+ name: r.nameWithOwner,
125
+ commits: r.defaultBranchRef?.target?.history?.totalCount ?? 0
126
+ })).filter((r) => r.commits > 0);
127
+ const reposContributed = repoCommits.length;
128
+ const totalRepoCommits = repoCommits.reduce((s, r) => s + r.commits, 0);
129
+ const topRepoShare = totalRepoCommits > 0 ? Math.max(...repoCommits.map((r) => r.commits)) / totalRepoCommits : 0;
130
+ const maxDailyCount = Math.max(...heatmapData.map((d) => d.count), 0);
131
+ const maxCommitsIn10Min = maxDailyCount >= 30 ? maxDailyCount : 0;
132
+ return {
133
+ handle: raw.login,
134
+ displayName: raw.name ?? void 0,
135
+ avatarUrl: raw.avatarUrl,
136
+ commitsTotal,
137
+ activeDays,
138
+ prsMergedCount,
139
+ prsMergedWeight,
140
+ reviewsSubmittedCount,
141
+ issuesClosedCount,
142
+ linesAdded,
143
+ linesDeleted,
144
+ reposContributed,
145
+ topRepoShare,
146
+ maxCommitsIn10Min,
147
+ heatmapData,
148
+ fetchedAt: (/* @__PURE__ */ new Date()).toISOString()
149
+ };
150
+ }
151
+
152
+ // src/fetch-emu.ts
153
+ async function fetchEmuStats(login, emuToken) {
154
+ const now = /* @__PURE__ */ new Date();
155
+ const since = new Date(now);
156
+ since.setDate(since.getDate() - 90);
157
+ try {
158
+ const res = await fetch("https://api.github.com/graphql", {
159
+ method: "POST",
160
+ headers: {
161
+ "Content-Type": "application/json",
162
+ Accept: "application/json",
163
+ Authorization: `Bearer ${emuToken}`
164
+ },
165
+ body: JSON.stringify({
166
+ query: CONTRIBUTION_QUERY,
167
+ variables: {
168
+ login,
169
+ since: since.toISOString(),
170
+ until: now.toISOString(),
171
+ historySince: since.toISOString(),
172
+ historyUntil: now.toISOString()
173
+ }
174
+ })
175
+ });
176
+ if (!res.ok) {
177
+ const body = await res.text().catch(() => "(unreadable)");
178
+ console.error(`[cli] GraphQL HTTP ${res.status}: ${body}`);
179
+ return null;
33
180
  }
34
- console.log(`Found: ${emuStats.commitsTotal} commits, ${emuStats.prsMergedCount} PRs merged, ${emuStats.reviewsSubmittedCount} reviews`);
35
- // Upload to Chapa
36
- console.log(`Uploading supplemental stats to ${args.server}...`);
37
- const result = await uploadSupplementalStats({
38
- targetHandle: args.handle,
39
- sourceHandle: args.emuHandle,
40
- stats: emuStats,
41
- token: personalToken,
42
- serverUrl: args.server,
181
+ const json = await res.json();
182
+ if (!json.data?.user) return null;
183
+ const user = json.data.user;
184
+ const cc = user.contributionsCollection;
185
+ const prNodes = cc.pullRequestContributions.nodes.filter(
186
+ (n) => n != null && n.pullRequest != null
187
+ ).map(
188
+ (n) => n.pullRequest
189
+ );
190
+ const raw = {
191
+ login: user.login,
192
+ name: user.name,
193
+ avatarUrl: user.avatarUrl,
194
+ contributionCalendar: cc.contributionCalendar,
195
+ pullRequests: {
196
+ totalCount: cc.pullRequestContributions.totalCount,
197
+ nodes: prNodes
198
+ },
199
+ reviews: { totalCount: cc.pullRequestReviewContributions.totalCount },
200
+ issues: { totalCount: cc.issueContributions.totalCount },
201
+ repositories: {
202
+ totalCount: user.repositories.totalCount,
203
+ nodes: user.repositories.nodes
204
+ }
205
+ };
206
+ return buildStats90dFromRaw(raw);
207
+ } catch (err) {
208
+ console.error(`[cli] fetch error:`, err);
209
+ return null;
210
+ }
211
+ }
212
+
213
+ // src/upload.ts
214
+ async function uploadSupplementalStats(opts) {
215
+ const baseUrl = opts.serverUrl.replace(/\/+$/, "");
216
+ const url = `${baseUrl}/api/supplemental`;
217
+ try {
218
+ const res = await fetch(url, {
219
+ method: "POST",
220
+ headers: {
221
+ "Content-Type": "application/json",
222
+ Authorization: `Bearer ${opts.token}`
223
+ },
224
+ body: JSON.stringify({
225
+ targetHandle: opts.targetHandle,
226
+ sourceHandle: opts.sourceHandle,
227
+ stats: opts.stats
228
+ })
43
229
  });
44
- if (!result.success) {
45
- console.error(`Error: ${result.error}`);
46
- process.exit(1);
230
+ if (!res.ok) {
231
+ const body = await res.json().catch(() => ({}));
232
+ return {
233
+ success: false,
234
+ error: `Server returned ${res.status}: ${body.error ?? "Unknown error"}`
235
+ };
47
236
  }
48
- console.log("Success! Supplemental stats uploaded. Your badge will reflect combined data on next refresh.");
237
+ return { success: true };
238
+ } catch (err) {
239
+ return {
240
+ success: false,
241
+ error: `Upload failed: ${err.message}`
242
+ };
243
+ }
244
+ }
245
+
246
+ // src/index.ts
247
+ var VERSION = true ? "0.1.2" : "0.0.0-dev";
248
+ var HELP_TEXT = `chapa-cli v${VERSION}
249
+
250
+ Merge GitHub EMU (Enterprise Managed User) contributions into your Chapa badge.
251
+
252
+ Usage:
253
+ chapa merge --handle <personal> --emu-handle <emu> [options]
254
+
255
+ Options:
256
+ --handle <handle> Your personal GitHub handle (required)
257
+ --emu-handle <handle> Your EMU GitHub handle (required)
258
+ --emu-token <token> EMU GitHub token (or set GITHUB_EMU_TOKEN)
259
+ --token <token> Personal GitHub token (or set GITHUB_TOKEN)
260
+ --server <url> Chapa server URL (default: https://chapa.thecreativetoken.com)
261
+ --version, -v Show version number
262
+ --help, -h Show this help message
263
+ `;
264
+ async function main() {
265
+ const args = parseArgs(process.argv.slice(2));
266
+ if (args.version) {
267
+ console.log(VERSION);
268
+ process.exit(0);
269
+ }
270
+ if (args.help) {
271
+ console.log(HELP_TEXT);
272
+ process.exit(0);
273
+ }
274
+ if (args.command !== "merge") {
275
+ console.error("Usage: chapa merge --handle <personal> --emu-handle <emu> [--emu-token <token>] [--token <token>] [--server <url>]");
276
+ console.error("\nRun 'chapa --help' for more information.");
277
+ process.exit(1);
278
+ }
279
+ const handle = args.handle;
280
+ const emuHandle = args.emuHandle;
281
+ if (!handle || !emuHandle) {
282
+ console.error("Error: --handle and --emu-handle are required.");
283
+ process.exit(1);
284
+ }
285
+ const emuToken = resolveToken(args.emuToken, "GITHUB_EMU_TOKEN");
286
+ if (!emuToken) {
287
+ console.error("Error: EMU token required. Use --emu-token, set GITHUB_EMU_TOKEN, or ensure `gh auth token` works.");
288
+ process.exit(1);
289
+ }
290
+ const personalToken = resolveToken(args.token, "GITHUB_TOKEN");
291
+ if (!personalToken) {
292
+ console.error("Error: Personal token required. Use --token, set GITHUB_TOKEN, or ensure `gh auth token` works.");
293
+ process.exit(1);
294
+ }
295
+ console.log(`Fetching stats for EMU account: ${emuHandle}...`);
296
+ const emuStats = await fetchEmuStats(emuHandle, emuToken);
297
+ if (!emuStats) {
298
+ console.error("Error: Failed to fetch EMU stats. Check your EMU token and handle.");
299
+ process.exit(1);
300
+ }
301
+ console.log(`Found: ${emuStats.commitsTotal} commits, ${emuStats.prsMergedCount} PRs merged, ${emuStats.reviewsSubmittedCount} reviews`);
302
+ console.log(`Uploading supplemental stats to ${args.server}...`);
303
+ const result = await uploadSupplementalStats({
304
+ targetHandle: handle,
305
+ sourceHandle: emuHandle,
306
+ stats: emuStats,
307
+ token: personalToken,
308
+ serverUrl: args.server
309
+ });
310
+ if (!result.success) {
311
+ console.error(`Error: ${result.error}`);
312
+ process.exit(1);
313
+ }
314
+ console.log("Success! Supplemental stats uploaded. Your badge will reflect combined data on next refresh.");
49
315
  }
50
316
  main();
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "chapa-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Merge GitHub EMU contributions into your Chapa developer impact badge",
5
5
  "type": "module",
6
6
  "bin": {
7
+ "chapa-cli": "dist/index.js",
7
8
  "chapa": "dist/index.js"
8
9
  },
9
10
  "files": [
@@ -11,6 +12,7 @@
11
12
  ],
12
13
  "scripts": {
13
14
  "build": "tsup",
15
+ "prepublishOnly": "tsup",
14
16
  "typecheck": "tsc --noEmit"
15
17
  },
16
18
  "keywords": [
package/dist/auth.d.ts DELETED
@@ -1,7 +0,0 @@
1
- /**
2
- * Token resolution for CLI.
3
- *
4
- * Priority: explicit flag → environment variable → null
5
- * (gh auth token fallback is handled at the CLI orchestration level)
6
- */
7
- export declare function resolveToken(flag: string | undefined, envVar: string): string | null;
package/dist/auth.js DELETED
@@ -1,14 +0,0 @@
1
- /**
2
- * Token resolution for CLI.
3
- *
4
- * Priority: explicit flag → environment variable → null
5
- * (gh auth token fallback is handled at the CLI orchestration level)
6
- */
7
- export function resolveToken(flag, envVar) {
8
- if (flag)
9
- return flag;
10
- const env = process.env[envVar]?.trim();
11
- if (env)
12
- return env;
13
- return null;
14
- }
@@ -1 +0,0 @@
1
- export {};
package/dist/auth.test.js DELETED
@@ -1,30 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
- import { resolveToken } from "./auth";
3
- describe("resolveToken", () => {
4
- const originalEnv = process.env;
5
- beforeEach(() => {
6
- process.env = { ...originalEnv };
7
- });
8
- afterEach(() => {
9
- process.env = originalEnv;
10
- });
11
- it("returns explicit flag value when provided", () => {
12
- const token = resolveToken("ghp_explicit", "GITHUB_TOKEN");
13
- expect(token).toBe("ghp_explicit");
14
- });
15
- it("falls back to environment variable when flag is undefined", () => {
16
- process.env.GITHUB_TOKEN = "ghp_from_env";
17
- const token = resolveToken(undefined, "GITHUB_TOKEN");
18
- expect(token).toBe("ghp_from_env");
19
- });
20
- it("trims whitespace from environment variable", () => {
21
- process.env.GITHUB_TOKEN = " ghp_padded \n";
22
- const token = resolveToken(undefined, "GITHUB_TOKEN");
23
- expect(token).toBe("ghp_padded");
24
- });
25
- it("returns null when no flag and no env var", () => {
26
- delete process.env.GITHUB_TOKEN;
27
- const token = resolveToken(undefined, "GITHUB_TOKEN");
28
- expect(token).toBeNull();
29
- });
30
- });
package/dist/cli.d.ts DELETED
@@ -1,9 +0,0 @@
1
- export interface CliArgs {
2
- command: "merge" | null;
3
- handle?: string;
4
- emuHandle?: string;
5
- emuToken?: string;
6
- token?: string;
7
- server: string;
8
- }
9
- export declare function parseArgs(argv: string[]): CliArgs;
package/dist/cli.js DELETED
@@ -1,28 +0,0 @@
1
- import { parseArgs as nodeParseArgs } from "node:util";
2
- const DEFAULT_SERVER = "https://chapa.thecreativetoken.com";
3
- export function parseArgs(argv) {
4
- // Extract positional command before flags
5
- const positional = argv.find((a) => !a.startsWith("--"));
6
- const command = positional === "merge" ? "merge" : null;
7
- // Remove positional from argv for nodeParseArgs
8
- const flagArgs = argv.filter((a) => a !== positional || a.startsWith("--"));
9
- const { values } = nodeParseArgs({
10
- args: flagArgs,
11
- options: {
12
- handle: { type: "string" },
13
- "emu-handle": { type: "string" },
14
- "emu-token": { type: "string" },
15
- token: { type: "string" },
16
- server: { type: "string", default: DEFAULT_SERVER },
17
- },
18
- strict: false,
19
- });
20
- return {
21
- command,
22
- handle: values.handle,
23
- emuHandle: values["emu-handle"],
24
- emuToken: values["emu-token"],
25
- token: values.token,
26
- server: values.server ?? DEFAULT_SERVER,
27
- };
28
- }
@@ -1 +0,0 @@
1
- export {};
package/dist/cli.test.js DELETED
@@ -1,43 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
- import { parseArgs } from "./cli";
3
- describe("parseArgs", () => {
4
- it("parses all required flags", () => {
5
- const args = parseArgs([
6
- "merge",
7
- "--handle", "juan294",
8
- "--emu-handle", "Juan_corp",
9
- ]);
10
- expect(args.command).toBe("merge");
11
- expect(args.handle).toBe("juan294");
12
- expect(args.emuHandle).toBe("Juan_corp");
13
- });
14
- it("parses optional flags", () => {
15
- const args = parseArgs([
16
- "merge",
17
- "--handle", "juan294",
18
- "--emu-handle", "Juan_corp",
19
- "--emu-token", "ghp_emu",
20
- "--token", "gho_personal",
21
- "--server", "http://localhost:3001",
22
- ]);
23
- expect(args.emuToken).toBe("ghp_emu");
24
- expect(args.token).toBe("gho_personal");
25
- expect(args.server).toBe("http://localhost:3001");
26
- });
27
- it("uses default server URL when not provided", () => {
28
- const args = parseArgs([
29
- "merge",
30
- "--handle", "juan294",
31
- "--emu-handle", "Juan_corp",
32
- ]);
33
- expect(args.server).toBe("https://chapa.thecreativetoken.com");
34
- });
35
- it("returns null command when no positional arg", () => {
36
- const args = parseArgs(["--handle", "juan294"]);
37
- expect(args.command).toBeNull();
38
- });
39
- it("returns null command for unknown commands", () => {
40
- const args = parseArgs(["unknown", "--handle", "juan294"]);
41
- expect(args.command).toBeNull();
42
- });
43
- });
@@ -1,2 +0,0 @@
1
- import type { Stats90d } from "@chapa/shared";
2
- export declare function fetchEmuStats(login: string, emuToken: string): Promise<Stats90d | null>;
package/dist/fetch-emu.js DELETED
@@ -1,157 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // GraphQL query (duplicated from apps/web/lib/github/queries.ts)
3
- // This is ~stable code; sharing via a common package is a future refactor.
4
- // ---------------------------------------------------------------------------
5
- const CONTRIBUTION_QUERY = `
6
- query($login: String!, $since: DateTime!, $until: DateTime!, $historySince: GitTimestamp!, $historyUntil: GitTimestamp!) {
7
- user(login: $login) {
8
- login
9
- name
10
- avatarUrl
11
- contributionsCollection(from: $since, to: $until) {
12
- contributionCalendar {
13
- totalContributions
14
- weeks {
15
- contributionDays {
16
- date
17
- contributionCount
18
- }
19
- }
20
- }
21
- pullRequestContributions(first: 100) {
22
- totalCount
23
- nodes {
24
- pullRequest {
25
- additions
26
- deletions
27
- changedFiles
28
- merged
29
- }
30
- }
31
- }
32
- pullRequestReviewContributions(first: 1) {
33
- totalCount
34
- }
35
- issueContributions(first: 1) {
36
- totalCount
37
- }
38
- }
39
- repositories(first: 20, ownerAffiliations: [OWNER, COLLABORATOR], orderBy: {field: PUSHED_AT, direction: DESC}) {
40
- totalCount
41
- nodes {
42
- nameWithOwner
43
- defaultBranchRef {
44
- target {
45
- ... on Commit {
46
- history(since: $historySince, until: $historyUntil) {
47
- totalCount
48
- }
49
- }
50
- }
51
- }
52
- }
53
- }
54
- }
55
- }
56
- `;
57
- // ---------------------------------------------------------------------------
58
- // PR weight formula (duplicated from apps/web/lib/github/stats90d.ts)
59
- // ---------------------------------------------------------------------------
60
- function computePrWeight(pr) {
61
- const w = 0.5 +
62
- 0.25 * Math.log(1 + pr.changedFiles) +
63
- 0.25 * Math.log(1 + pr.additions + pr.deletions);
64
- return Math.min(w, 3.0);
65
- }
66
- // ---------------------------------------------------------------------------
67
- // Fetch EMU stats via GraphQL (requires EMU token with auth)
68
- // ---------------------------------------------------------------------------
69
- export async function fetchEmuStats(login, emuToken) {
70
- const now = new Date();
71
- const since = new Date(now);
72
- since.setDate(since.getDate() - 90);
73
- try {
74
- const res = await fetch("https://api.github.com/graphql", {
75
- method: "POST",
76
- headers: {
77
- "Content-Type": "application/json",
78
- Accept: "application/json",
79
- Authorization: `Bearer ${emuToken}`,
80
- },
81
- body: JSON.stringify({
82
- query: CONTRIBUTION_QUERY,
83
- variables: {
84
- login,
85
- since: since.toISOString(),
86
- until: now.toISOString(),
87
- historySince: since.toISOString(),
88
- historyUntil: now.toISOString(),
89
- },
90
- }),
91
- });
92
- if (!res.ok) {
93
- const body = await res.text().catch(() => "(unreadable)");
94
- console.error(`[cli] GraphQL HTTP ${res.status}: ${body}`);
95
- return null;
96
- }
97
- const json = await res.json();
98
- if (!json.data?.user)
99
- return null;
100
- const user = json.data.user;
101
- const cc = user.contributionsCollection;
102
- // Heatmap
103
- const heatmapData = [];
104
- for (const week of cc.contributionCalendar.weeks) {
105
- for (const day of week.contributionDays) {
106
- heatmapData.push({ date: day.date, count: day.contributionCount });
107
- }
108
- }
109
- const activeDays = heatmapData.filter((d) => d.count > 0).length;
110
- // PRs
111
- const prNodes = cc.pullRequestContributions.nodes
112
- .filter((n) => n != null && n.pullRequest != null)
113
- .map((n) => n.pullRequest);
114
- const mergedPRs = prNodes.filter((pr) => pr.merged);
115
- const prsMergedCount = mergedPRs.length;
116
- const prsMergedWeight = Math.min(mergedPRs.reduce((sum, pr) => sum + computePrWeight(pr), 0), 40);
117
- const linesAdded = mergedPRs.reduce((sum, pr) => sum + pr.additions, 0);
118
- const linesDeleted = mergedPRs.reduce((sum, pr) => sum + pr.deletions, 0);
119
- // Repos
120
- const repoCommits = user.repositories.nodes
121
- .map((r) => ({
122
- name: r.nameWithOwner,
123
- commits: r.defaultBranchRef?.target?.history?.totalCount ?? 0,
124
- }))
125
- .filter((r) => r.commits > 0);
126
- const reposContributed = repoCommits.length;
127
- const totalRepoCommits = repoCommits.reduce((s, r) => s + r.commits, 0);
128
- const topRepoShare = totalRepoCommits > 0
129
- ? Math.max(...repoCommits.map((r) => r.commits)) / totalRepoCommits
130
- : 0;
131
- // Max daily spike approximation
132
- const maxDailyCount = Math.max(...heatmapData.map((d) => d.count), 0);
133
- const maxCommitsIn10Min = maxDailyCount >= 30 ? maxDailyCount : 0;
134
- return {
135
- handle: user.login,
136
- displayName: user.name ?? undefined,
137
- avatarUrl: user.avatarUrl,
138
- commitsTotal: cc.contributionCalendar.totalContributions,
139
- activeDays,
140
- prsMergedCount,
141
- prsMergedWeight,
142
- reviewsSubmittedCount: cc.pullRequestReviewContributions.totalCount,
143
- issuesClosedCount: cc.issueContributions.totalCount,
144
- linesAdded,
145
- linesDeleted,
146
- reposContributed,
147
- topRepoShare,
148
- maxCommitsIn10Min,
149
- heatmapData,
150
- fetchedAt: new Date().toISOString(),
151
- };
152
- }
153
- catch (err) {
154
- console.error(`[cli] fetch error:`, err);
155
- return null;
156
- }
157
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,164 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { fetchEmuStats } from "./fetch-emu";
3
- // Mock global fetch
4
- const mockFetch = vi.fn();
5
- describe("fetchEmuStats", () => {
6
- beforeEach(() => {
7
- vi.stubGlobal("fetch", mockFetch);
8
- });
9
- afterEach(() => {
10
- vi.restoreAllMocks();
11
- });
12
- it("returns Stats90d on successful GraphQL response", async () => {
13
- mockFetch.mockResolvedValue({
14
- ok: true,
15
- json: async () => ({
16
- data: {
17
- user: {
18
- login: "Juan_corp",
19
- name: "Juan Corp",
20
- avatarUrl: "https://example.com/avatar.png",
21
- contributionsCollection: {
22
- contributionCalendar: {
23
- totalContributions: 42,
24
- weeks: [
25
- {
26
- contributionDays: [
27
- { date: "2025-01-01", contributionCount: 5 },
28
- { date: "2025-01-02", contributionCount: 0 },
29
- ],
30
- },
31
- ],
32
- },
33
- pullRequestContributions: {
34
- totalCount: 3,
35
- nodes: [
36
- {
37
- pullRequest: {
38
- additions: 100,
39
- deletions: 50,
40
- changedFiles: 3,
41
- merged: true,
42
- },
43
- },
44
- {
45
- pullRequest: {
46
- additions: 20,
47
- deletions: 5,
48
- changedFiles: 1,
49
- merged: false,
50
- },
51
- },
52
- ],
53
- },
54
- pullRequestReviewContributions: { totalCount: 5 },
55
- issueContributions: { totalCount: 2 },
56
- },
57
- repositories: {
58
- totalCount: 3,
59
- nodes: [
60
- {
61
- nameWithOwner: "org/repo1",
62
- defaultBranchRef: {
63
- target: { history: { totalCount: 30 } },
64
- },
65
- },
66
- {
67
- nameWithOwner: "org/repo2",
68
- defaultBranchRef: {
69
- target: { history: { totalCount: 10 } },
70
- },
71
- },
72
- ],
73
- },
74
- },
75
- },
76
- }),
77
- });
78
- const result = await fetchEmuStats("Juan_corp", "ghp_emu_token");
79
- expect(result).not.toBeNull();
80
- expect(result.handle).toBe("Juan_corp");
81
- expect(result.commitsTotal).toBe(42);
82
- expect(result.prsMergedCount).toBe(1); // only merged
83
- expect(result.reviewsSubmittedCount).toBe(5);
84
- expect(result.issuesClosedCount).toBe(2);
85
- expect(result.reposContributed).toBe(2);
86
- expect(result.heatmapData).toHaveLength(2);
87
- expect(result.activeDays).toBe(1); // only Jan 1 has count > 0
88
- });
89
- it("sends Authorization header with EMU token", async () => {
90
- mockFetch.mockResolvedValue({
91
- ok: true,
92
- json: async () => ({ data: { user: null } }),
93
- });
94
- await fetchEmuStats("corp_user", "ghp_test_token");
95
- expect(mockFetch).toHaveBeenCalledWith("https://api.github.com/graphql", expect.objectContaining({
96
- headers: expect.objectContaining({
97
- Authorization: "Bearer ghp_test_token",
98
- }),
99
- }));
100
- });
101
- it("returns null when API returns HTTP error", async () => {
102
- mockFetch.mockResolvedValue({
103
- ok: false,
104
- status: 401,
105
- text: async () => "Unauthorized",
106
- });
107
- const result = await fetchEmuStats("corp_user", "bad_token");
108
- expect(result).toBeNull();
109
- });
110
- it("returns null when user is not found", async () => {
111
- mockFetch.mockResolvedValue({
112
- ok: true,
113
- json: async () => ({ data: { user: null } }),
114
- });
115
- const result = await fetchEmuStats("nonexistent_user", "ghp_token");
116
- expect(result).toBeNull();
117
- });
118
- it("returns null when fetch throws", async () => {
119
- mockFetch.mockRejectedValue(new Error("Network error"));
120
- const result = await fetchEmuStats("corp_user", "ghp_token");
121
- expect(result).toBeNull();
122
- });
123
- it("filters out null PR nodes", async () => {
124
- mockFetch.mockResolvedValue({
125
- ok: true,
126
- json: async () => ({
127
- data: {
128
- user: {
129
- login: "corp_user",
130
- name: null,
131
- avatarUrl: "https://example.com/avatar.png",
132
- contributionsCollection: {
133
- contributionCalendar: {
134
- totalContributions: 10,
135
- weeks: [],
136
- },
137
- pullRequestContributions: {
138
- totalCount: 2,
139
- nodes: [
140
- null,
141
- { pullRequest: null },
142
- {
143
- pullRequest: {
144
- additions: 10,
145
- deletions: 5,
146
- changedFiles: 1,
147
- merged: true,
148
- },
149
- },
150
- ],
151
- },
152
- pullRequestReviewContributions: { totalCount: 0 },
153
- issueContributions: { totalCount: 0 },
154
- },
155
- repositories: { totalCount: 0, nodes: [] },
156
- },
157
- },
158
- }),
159
- });
160
- const result = await fetchEmuStats("corp_user", "ghp_token");
161
- expect(result).not.toBeNull();
162
- expect(result.prsMergedCount).toBe(1);
163
- });
164
- });
package/dist/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
package/dist/upload.d.ts DELETED
@@ -1,14 +0,0 @@
1
- import type { Stats90d } from "@chapa/shared";
2
- interface UploadOptions {
3
- targetHandle: string;
4
- sourceHandle: string;
5
- stats: Stats90d;
6
- token: string;
7
- serverUrl: string;
8
- }
9
- interface UploadResult {
10
- success: boolean;
11
- error?: string;
12
- }
13
- export declare function uploadSupplementalStats(opts: UploadOptions): Promise<UploadResult>;
14
- export {};
package/dist/upload.js DELETED
@@ -1,32 +0,0 @@
1
- export async function uploadSupplementalStats(opts) {
2
- const baseUrl = opts.serverUrl.replace(/\/+$/, "");
3
- const url = `${baseUrl}/api/supplemental`;
4
- try {
5
- const res = await fetch(url, {
6
- method: "POST",
7
- headers: {
8
- "Content-Type": "application/json",
9
- Authorization: `Bearer ${opts.token}`,
10
- },
11
- body: JSON.stringify({
12
- targetHandle: opts.targetHandle,
13
- sourceHandle: opts.sourceHandle,
14
- stats: opts.stats,
15
- }),
16
- });
17
- if (!res.ok) {
18
- const body = await res.json().catch(() => ({}));
19
- return {
20
- success: false,
21
- error: `Server returned ${res.status}: ${body.error ?? "Unknown error"}`,
22
- };
23
- }
24
- return { success: true };
25
- }
26
- catch (err) {
27
- return {
28
- success: false,
29
- error: `Upload failed: ${err.message}`,
30
- };
31
- }
32
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,114 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { uploadSupplementalStats } from "./upload";
3
- const mockFetch = vi.fn();
4
- function makeStats() {
5
- return {
6
- handle: "corp_user",
7
- commitsTotal: 30,
8
- activeDays: 10,
9
- prsMergedCount: 3,
10
- prsMergedWeight: 5,
11
- reviewsSubmittedCount: 2,
12
- issuesClosedCount: 1,
13
- linesAdded: 500,
14
- linesDeleted: 200,
15
- reposContributed: 2,
16
- topRepoShare: 0.6,
17
- maxCommitsIn10Min: 3,
18
- heatmapData: [],
19
- fetchedAt: new Date().toISOString(),
20
- };
21
- }
22
- describe("uploadSupplementalStats", () => {
23
- beforeEach(() => {
24
- vi.stubGlobal("fetch", mockFetch);
25
- });
26
- afterEach(() => {
27
- vi.restoreAllMocks();
28
- });
29
- it("sends POST with correct body and auth header", async () => {
30
- mockFetch.mockResolvedValue({
31
- ok: true,
32
- status: 200,
33
- json: async () => ({ success: true }),
34
- });
35
- const result = await uploadSupplementalStats({
36
- targetHandle: "juan294",
37
- sourceHandle: "corp_user",
38
- stats: makeStats(),
39
- token: "gho_personal",
40
- serverUrl: "https://chapa.thecreativetoken.com",
41
- });
42
- expect(result.success).toBe(true);
43
- expect(mockFetch).toHaveBeenCalledWith("https://chapa.thecreativetoken.com/api/supplemental", expect.objectContaining({
44
- method: "POST",
45
- headers: expect.objectContaining({
46
- Authorization: "Bearer gho_personal",
47
- "Content-Type": "application/json",
48
- }),
49
- }));
50
- const body = JSON.parse(mockFetch.mock.calls[0][1].body);
51
- expect(body.targetHandle).toBe("juan294");
52
- expect(body.sourceHandle).toBe("corp_user");
53
- expect(body.stats).toEqual(expect.objectContaining({ handle: "corp_user", commitsTotal: 30 }));
54
- });
55
- it("returns error message on 401", async () => {
56
- mockFetch.mockResolvedValue({
57
- ok: false,
58
- status: 401,
59
- json: async () => ({ error: "Invalid token" }),
60
- });
61
- const result = await uploadSupplementalStats({
62
- targetHandle: "juan294",
63
- sourceHandle: "corp_user",
64
- stats: makeStats(),
65
- token: "bad_token",
66
- serverUrl: "https://chapa.thecreativetoken.com",
67
- });
68
- expect(result.success).toBe(false);
69
- expect(result.error).toContain("401");
70
- });
71
- it("returns error message on 403", async () => {
72
- mockFetch.mockResolvedValue({
73
- ok: false,
74
- status: 403,
75
- json: async () => ({ error: "Handle mismatch" }),
76
- });
77
- const result = await uploadSupplementalStats({
78
- targetHandle: "juan294",
79
- sourceHandle: "corp_user",
80
- stats: makeStats(),
81
- token: "wrong_user_token",
82
- serverUrl: "https://chapa.thecreativetoken.com",
83
- });
84
- expect(result.success).toBe(false);
85
- expect(result.error).toContain("403");
86
- });
87
- it("returns error on network failure", async () => {
88
- mockFetch.mockRejectedValue(new Error("Connection refused"));
89
- const result = await uploadSupplementalStats({
90
- targetHandle: "juan294",
91
- sourceHandle: "corp_user",
92
- stats: makeStats(),
93
- token: "gho_personal",
94
- serverUrl: "https://chapa.thecreativetoken.com",
95
- });
96
- expect(result.success).toBe(false);
97
- expect(result.error).toContain("Connection refused");
98
- });
99
- it("strips trailing slash from server URL", async () => {
100
- mockFetch.mockResolvedValue({
101
- ok: true,
102
- status: 200,
103
- json: async () => ({ success: true }),
104
- });
105
- await uploadSupplementalStats({
106
- targetHandle: "juan294",
107
- sourceHandle: "corp_user",
108
- stats: makeStats(),
109
- token: "gho_personal",
110
- serverUrl: "https://chapa.thecreativetoken.com/",
111
- });
112
- expect(mockFetch).toHaveBeenCalledWith("https://chapa.thecreativetoken.com/api/supplemental", expect.anything());
113
- });
114
- });