chapa-cli 0.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 ADDED
@@ -0,0 +1,48 @@
1
+ # chapa-cli
2
+
3
+ Merge GitHub EMU (Enterprise Managed User) contributions into your [Chapa](https://chapa.thecreativetoken.com) developer impact badge.
4
+
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
+
7
+ ## Usage
8
+
9
+ ```bash
10
+ npx chapa-cli merge \
11
+ --handle your-personal-handle \
12
+ --emu-handle your-emu-handle
13
+ ```
14
+
15
+ ## Options
16
+
17
+ | Flag | Description | Required |
18
+ |------|-------------|----------|
19
+ | `--handle <handle>` | Your personal GitHub handle | Yes |
20
+ | `--emu-handle <handle>` | Your EMU GitHub handle | Yes |
21
+ | `--emu-token <token>` | EMU GitHub token | No* |
22
+ | `--token <token>` | Personal GitHub token | No* |
23
+ | `--server <url>` | Chapa server URL | No |
24
+ | `--version`, `-v` | Show version number | |
25
+ | `--help`, `-h` | Show help message | |
26
+
27
+ *Tokens are resolved in order: flag > environment variable > `gh auth token`.
28
+
29
+ ## Token setup
30
+
31
+ Set tokens via environment variables to avoid passing them on every run:
32
+
33
+ ```bash
34
+ export GITHUB_TOKEN=ghp_your_personal_token
35
+ export GITHUB_EMU_TOKEN=ghp_your_emu_token
36
+ ```
37
+
38
+ Tokens need the `read:user` scope at minimum.
39
+
40
+ ## How it works
41
+
42
+ 1. Fetches your last 90 days of activity from the EMU account via GitHub GraphQL API
43
+ 2. Uploads the stats (commits, PRs merged, reviews) to the Chapa server
44
+ 3. Your Chapa badge recalculates on next refresh, combining personal + EMU contributions
45
+
46
+ ## License
47
+
48
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,7 @@
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 ADDED
@@ -0,0 +1,14 @@
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
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 ADDED
@@ -0,0 +1,9 @@
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 ADDED
@@ -0,0 +1,28 @@
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
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
+ });
@@ -0,0 +1,2 @@
1
+ import type { Stats90d } from "@chapa/shared";
2
+ export declare function fetchEmuStats(login: string, emuToken: string): Promise<Stats90d | null>;
@@ -0,0 +1,157 @@
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,164 @@
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
+ });
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,50 @@
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);
15
+ }
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);
21
+ }
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);
26
+ }
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);
33
+ }
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,
43
+ });
44
+ if (!result.success) {
45
+ console.error(`Error: ${result.error}`);
46
+ process.exit(1);
47
+ }
48
+ console.log("Success! Supplemental stats uploaded. Your badge will reflect combined data on next refresh.");
49
+ }
50
+ main();
@@ -0,0 +1,14 @@
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 ADDED
@@ -0,0 +1,32 @@
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
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,114 @@
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
+ });
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "chapa-cli",
3
+ "version": "0.1.0",
4
+ "description": "Merge GitHub EMU contributions into your Chapa developer impact badge",
5
+ "type": "module",
6
+ "bin": {
7
+ "chapa": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsup",
14
+ "typecheck": "tsc --noEmit"
15
+ },
16
+ "keywords": [
17
+ "chapa",
18
+ "github",
19
+ "badge",
20
+ "developer",
21
+ "impact",
22
+ "emu",
23
+ "cli"
24
+ ],
25
+ "author": "juan294",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/juan294/chapa",
30
+ "directory": "packages/cli"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "devDependencies": {
36
+ "@chapa/shared": "workspace:*",
37
+ "@types/node": "^22.19.10",
38
+ "tsup": "^8.5.1",
39
+ "typescript": "^5.7.3"
40
+ }
41
+ }