github-lang-stats 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 smallstack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # github-lang-stats
2
+
3
+ CLI that computes **per-author** GitHub language statistics by inspecting the files changed in every commit authored by a given user.
4
+
5
+ Unlike `GET /repos/{owner}/{repo}/languages` (which returns repo-wide bytes regardless of who wrote them), this tool only counts lines in files **you personally changed**.
6
+
7
+ ## How it works
8
+
9
+ | Phase | API | Cost |
10
+ |---|---|---|
11
+ | 1. Discover repos | GraphQL `contributionsCollection` | ~20 calls (one per year) |
12
+ | 2. Collect commit SHAs per repo | GraphQL `history(author: ...)` | ~1 call per 100 commits |
13
+ | 3. Fetch per-commit file details | REST `GET /repos/:owner/:repo/commits/:sha` | 1 call per commit |
14
+
15
+ Progress is cached in `.github-lang-stats-cache/<user>.json` so **interrupted runs resume from where they left off**.
16
+
17
+ ## Metric: `lines_changed`
18
+
19
+ For each file in each commit we count `additions + deletions`. This is a proxy for "language activity" — it's not as precise as bytes stored, but it correctly reflects only work you did.
20
+
21
+ ## Installation & usage
22
+
23
+ ### npx (no install)
24
+
25
+ ```sh
26
+ npx github-lang-stats --user=<github-username> --token=<pat> --output=stats.json
27
+ ```
28
+
29
+
30
+ ### Global install
31
+
32
+ ```sh
33
+ npm i -g github-lang-stats
34
+ gls --user=<github-username> --token=<pat>
35
+ # or the long form:
36
+ github-lang-stats --user=<github-username> --token=<pat>
37
+ ```
38
+
39
+
40
+ ### Programmatic API
41
+
42
+ ```sh
43
+ npm i github-lang-stats
44
+ ```
45
+
46
+ ```ts
47
+ import { getGithubLangStats } from "github-lang-stats";
48
+
49
+ const stats = await getGithubLangStats({
50
+ user: "octocat",
51
+ token: process.env.GITHUB_TOKEN,
52
+ fromYear: 2020, // optional, defaults to 10 years ago
53
+ excludeLanguages: ["JSON"], // optional
54
+ onProgress: (e) => console.log(e),
55
+ });
56
+
57
+ console.log(stats.totals); // { TypeScript: 412000, … }
58
+ ```
59
+
60
+ ### Local development
61
+
62
+ ```sh
63
+ npm install
64
+ npm run build
65
+ node dist/index.js --user=<github-username> --token=<pat> --output=stats.json
66
+ ```
67
+
68
+ ## Token scopes required
69
+
70
+ | Scope | Why |
71
+ |---|---|
72
+ | `repo` | Read access to private repositories |
73
+ | `read:user` | Fetch verified email addresses for commit matching |
74
+
75
+ ## Options
76
+
77
+ ```
78
+ Usage: gls|github-lang-stats [options]
79
+
80
+ Options:
81
+ -u, --user <username> GitHub username (required)
82
+ -t, --token <pat> GitHub PAT (required)
83
+ -o, --output <path> Write JSON to file (default: stdout)
84
+ --cache <path> Override cache file path
85
+ --no-cache Disable caching (start fresh each run)
86
+ --concurrency <n> Concurrent REST requests (default: 5)
87
+ --from-year <year> Earliest year to include (default: 10 years ago)
88
+ --exclude-langs <langs> Comma-separated languages to exclude (e.g. HCL,JSON)
89
+ --select-repos Interactively pick repos to analyse after commit counts are known
90
+ --stats-only Re-aggregate from cache without fetching new data
91
+ --reset Delete cache and start fresh
92
+ -V, --version Print version
93
+ -h, --help Print help
94
+ ```
95
+
96
+ ### `--select-repos` interactive picker
97
+
98
+ When passed, after all commit SHAs have been collected the tool shows a full-screen
99
+ checkbox list sorted by **number of commits** (highest first). All repos are pre-selected:
100
+
101
+ ```
102
+ ? Choose repos (42 total, sorted by commit count)
103
+ ❯◉ myorg/monorepo 1 248 commits
104
+ ◉ myorg/frontend 832 commits
105
+ ◉ octocat/personal-site 201 commits
106
+ ...
107
+ ```
108
+
109
+ | Key | Action |
110
+ |-----|--------|
111
+ | `space` | Toggle selected repo |
112
+ | `a` | Toggle **all** (select all / deselect all) |
113
+ | `i` | Invert selection |
114
+ | `enter` | Confirm and continue |
115
+
116
+ ## Output format
117
+
118
+ ```json
119
+ {
120
+ "totals": {
121
+ "TypeScript": 412000,
122
+ "JavaScript": 88000,
123
+ "Svelte": 43000
124
+ },
125
+ "byRepo": {
126
+ "myorg/myrepo": {
127
+ "TypeScript": 200000,
128
+ "CSS": 5000
129
+ }
130
+ },
131
+ "meta": {
132
+ "user": "maxfriedmann",
133
+ "generatedAt": "2026-02-20T10:00:00.000Z",
134
+ "totalCommitsProcessed": 12450,
135
+ "totalRepos": 47,
136
+ "unit": "lines_changed"
137
+ }
138
+ }
139
+ ```
140
+
141
+ ### Using the output in the CV widget
142
+
143
+ The `totals` field maps directly to the `githubLanguageTotals` field in the CV widget schema — just copy it in.
144
+
145
+ ## Tips
146
+
147
+ - **First run is slow** — at 5k req/hr with 30k commits it can take hours. Let it run overnight; it saves progress every 50 commits.
148
+ - **Subsequent runs are fast** — only new commits since the last run are fetched.
149
+ - **Exclude infrastructure languages** with `--exclude-langs HCL,Dockerfile` if teammates committed those to repos you also touched.
150
+ - **Adjust concurrency** carefully — GitHub's [secondary rate limits](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api) may trigger at high concurrency even if your primary limit is not exhausted. `5` is a safe default.
@@ -0,0 +1,3 @@
1
+ import type { AggregatedStats, CommitDetail } from "./types.js";
2
+ export declare function aggregate(user: string, commitsByRepo: Record<string, string[]>, commitDetails: Record<string, CommitDetail | null>, excludeLanguages?: string[]): AggregatedStats;
3
+ //# sourceMappingURL=aggregator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregator.d.ts","sourceRoot":"","sources":["../src/aggregator.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAGhE,wBAAgB,SAAS,CACxB,IAAI,EAAE,MAAM,EACZ,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,EACvC,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC,EAClD,gBAAgB,GAAE,MAAM,EAAO,GAC7B,eAAe,CA0DjB"}
@@ -0,0 +1,52 @@
1
+ import { detectLanguage, isExcludedLanguage } from "./language-detector.js";
2
+ // We accept the cache data object directly for aggregation
3
+ export function aggregate(user, commitsByRepo, commitDetails, excludeLanguages = []) {
4
+ const totals = {};
5
+ const byRepo = {};
6
+ let totalCommitsProcessed = 0;
7
+ for (const [repoKey, shas] of Object.entries(commitsByRepo)) {
8
+ const repoLangs = {};
9
+ for (const sha of shas) {
10
+ const detail = commitDetails[sha];
11
+ if (!detail)
12
+ continue; // not yet fetched or errored
13
+ totalCommitsProcessed++;
14
+ for (const file of detail.files) {
15
+ const lang = detectLanguage(file.filename);
16
+ if (!lang)
17
+ continue;
18
+ if (isExcludedLanguage(lang))
19
+ continue;
20
+ if (excludeLanguages.includes(lang))
21
+ continue;
22
+ const lines = file.additions + file.deletions;
23
+ repoLangs[lang] = (repoLangs[lang] ?? 0) + lines;
24
+ totals[lang] = (totals[lang] ?? 0) + lines;
25
+ }
26
+ }
27
+ if (Object.keys(repoLangs).length > 0) {
28
+ byRepo[repoKey] = repoLangs;
29
+ }
30
+ }
31
+ // Sort totals descending
32
+ const sortedTotals = Object.fromEntries(Object.entries(totals).sort(([, a], [, b]) => b - a));
33
+ // Sort byRepo entries descending within each repo
34
+ const sortedByRepo = Object.fromEntries(Object.entries(byRepo)
35
+ .sort(([a], [b]) => a.localeCompare(b))
36
+ .map(([repo, langs]) => [
37
+ repo,
38
+ Object.fromEntries(Object.entries(langs).sort(([, a], [, b]) => b - a))
39
+ ]));
40
+ return {
41
+ totals: sortedTotals,
42
+ byRepo: sortedByRepo,
43
+ meta: {
44
+ user,
45
+ generatedAt: new Date().toISOString(),
46
+ totalCommitsProcessed,
47
+ totalRepos: Object.keys(byRepo).length,
48
+ unit: "lines_changed"
49
+ }
50
+ };
51
+ }
52
+ //# sourceMappingURL=aggregator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"aggregator.js","sourceRoot":"","sources":["../src/aggregator.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ import type { CommitDetail, Repo } from "./types.js";
2
+ export declare class CacheStore {
3
+ private path;
4
+ private data;
5
+ constructor(cachePath: string);
6
+ private load;
7
+ save(): void;
8
+ get repos(): Repo[];
9
+ set repos(repos: Repo[]);
10
+ get completedRepos(): string[];
11
+ isRepoComplete(owner: string, repo: string): boolean;
12
+ markRepoComplete(owner: string, repo: string): void;
13
+ getCommits(owner: string, repo: string): string[];
14
+ addCommits(owner: string, repo: string, shas: string[]): void;
15
+ hasCommitDetail(sha: string): boolean;
16
+ getCommitDetail(sha: string): CommitDetail | null | undefined;
17
+ setCommitDetail(sha: string, detail: CommitDetail | null): void;
18
+ /** How many commit details we still need to fetch */
19
+ pendingCommitCount(): number;
20
+ totalCommitShas(): number;
21
+ /** Returns cache file path */
22
+ get filePath(): string;
23
+ /** Returns a snapshot of the raw data needed for aggregation */
24
+ getAggregationData(): {
25
+ commitsByRepo: Record<string, string[]>;
26
+ commitDetails: Record<string, CommitDetail | null>;
27
+ };
28
+ /** Clear all cached data */
29
+ reset(): void;
30
+ }
31
+ export declare function defaultCachePath(user: string): string;
32
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.d.ts","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAS,YAAY,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAc5D,qBAAa,UAAU;IACtB,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,IAAI,CAAQ;gBAER,SAAS,EAAE,MAAM;IAK7B,OAAO,CAAC,IAAI;IAkBZ,IAAI,IAAI,IAAI;IAMZ,IAAI,KAAK,IAAI,IAAI,EAAE,CAElB;IAED,IAAI,KAAK,CAAC,KAAK,EAAE,IAAI,EAAE,EAEtB;IAED,IAAI,cAAc,IAAI,MAAM,EAAE,CAE7B;IAED,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO;IAIpD,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAOnD,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE;IAIjD,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI;IAQ7D,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAIrC,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,GAAG,SAAS;IAI7D,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,IAAI,GAAG,IAAI;IAI/D,qDAAqD;IACrD,kBAAkB,IAAI,MAAM;IAU5B,eAAe,IAAI,MAAM;IAQzB,8BAA8B;IAC9B,IAAI,QAAQ,IAAI,MAAM,CAErB;IAED,gEAAgE;IAChE,kBAAkB,IAAI;QACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;QACxC,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC,CAAC;KACnD;IAOD,4BAA4B;IAC5B,KAAK,IAAI,IAAI;CAGb;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAErD"}
package/dist/cache.js ADDED
@@ -0,0 +1,117 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ const CACHE_VERSION = 1;
4
+ function emptyCache() {
5
+ return {
6
+ version: CACHE_VERSION,
7
+ repos: [],
8
+ completedRepos: [],
9
+ commitsByRepo: {},
10
+ commitDetails: {}
11
+ };
12
+ }
13
+ export class CacheStore {
14
+ path;
15
+ data;
16
+ constructor(cachePath) {
17
+ this.path = cachePath;
18
+ this.data = this.load();
19
+ }
20
+ load() {
21
+ if (!existsSync(this.path))
22
+ return emptyCache();
23
+ try {
24
+ const raw = readFileSync(this.path, "utf-8");
25
+ const parsed = JSON.parse(raw);
26
+ if (parsed.version !== CACHE_VERSION) {
27
+ console.warn(`Cache version mismatch (got ${parsed.version}, expected ${CACHE_VERSION}). Starting fresh.`);
28
+ return emptyCache();
29
+ }
30
+ return parsed;
31
+ }
32
+ catch {
33
+ console.warn("Failed to read cache, starting fresh.");
34
+ return emptyCache();
35
+ }
36
+ }
37
+ save() {
38
+ const dir = dirname(this.path);
39
+ if (!existsSync(dir))
40
+ mkdirSync(dir, { recursive: true });
41
+ writeFileSync(this.path, JSON.stringify(this.data, null, 2), "utf-8");
42
+ }
43
+ get repos() {
44
+ return this.data.repos;
45
+ }
46
+ set repos(repos) {
47
+ this.data.repos = repos;
48
+ }
49
+ get completedRepos() {
50
+ return this.data.completedRepos;
51
+ }
52
+ isRepoComplete(owner, repo) {
53
+ return this.data.completedRepos.includes(`${owner}/${repo}`);
54
+ }
55
+ markRepoComplete(owner, repo) {
56
+ const key = `${owner}/${repo}`;
57
+ if (!this.data.completedRepos.includes(key)) {
58
+ this.data.completedRepos.push(key);
59
+ }
60
+ }
61
+ getCommits(owner, repo) {
62
+ return this.data.commitsByRepo[`${owner}/${repo}`] ?? [];
63
+ }
64
+ addCommits(owner, repo, shas) {
65
+ const key = `${owner}/${repo}`;
66
+ const existing = this.data.commitsByRepo[key] ?? [];
67
+ const existingSet = new Set(existing);
68
+ const newShas = shas.filter((s) => !existingSet.has(s));
69
+ this.data.commitsByRepo[key] = [...existing, ...newShas];
70
+ }
71
+ hasCommitDetail(sha) {
72
+ return sha in this.data.commitDetails;
73
+ }
74
+ getCommitDetail(sha) {
75
+ return this.data.commitDetails[sha];
76
+ }
77
+ setCommitDetail(sha, detail) {
78
+ this.data.commitDetails[sha] = detail;
79
+ }
80
+ /** How many commit details we still need to fetch */
81
+ pendingCommitCount() {
82
+ let count = 0;
83
+ for (const shas of Object.values(this.data.commitsByRepo)) {
84
+ for (const sha of shas) {
85
+ if (!this.hasCommitDetail(sha))
86
+ count++;
87
+ }
88
+ }
89
+ return count;
90
+ }
91
+ totalCommitShas() {
92
+ let count = 0;
93
+ for (const shas of Object.values(this.data.commitsByRepo)) {
94
+ count += shas.length;
95
+ }
96
+ return count;
97
+ }
98
+ /** Returns cache file path */
99
+ get filePath() {
100
+ return this.path;
101
+ }
102
+ /** Returns a snapshot of the raw data needed for aggregation */
103
+ getAggregationData() {
104
+ return {
105
+ commitsByRepo: this.data.commitsByRepo,
106
+ commitDetails: this.data.commitDetails
107
+ };
108
+ }
109
+ /** Clear all cached data */
110
+ reset() {
111
+ this.data = emptyCache();
112
+ }
113
+ }
114
+ export function defaultCachePath(user) {
115
+ return join(process.cwd(), ".github-lang-stats-cache", `${user}.json`);
116
+ }
117
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cache.js","sourceRoot":"","sources":["../src/cache.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,20 @@
1
+ import type { CommitDetail, RateLimitInfo, Repo } from "./types.js";
2
+ export declare class GitHubClient {
3
+ private token;
4
+ private rateLimitRemaining;
5
+ private rateLimitReset;
6
+ constructor(token: string);
7
+ private headers;
8
+ private updateRateLimitFromHeaders;
9
+ /** Wait if we are close to the rate limit */
10
+ private throttle;
11
+ getRateLimitInfo(): RateLimitInfo;
12
+ graphql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T>;
13
+ discoverReposViaRest(): Promise<Repo[]>;
14
+ discoverReposViaContributions(user: string, fromYear?: number, onProgress?: (year: number) => void): Promise<Repo[]>;
15
+ discoverContributedRepos(user: string, fromYear?: number, onProgress?: (year: number) => void): Promise<Repo[]>;
16
+ getUserNodeId(username: string): Promise<string>;
17
+ collectCommitShas(owner: string, repo: string, authorId: string, onPage?: (count: number) => void, fromYear?: number): Promise<string[]>;
18
+ fetchCommitDetail(owner: string, repo: string, sha: string): Promise<CommitDetail | null>;
19
+ }
20
+ //# sourceMappingURL=github-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"github-client.d.ts","sourceRoot":"","sources":["../src/github-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAKpE,qBAAa,YAAY;IACxB,OAAO,CAAC,KAAK,CAAS;IACtB,OAAO,CAAC,kBAAkB,CAAQ;IAClC,OAAO,CAAC,cAAc,CAAK;gBAEf,KAAK,EAAE,MAAM;IAIzB,OAAO,CAAC,OAAO;IAUf,OAAO,CAAC,0BAA0B;IAOlC,6CAA6C;YAC/B,QAAQ;IAWtB,gBAAgB,IAAI,aAAa;IAY3B,OAAO,CAAC,CAAC,GAAG,OAAO,EACxB,KAAK,EAAE,MAAM,EACb,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACjC,OAAO,CAAC,CAAC,CAAC;IA2BP,oBAAoB,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;IAoCvC,6BAA6B,CAClC,IAAI,EAAE,MAAM,EACZ,QAAQ,SAAO,EACf,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GACjC,OAAO,CAAC,IAAI,EAAE,CAAC;IA8DZ,wBAAwB,CAC7B,IAAI,EAAE,MAAM,EACZ,QAAQ,SAAO,EACf,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GACjC,OAAO,CAAC,IAAI,EAAE,CAAC;IAsBZ,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAYhD,iBAAiB,CACtB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,EAChC,QAAQ,CAAC,EAAE,MAAM,GACf,OAAO,CAAC,MAAM,EAAE,CAAC;IAkEd,iBAAiB,CACtB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM,GACT,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;CA0C/B"}
@@ -0,0 +1,237 @@
1
+ const GH_REST = "https://api.github.com";
2
+ const GH_GRAPHQL = "https://api.github.com/graphql";
3
+ export class GitHubClient {
4
+ token;
5
+ rateLimitRemaining = 5000;
6
+ rateLimitReset = 0;
7
+ constructor(token) {
8
+ this.token = token;
9
+ }
10
+ headers() {
11
+ return {
12
+ Authorization: `Bearer ${this.token}`,
13
+ "Content-Type": "application/json",
14
+ "User-Agent": "github-lang-stats-cli/1.0",
15
+ Accept: "application/vnd.github+json",
16
+ "X-GitHub-Api-Version": "2022-11-28"
17
+ };
18
+ }
19
+ updateRateLimitFromHeaders(headers) {
20
+ const remaining = headers.get("x-ratelimit-remaining");
21
+ const reset = headers.get("x-ratelimit-reset");
22
+ if (remaining !== null)
23
+ this.rateLimitRemaining = parseInt(remaining, 10);
24
+ if (reset !== null)
25
+ this.rateLimitReset = parseInt(reset, 10);
26
+ }
27
+ /** Wait if we are close to the rate limit */
28
+ async throttle() {
29
+ if (this.rateLimitRemaining <= 10) {
30
+ const now = Math.floor(Date.now() / 1000);
31
+ const waitSeconds = Math.max(this.rateLimitReset - now + 5, 5);
32
+ process.stderr.write(`\nRate limit nearly exhausted. Waiting ${waitSeconds}s for reset...\n`);
33
+ await sleep(waitSeconds * 1000);
34
+ }
35
+ }
36
+ getRateLimitInfo() {
37
+ return {
38
+ limit: 5000,
39
+ remaining: this.rateLimitRemaining,
40
+ reset: this.rateLimitReset
41
+ };
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // GraphQL
45
+ // ---------------------------------------------------------------------------
46
+ async graphql(query, variables) {
47
+ await this.throttle();
48
+ const res = await fetch(GH_GRAPHQL, {
49
+ method: "POST",
50
+ headers: this.headers(),
51
+ body: JSON.stringify({ query, variables })
52
+ });
53
+ this.updateRateLimitFromHeaders(res.headers);
54
+ if (!res.ok) {
55
+ const text = await res.text();
56
+ throw new Error(`GraphQL request failed (${res.status}): ${text}`);
57
+ }
58
+ const json = (await res.json());
59
+ if (json.errors?.length) {
60
+ // Don't throw on partial errors (e.g. repos with no default branch); just return data
61
+ if (!json.data)
62
+ throw new Error(`GraphQL errors: ${JSON.stringify(json.errors)}`);
63
+ }
64
+ return json.data;
65
+ }
66
+ // ---------------------------------------------------------------------------
67
+ // Discover repos via REST: all repos the user owns / collaborates on / is org member of
68
+ // This matches the same endpoint used by the in-app sync (fetchUserRepos) and typically
69
+ // returns many more repos than contributionsCollection alone.
70
+ // ---------------------------------------------------------------------------
71
+ async discoverReposViaRest() {
72
+ const repos = [];
73
+ let page = 1;
74
+ const perPage = 100;
75
+ while (true) {
76
+ const res = await fetch(`${GH_REST}/user/repos?affiliation=owner,collaborator,organization_member&per_page=${perPage}&page=${page}`, { headers: this.headers() });
77
+ this.updateRateLimitFromHeaders(res.headers);
78
+ if (!res.ok)
79
+ throw new Error(`Failed to fetch repos (${res.status})`);
80
+ const apiRepos = (await res.json());
81
+ if (apiRepos.length === 0)
82
+ break;
83
+ for (const r of apiRepos) {
84
+ repos.push({ owner: r.owner.login, name: r.name });
85
+ }
86
+ if (apiRepos.length < perPage)
87
+ break;
88
+ page++;
89
+ }
90
+ return repos;
91
+ }
92
+ // ---------------------------------------------------------------------------
93
+ // Discover repos via GraphQL contributionsCollection (year-by-year).
94
+ // Catches public repos the user contributed to via PRs but isn't a collaborator on.
95
+ // ---------------------------------------------------------------------------
96
+ async discoverReposViaContributions(user, fromYear = 2008, onProgress) {
97
+ const repoMap = new Map();
98
+ const currentYear = new Date().getFullYear();
99
+ for (let year = fromYear; year <= currentYear; year++) {
100
+ onProgress?.(year);
101
+ const from = `${year}-01-01T00:00:00Z`;
102
+ const to = `${year}-12-31T23:59:59Z`;
103
+ try {
104
+ const data = await this.graphql(`
105
+ query($login: String!, $from: DateTime!, $to: DateTime!) {
106
+ user(login: $login) {
107
+ contributionsCollection(from: $from, to: $to) {
108
+ commitContributionsByRepository(maxRepositories: 100) {
109
+ repository {
110
+ owner { login }
111
+ name
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+ `, { login: user, from, to });
118
+ for (const entry of data.user.contributionsCollection
119
+ .commitContributionsByRepository) {
120
+ const key = `${entry.repository.owner.login}/${entry.repository.name}`;
121
+ if (!repoMap.has(key)) {
122
+ repoMap.set(key, {
123
+ owner: entry.repository.owner.login,
124
+ name: entry.repository.name
125
+ });
126
+ }
127
+ }
128
+ }
129
+ catch (err) {
130
+ process.stderr.write(` Warning: could not fetch contributions for ${year}: ${String(err)}\n`);
131
+ }
132
+ await sleep(100);
133
+ }
134
+ return [...repoMap.values()];
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Union of both discovery methods
138
+ // ---------------------------------------------------------------------------
139
+ async discoverContributedRepos(user, fromYear = 2008, onProgress) {
140
+ const repoMap = new Map();
141
+ // Source 1: REST — all repos the user has access to (owner/collaborator/org member)
142
+ const restRepos = await this.discoverReposViaRest();
143
+ for (const r of restRepos)
144
+ repoMap.set(`${r.owner}/${r.name}`, r);
145
+ // Source 2: GraphQL contributions — catches public repos contributed to via PRs
146
+ const contribRepos = await this.discoverReposViaContributions(user, fromYear, onProgress);
147
+ for (const r of contribRepos)
148
+ repoMap.set(`${r.owner}/${r.name}`, r);
149
+ return [...repoMap.values()];
150
+ }
151
+ // ---------------------------------------------------------------------------
152
+ // Get GitHub user node ID (for reliable author filtering)
153
+ // ---------------------------------------------------------------------------
154
+ async getUserNodeId(username) {
155
+ const data = await this.graphql(`query($login: String!) { user(login: $login) { id } }`, { login: username });
156
+ return data.user.id;
157
+ }
158
+ // ---------------------------------------------------------------------------
159
+ // Collect all commit SHAs authored by user in a repo (GraphQL pagination)
160
+ // ---------------------------------------------------------------------------
161
+ async collectCommitShas(owner, repo, authorId, onPage, fromYear) {
162
+ const shas = [];
163
+ let cursor = null;
164
+ const since = fromYear ? `${fromYear}-01-01T00:00:00Z` : undefined;
165
+ while (true) {
166
+ await this.throttle();
167
+ try {
168
+ const data = await this.graphql(`
169
+ query($owner: String!, $repo: String!, $authorId: ID!, $cursor: String, $since: GitTimestamp) {
170
+ repository(owner: $owner, name: $repo) {
171
+ defaultBranchRef {
172
+ target {
173
+ ... on Commit {
174
+ history(author: { id: $authorId }, first: 100, after: $cursor, since: $since) {
175
+ nodes { oid }
176
+ pageInfo { hasNextPage endCursor }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ `, { owner, repo: repo, authorId, cursor, since });
184
+ const history = data?.repository?.defaultBranchRef?.target?.history;
185
+ if (!history)
186
+ break;
187
+ for (const node of history.nodes) {
188
+ shas.push(node.oid);
189
+ }
190
+ onPage?.(shas.length);
191
+ if (!history.pageInfo.hasNextPage)
192
+ break;
193
+ cursor = history.pageInfo.endCursor;
194
+ }
195
+ catch (err) {
196
+ process.stderr.write(` Warning: error fetching commits for ${owner}/${repo}: ${String(err)}\n`);
197
+ break;
198
+ }
199
+ }
200
+ return shas;
201
+ }
202
+ // ---------------------------------------------------------------------------
203
+ // Fetch commit detail (files changed) via REST
204
+ // ---------------------------------------------------------------------------
205
+ async fetchCommitDetail(owner, repo, sha) {
206
+ await this.throttle();
207
+ const url = `${GH_REST}/repos/${owner}/${repo}/commits/${sha}`;
208
+ const res = await fetch(url, { headers: this.headers() });
209
+ this.updateRateLimitFromHeaders(res.headers);
210
+ if (res.status === 404 || res.status === 422)
211
+ return null;
212
+ if (!res.ok) {
213
+ if (res.status === 429 || res.status === 403) {
214
+ // Secondary rate limit — wait and retry once
215
+ const retryAfter = parseInt(res.headers.get("retry-after") ?? "60", 10);
216
+ process.stderr.write(`\nSecondary rate limit hit. Waiting ${retryAfter}s...\n`);
217
+ await sleep(retryAfter * 1000);
218
+ return this.fetchCommitDetail(owner, repo, sha);
219
+ }
220
+ throw new Error(`fetchCommitDetail failed (${res.status}) for ${owner}/${repo}@${sha}`);
221
+ }
222
+ const json = (await res.json());
223
+ return {
224
+ sha: json.sha,
225
+ files: (json.files ?? []).map((f) => ({
226
+ filename: f.filename,
227
+ additions: f.additions,
228
+ deletions: f.deletions,
229
+ status: f.status
230
+ }))
231
+ };
232
+ }
233
+ }
234
+ function sleep(ms) {
235
+ return new Promise((resolve) => setTimeout(resolve, ms));
236
+ }
237
+ //# sourceMappingURL=github-client.js.map