@shiinasaku/github-card 2.0.0 → 4.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/src/github.ts CHANGED
@@ -1,572 +1,440 @@
1
- import type { ProfileData, LanguageStat } from "./types";
2
-
3
- export class NotFoundError extends Error {
4
- constructor(message = "User not found") {
5
- super(message);
6
- this.name = "NotFoundError";
7
- }
8
- }
9
-
10
- export class RateLimitError extends Error {
11
- constructor(message = "GitHub API rate limit exceeded") {
12
- super(message);
13
- this.name = "RateLimitError";
14
- }
15
- }
16
-
17
- export class AuthError extends Error {
18
- constructor(message = "GitHub authentication failed") {
19
- super(message);
20
- this.name = "AuthError";
21
- }
22
- }
23
-
24
- export class UpstreamError extends Error {
25
- constructor(message = "GitHub API request failed") {
26
- super(message);
27
- this.name = "UpstreamError";
1
+ import type { ProfileData } from "./types";
2
+ import { RedisClient } from "bun";
3
+ import {
4
+ GitHubClient,
5
+ NotFoundError,
6
+ RateLimitError,
7
+ AuthError,
8
+ GitHubError as UpstreamError,
9
+ } from "./utils/github-client";
10
+
11
+ export { NotFoundError, RateLimitError, AuthError, UpstreamError };
12
+
13
+ const githubClient = new GitHubClient({
14
+ token: Bun.env.GITHUB_TOKEN || process.env?.GITHUB_TOKEN || "",
15
+ userAgent: "github-card",
16
+ });
17
+
18
+ const CACHE_FRESH_MS = 30 * 60 * 1000;
19
+ const CACHE_STALE_MS = 30 * 60 * 1000;
20
+ const CACHE_VERSION = 7;
21
+ const REDIS_TTL_SECONDS = Math.ceil((CACHE_FRESH_MS + CACHE_STALE_MS) / 1000);
22
+ const FETCH_TIMEOUT_MS = 6000;
23
+ const AVATAR_TIMEOUT_MS = 4000;
24
+ const MAX_AVATAR_BYTES = 256 * 1024;
25
+ const MAX_PAGES = 5;
26
+ const REPO_PAGE_SIZE = 100;
27
+ const MAX_L1_ENTRIES = 1000;
28
+ const REDIS_PROFILE_PREFIX = "profile:";
29
+
30
+ let redisClient: RedisClient | null = null;
31
+ try {
32
+ if (Bun.env.REDIS_URL) {
33
+ redisClient = new RedisClient(Bun.env.REDIS_URL);
28
34
  }
35
+ } catch (e) {
36
+ console.error("Bun RedisClient failed to initialize", e);
29
37
  }
30
38
 
31
- function classifyGraphQLError(message: string): Error | null {
32
- const lower = message.toLowerCase();
33
-
34
- if (
35
- lower.includes("could not resolve to a user") ||
36
- lower.includes("not found") ||
37
- lower.includes("couldn't find user")
38
- ) {
39
- return new NotFoundError("User not found");
40
- }
41
-
42
- if (lower.includes("rate limit") || lower.includes("api rate limit exceeded")) {
43
- return new RateLimitError("GitHub API rate limit exceeded");
44
- }
45
-
46
- if (
47
- lower.includes("bad credentials") ||
48
- lower.includes("requires authentication") ||
49
- lower.includes("resource not accessible")
50
- ) {
51
- return new AuthError("GitHub authentication failed");
39
+ export async function isRedisReachable(): Promise<boolean> {
40
+ if (!redisClient) return false;
41
+ try {
42
+ return (await redisClient.ping()) === "PONG";
43
+ } catch {
44
+ return false;
52
45
  }
53
-
54
- return null;
55
46
  }
56
47
 
57
- function classifyHttpError(status: number, bodyText: string): Error {
58
- const lower = bodyText.toLowerCase();
59
-
60
- if (status === 401 || lower.includes("bad credentials")) {
61
- return new AuthError("GitHub authentication failed");
62
- }
63
-
64
- if (status === 403 && (lower.includes("rate limit") || lower.includes("abuse detection"))) {
65
- return new RateLimitError("GitHub API rate limit exceeded");
66
- }
48
+ type CachedData = {
49
+ v: number;
50
+ staleAt: number;
51
+ expiresAt: number;
52
+ data: ProfileData;
53
+ };
67
54
 
68
- if (status === 404) {
69
- return new NotFoundError("User not found");
70
- }
55
+ const memCache = new Map<string, CachedData>();
56
+ const inFlight = new Map<string, Promise<ProfileData>>();
57
+ const refreshInFlight = new Set<string>();
71
58
 
72
- return new UpstreamError(`GitHub API error (${status})`);
59
+ function evictL1IfNeeded() {
60
+ if (memCache.size <= MAX_L1_ENTRIES) return;
61
+ const oldestKey = memCache.keys().next().value as string | undefined;
62
+ if (oldestKey) memCache.delete(oldestKey);
73
63
  }
74
64
 
75
- function getHeaders() {
76
- const token = Bun.env.GITHUB_TOKEN;
77
- if (!token) throw new AuthError("GITHUB_TOKEN is missing");
78
- return {
79
- Authorization: `bearer ${token}`,
80
- "Content-Type": "application/json",
81
- "User-Agent": "github-card",
82
- };
65
+ function setMemoryCache(cacheKey: string, payload: CachedData) {
66
+ memCache.set(cacheKey, payload);
67
+ evictL1IfNeeded();
83
68
  }
84
69
 
85
- const LANG_FIELDS = `languages(first: 10, orderBy: {field: SIZE, direction: DESC}) {
86
- edges { size node { color name } }
87
- }`;
88
-
89
- const USER_META_FIELDS = `
90
- login name avatarUrl bio pronouns twitterUsername
91
- openPRs: pullRequests(states: OPEN) { totalCount }
92
- closedPRs: pullRequests(states: CLOSED) { totalCount }
93
- mergedPRs: pullRequests(states: MERGED) { totalCount }
94
- openIssues: issues(states: OPEN) { totalCount }
95
- closedIssues: issues(states: CLOSED) { totalCount }
96
- contributionsCollection(from: $from, to: $to) { totalCommitContributions }`;
97
-
98
- function buildUserReposQuery(langs: boolean): string {
99
- return `query userInfo($login: String!, $cursor: String, $from: DateTime!, $to: DateTime!) {
100
- user(login: $login) {
101
- ${USER_META_FIELDS}
102
- repositories(first: 100, ownerAffiliations: OWNER, isFork: false, orderBy: {direction: DESC, field: STARGAZERS}, after: $cursor) {
103
- totalCount
104
- pageInfo { hasNextPage endCursor }
105
- nodes {
106
- stargazers { totalCount }
107
- ${langs ? LANG_FIELDS : ""}
108
- }
109
- }
110
- }
111
- }`;
70
+ async function writeRedisCache(cacheKey: string, payload: CachedData) {
71
+ if (!redisClient) return;
72
+ try {
73
+ await redisClient.set(
74
+ `${REDIS_PROFILE_PREFIX}${cacheKey}`,
75
+ JSON.stringify(payload),
76
+ "EX",
77
+ REDIS_TTL_SECONDS,
78
+ );
79
+ } catch {}
112
80
  }
113
81
 
114
- function buildOrgContribsQuery(langs: boolean): string {
115
- return `query orgContribs($login: String!, $cursor: String) {
116
- user(login: $login) {
117
- repositoriesContributedTo(
118
- first: 100
119
- contributionTypes: [COMMIT, PULL_REQUEST, REPOSITORY]
120
- includeUserRepositories: false
121
- after: $cursor
122
- ) {
123
- totalCount
124
- pageInfo { hasNextPage endCursor }
125
- nodes {
126
- owner { login __typename }
127
- stargazers { totalCount }
128
- ${langs ? LANG_FIELDS : ""}
129
- }
130
- }
131
- }
132
- }`;
133
- }
82
+ async function readRedisCache(cacheKey: string, now: number): Promise<CachedData | null> {
83
+ if (!redisClient) return null;
84
+ try {
85
+ const raw = await redisClient.get(`${REDIS_PROFILE_PREFIX}${cacheKey}`);
86
+ if (!raw) return null;
134
87
 
135
- const QUERY_USER_META = `
136
- query userMeta($login: String!, $from: DateTime!, $to: DateTime!) {
137
- user(login: $login) {
138
- ${USER_META_FIELDS}
88
+ const parsed = JSON.parse(raw) as CachedData;
89
+ if (parsed.v !== CACHE_VERSION || parsed.expiresAt <= now || !parsed.data) return null;
90
+ return parsed;
91
+ } catch {
92
+ return null;
139
93
  }
140
- }`;
141
-
142
- const CACHE_FRESH_SECONDS = 30 * 60;
143
- const CACHE_STALE_SECONDS = 30 * 60;
144
- const FETCH_TIMEOUT_MS = 8000;
145
- const MAX_PAGES = 10; // Safety: 10 pages × 100 items = 1000 max
146
- type CacheEntry = {
147
- staleAt: number;
148
- expiresAt: number;
149
- value?: ProfileData;
150
- inFlight?: Promise<ProfileData>;
151
- };
152
- const cache = new Map<string, CacheEntry>();
153
-
154
- type FetchOptions = {
155
- includeLanguages?: boolean;
156
- langCount?: number;
157
- scope?: "personal" | "org" | "all";
158
- orgs?: string[];
159
- forceRefresh?: boolean;
160
- };
161
-
162
- function normalizeScope(value?: string): "personal" | "org" | "all" {
163
- if (value === "org" || value === "all") return value;
164
- return "personal";
165
94
  }
166
95
 
167
- const redisUrl = Bun.env.UPSTASH_REDIS_REST_URL || Bun.env.KV_REST_API_URL || "";
168
- const redisToken = Bun.env.UPSTASH_REDIS_REST_TOKEN || Bun.env.KV_REST_API_TOKEN || "";
169
- let redisPromise: Promise<import("@upstash/redis").Redis | null> | null = null;
170
-
171
- function buildCacheKey(raw: string): string {
172
- return Bun.hash(raw).toString(36);
173
- }
96
+ async function saveProfileCache(cacheKey: string, data: ProfileData): Promise<ProfileData> {
97
+ const now = Date.now();
98
+ const payload: CachedData = {
99
+ v: CACHE_VERSION,
100
+ staleAt: now + CACHE_FRESH_MS,
101
+ expiresAt: now + CACHE_FRESH_MS + CACHE_STALE_MS,
102
+ data,
103
+ };
174
104
 
175
- async function getRedis() {
176
- if (!redisUrl || !redisToken) return null;
177
- if (!redisPromise) {
178
- redisPromise = import("@upstash/redis")
179
- .then((mod) => new mod.Redis({ url: redisUrl, token: redisToken }))
180
- .catch(() => null);
181
- }
182
- return redisPromise;
105
+ setMemoryCache(cacheKey, payload);
106
+ await writeRedisCache(cacheKey, payload);
107
+ return data;
183
108
  }
184
109
 
185
110
  export function getCacheMetrics() {
186
- const now = Date.now();
187
111
  let fresh = 0;
188
112
  let stale = 0;
189
113
  let expired = 0;
190
-
191
- for (const entry of cache.values()) {
192
- if (entry.expiresAt <= now) {
193
- expired++;
194
- continue;
195
- }
196
- if (entry.staleAt <= now) stale++;
114
+ const now = Date.now();
115
+ for (const entry of memCache.values()) {
116
+ if (entry.expiresAt <= now) expired++;
117
+ else if (entry.staleAt <= now) stale++;
197
118
  else fresh++;
198
119
  }
199
-
200
120
  return {
201
- total: cache.size,
121
+ total: memCache.size,
122
+ maxEntries: MAX_L1_ENTRIES,
202
123
  fresh,
203
124
  stale,
204
125
  expired,
205
- ttlFreshSeconds: CACHE_FRESH_SECONDS,
206
- ttlStaleSeconds: CACHE_STALE_SECONDS,
126
+ ttlFreshSeconds: 1800,
127
+ ttlStaleSeconds: 1800,
207
128
  };
208
129
  }
209
130
 
210
- export async function isRedisReachable(): Promise<boolean> {
211
- const redis = await getRedis();
212
- if (!redis) return false;
213
- try {
214
- await redis.ping();
215
- return true;
216
- } catch {
217
- return false;
218
- }
131
+ function normalizeUsername(username: string): string {
132
+ return username.trim().toLowerCase();
219
133
  }
220
134
 
221
- function getCacheState(cacheKey: string): { value: ProfileData; stale: boolean } | null {
222
- const entry = cache.get(cacheKey);
223
- if (!entry || !entry.value) return null;
224
- if (entry.expiresAt <= Date.now()) {
225
- cache.delete(cacheKey);
226
- return null;
227
- }
228
- return { value: entry.value, stale: entry.staleAt <= Date.now() };
135
+ function normalizeOrgFilters(orgs?: string[]): string[] {
136
+ return Array.from(new Set((orgs || []).map((o) => o.trim().toLowerCase()).filter(Boolean)));
229
137
  }
230
138
 
231
- function setCache(cacheKey: string, value: ProfileData) {
232
- const now = Date.now();
233
- cache.set(cacheKey, {
234
- staleAt: now + CACHE_FRESH_SECONDS * 1000,
235
- expiresAt: now + (CACHE_FRESH_SECONDS + CACHE_STALE_SECONDS) * 1000,
236
- value,
237
- });
238
- }
139
+ type FetchOptions = {
140
+ includeLanguages?: boolean;
141
+ langCount?: number;
142
+ scope?: "personal" | "org" | "all";
143
+ orgs?: string[];
144
+ affiliations?: "owner" | "affiliated";
145
+ forceRefresh?: boolean;
146
+ };
239
147
 
240
- function accumulateLanguageEdges(
241
- edges: Array<{ size: number; node: { color?: string; name?: string } }> = [],
242
- map: Map<string, { size: number; color: string }>,
243
- ) {
244
- for (const edge of edges) {
245
- if (!edge?.node?.name || !edge.size) continue;
246
- const current = map.get(edge.node.name);
247
- if (current) {
248
- current.size += edge.size;
249
- } else {
250
- map.set(edge.node.name, { size: edge.size, color: edge.node.color || "#ccc" });
148
+ const QUERY_PROFILE = `
149
+ query fullProfile($login: String!, $from: DateTime!, $to: DateTime!, $isOrg: Boolean!) {
150
+ user(login: $login) {
151
+ login name avatarUrl bio pronouns twitterUsername
152
+ openPRs: pullRequests(states: OPEN) { totalCount }
153
+ closedPRs: pullRequests(states: CLOSED) { totalCount }
154
+ mergedPRs: pullRequests(states: MERGED) { totalCount }
155
+ openIssues: issues(states: OPEN) { totalCount }
156
+ closedIssues: issues(states: CLOSED) { totalCount }
157
+ contributionsCollection(from: $from, to: $to) {
158
+ totalCommitContributions
159
+ commitContributionsByRepository(maxRepositories: 100) @include(if: $isOrg) { repository { owner { login __typename } } contributions(first:1) { totalCount } }
160
+ pullRequestContributionsByRepository(maxRepositories: 100) @include(if: $isOrg) { repository { owner { login __typename } } contributions(first:1) { totalCount } }
161
+ issueContributionsByRepository(maxRepositories: 100) @include(if: $isOrg) { repository { owner { login __typename } } contributions(first:1) { totalCount } }
251
162
  }
252
163
  }
253
164
  }
165
+ `;
254
166
 
255
- type LangMap = Map<string, { size: number; color: string }>;
256
-
257
- /** Merges multiple language maps into one, summing sizes for duplicates. */
258
- function mergeLangMaps(...maps: (LangMap | null)[]): LangMap {
259
- const merged: LangMap = new Map();
260
- for (const map of maps) {
261
- if (!map) continue;
262
- for (const [name, val] of map) {
263
- const cur = merged.get(name);
264
- if (cur) cur.size += val.size;
265
- else merged.set(name, { size: val.size, color: val.color });
266
- }
167
+ const QUERY_ORG = `
168
+ query fullOrg($login: String!) {
169
+ organization(login: $login) {
170
+ login name avatarUrl description twitterUsername
267
171
  }
268
- return merged;
269
172
  }
173
+ `;
270
174
 
271
- /** Sorts a language map by size descending to produce final LanguageStat[]. */
272
- function buildLanguageStats(map: LangMap, limit: number): LanguageStat[] {
273
- return Array.from(map)
274
- .sort((a, b) => b[1].size - a[1].size)
275
- .slice(0, limit)
276
- .map(([name, { size, color }]) => ({ name, size, color }));
175
+ const QUERY_PAGINATE_USER_REPOS = `
176
+ query paginateRepos($login: String!, $cursor: String, $pageSize: Int!, $affiliations: [RepositoryAffiliation!], $fetchLangs: Boolean!) {
177
+ user(login: $login) {
178
+ repositories(first: $pageSize, after: $cursor, ownerAffiliations: $affiliations, isFork: false, orderBy: {direction: DESC, field: STARGAZERS}) {
179
+ pageInfo { hasNextPage endCursor }
180
+ nodes { nameWithOwner stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
181
+ }
182
+ }
277
183
  }
184
+ `;
278
185
 
279
- async function postGraphQL(query: string, variables: Record<string, unknown>): Promise<any> {
280
- let res: Response;
281
- try {
282
- res = await fetch("https://api.github.com/graphql", {
283
- method: "POST",
284
- headers: getHeaders(),
285
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
286
- body: JSON.stringify({ query, variables }),
287
- });
288
- } catch (error: any) {
289
- if (error?.name === "TimeoutError" || error?.name === "AbortError") {
290
- throw new UpstreamError("GitHub API request timed out");
186
+ const QUERY_PAGINATE_ORG_CONTRIBS = `
187
+ query paginateOrgContribs($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs: Boolean!) {
188
+ user(login: $login) {
189
+ repositoriesContributedTo(first: $pageSize, contributionTypes: [COMMIT, PULL_REQUEST, REPOSITORY], includeUserRepositories: false, after: $cursor) {
190
+ pageInfo { hasNextPage endCursor }
191
+ nodes { nameWithOwner owner { login __typename } stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
291
192
  }
292
- throw new UpstreamError("GitHub API request failed");
293
193
  }
194
+ }
195
+ `;
294
196
 
295
- if (!res.ok) {
296
- const text = await res.text();
297
- throw classifyHttpError(res.status, text);
197
+ const QUERY_PAGINATE_ORG_REPOS = `
198
+ query paginateOrg($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs: Boolean!) {
199
+ organization(login: $login) {
200
+ repositories(first: $pageSize, isFork: false, orderBy: {direction: DESC, field: STARGAZERS}, after: $cursor) {
201
+ pageInfo { hasNextPage endCursor }
202
+ nodes { nameWithOwner stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
203
+ }
298
204
  }
205
+ }
206
+ `;
299
207
 
300
- const body = (await res.json()) as { data?: Record<string, any>; errors?: Array<{ message: string }> };
301
- if (body.errors?.length) {
302
- const msg =
303
- body.errors
304
- .map((e) => e.message)
305
- .filter(Boolean)
306
- .join(" | ") || "GitHub API error";
307
- throw classifyGraphQLError(msg) || new UpstreamError(msg);
308
- }
208
+ async function postGraphQL(query: string, variables: Record<string, unknown>) {
209
+ return githubClient.request(query, {
210
+ variables,
211
+ timeoutMs: FETCH_TIMEOUT_MS,
212
+ });
213
+ }
309
214
 
310
- if (!body.data) {
311
- throw new UpstreamError("GitHub API returned no data");
215
+ type LangMap = Map<string, { size: number; color: string }>;
216
+ function accLangs(edges: any[], map: LangMap) {
217
+ if (!edges) return;
218
+ for (const e of edges) {
219
+ if (!e?.node?.name || !e.size) continue;
220
+ const cur = map.get(e.node.name);
221
+ if (cur) cur.size += e.size;
222
+ else map.set(e.node.name, { size: e.size, color: e.node.color || "#ccc" });
312
223
  }
313
-
314
- return body;
315
224
  }
316
225
 
317
- type RepoResult = { stars: number; repos: number; langMap: LangMap | null };
318
- type PersonalResult = RepoResult & { user: any };
319
-
320
- /** Fetches an avatar image and returns it as a base64 data URI. */
321
- async function fetchAvatarDataUrl(url: string): Promise<string> {
226
+ async function fetchAvatarAsBase64(url: string | null | undefined): Promise<string> {
227
+ if (!url) return "";
322
228
  try {
323
- const res = await fetch(url, {
324
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
325
- headers: { "User-Agent": "github-card" },
229
+ const target = new URL(url);
230
+ target.searchParams.set("s", "150");
231
+ const res = await fetch(target.toString(), {
232
+ signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS),
326
233
  });
327
234
  if (!res.ok) return url;
235
+ const contentType = res.headers.get("content-type") || "image/png";
236
+ if (!contentType.toLowerCase().startsWith("image/")) return url;
237
+ const contentLength = Number(res.headers.get("content-length") || 0);
238
+ if (contentLength > MAX_AVATAR_BYTES) return url;
328
239
  const buf = await res.arrayBuffer();
329
- const mime = res.headers.get("content-type") || "image/png";
330
- return `data:${mime};base64,${Buffer.from(buf).toString("base64")}`;
240
+ if (buf.byteLength > MAX_AVATAR_BYTES) return url;
241
+ return `data:${contentType};base64,${Buffer.from(buf).toString("base64")}`;
331
242
  } catch {
332
- return url; // fallback to raw URL
243
+ return url;
333
244
  }
334
245
  }
335
246
 
336
- /** Fetches user metadata only (no repos). */
337
- async function fetchUserMeta(username: string, from: string, to: string): Promise<any> {
338
- const body = await postGraphQL(QUERY_USER_META, { login: username, from, to });
339
- if (!body.data?.user) throw new NotFoundError("User not found");
340
- return body.data.user;
247
+ function sumContributions(entries: any[], orgsSet: Set<string> | null) {
248
+ let tot = 0;
249
+ for (const e of entries || []) {
250
+ const owner = e?.repository?.owner;
251
+ if (owner?.__typename !== "Organization" || !owner?.login) continue;
252
+ if (orgsSet && !orgsSet.has(owner.login.toLowerCase())) continue;
253
+ tot += e?.contributions?.totalCount || 0;
254
+ }
255
+ return tot;
341
256
  }
342
257
 
343
- /** Paginates user-owned repos and returns stars, repo count, language map, and user metadata. */
344
- async function fetchPersonalRepos(
345
- username: string,
346
- langs: boolean,
347
- from: string,
348
- to: string,
349
- ): Promise<PersonalResult> {
350
- const query = buildUserReposQuery(langs);
351
- const langMap: LangMap | null = langs ? new Map() : null;
352
- let hasNext = true;
353
- let cursor: string | null = null;
258
+ async function directFetch(username: string, opts: FetchOptions): Promise<ProfileData> {
259
+ const isPersonal = opts.scope !== "org";
260
+ const isOrg = opts.scope === "org" || opts.scope === "all";
261
+ const fetchLangs = opts.includeLanguages ?? true;
262
+ const orgsArr = normalizeOrgFilters(opts.orgs);
263
+ const orgsSet = orgsArr.length > 0 ? new Set(orgsArr) : null;
264
+ const affiliations =
265
+ opts.affiliations === "owner" ? ["OWNER"] : ["OWNER", "ORGANIZATION_MEMBER", "COLLABORATOR"];
266
+
267
+ const now = new Date();
268
+ const from = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)).toISOString();
269
+ const to = now.toISOString();
270
+
354
271
  let user: any = null;
272
+ let isOrgAccount = false;
273
+ try {
274
+ const data = await postGraphQL(QUERY_PROFILE, {
275
+ login: username,
276
+ from,
277
+ to,
278
+ isOrg,
279
+ });
280
+ user = data.user;
281
+ if (!user) throw new NotFoundError();
282
+ } catch (err) {
283
+ if (err instanceof NotFoundError) {
284
+ const data = await postGraphQL(QUERY_ORG, {
285
+ login: username,
286
+ });
287
+ user = data.organization;
288
+ if (!user) throw new NotFoundError();
289
+ isOrgAccount = true;
290
+ } else throw err;
291
+ }
292
+
293
+ const langMap: LangMap = new Map();
294
+ const seenRepos = new Set<string>();
355
295
  let stars = 0;
356
- let repos = 0;
357
- let page = 0;
358
-
359
- while (hasNext && page++ < MAX_PAGES) {
360
- const body = await postGraphQL(query, { login: username, cursor, from, to });
361
- if (!body.data?.user) throw new NotFoundError("User not found");
362
- if (!user) user = body.data.user;
363
-
364
- const r = body.data.user.repositories;
365
- if (!repos) repos = r?.totalCount || 0;
366
- for (const node of r?.nodes || []) {
367
- stars += node?.stargazers?.totalCount || 0;
368
- if (langMap) accumulateLanguageEdges(node?.languages?.edges || [], langMap);
296
+ let reposCount = 0;
297
+
298
+ const processNode = (n: any, scopeCheck: boolean) => {
299
+ if (!n || !n.nameWithOwner || seenRepos.has(n.nameWithOwner)) return;
300
+ if (
301
+ scopeCheck &&
302
+ n.owner?.__typename === "Organization" &&
303
+ orgsSet &&
304
+ !orgsSet.has(n.owner.login.toLowerCase())
305
+ )
306
+ return;
307
+ seenRepos.add(n.nameWithOwner);
308
+ stars += n.stargazers?.totalCount || 0;
309
+ reposCount++;
310
+ if (fetchLangs) accLangs(n.languages?.edges, langMap);
311
+ };
312
+
313
+ const getConnection = (data: any, query: string) =>
314
+ data.user
315
+ ? query.includes("repositoriesContributedTo")
316
+ ? data.user.repositoriesContributedTo
317
+ : data.user.repositories
318
+ : data.organization?.repositories;
319
+
320
+ const traversePages = async (query: string, scopeCheck: boolean) => {
321
+ let cursor: string | null = null;
322
+ let page = 0;
323
+ let hasNext = true;
324
+
325
+ while (hasNext && page++ < MAX_PAGES) {
326
+ const data = await postGraphQL(query, {
327
+ login: username,
328
+ cursor,
329
+ pageSize: REPO_PAGE_SIZE,
330
+ fetchLangs,
331
+ affiliations,
332
+ });
333
+ const nextConn = getConnection(data, query);
334
+ for (const n of nextConn?.nodes || []) processNode(n, scopeCheck);
335
+ hasNext = nextConn?.pageInfo?.hasNextPage;
336
+ cursor = nextConn?.pageInfo?.endCursor;
337
+ if (hasNext && !cursor) break;
369
338
  }
339
+ };
370
340
 
371
- hasNext = Boolean(r?.pageInfo?.hasNextPage);
372
- cursor = r?.pageInfo?.endCursor ?? null;
373
- if (!r?.nodes?.length) hasNext = false;
341
+ const tasks: Promise<void>[] = [];
342
+ if (!isOrgAccount) {
343
+ if (isPersonal) tasks.push(traversePages(QUERY_PAGINATE_USER_REPOS, false));
344
+ if (isOrg) tasks.push(traversePages(QUERY_PAGINATE_ORG_CONTRIBS, true));
345
+ } else {
346
+ tasks.push(traversePages(QUERY_PAGINATE_ORG_REPOS, false));
374
347
  }
375
348
 
376
- return { user, stars, repos, langMap };
377
- }
349
+ const [avatarDataUrl] = await Promise.all([fetchAvatarAsBase64(user.avatarUrl), ...tasks]);
378
350
 
379
- /**
380
- * Fetches repos the user actually contributed to (commits, PRs, or owns)
381
- * that are owned by organizations — not the user themselves.
382
- * Filters by org login when `orgsFilter` is non-empty.
383
- */
384
- async function fetchOrgContributions(
385
- username: string,
386
- langs: boolean,
387
- orgsFilter: string[],
388
- ): Promise<RepoResult> {
389
- const query = buildOrgContribsQuery(langs);
390
- const langMap: LangMap | null = langs ? new Map() : null;
391
- const filterSet = orgsFilter.length > 0 ? new Set(orgsFilter) : null;
392
- let hasNext = true;
393
- let cursor: string | null = null;
394
- let stars = 0;
395
- let repos = 0;
396
- let page = 0;
397
-
398
- while (hasNext && page++ < MAX_PAGES) {
399
- const body = await postGraphQL(query, { login: username, cursor });
400
- if (!body.data?.user) throw new NotFoundError("User not found");
401
-
402
- const c = body.data.user.repositoriesContributedTo;
403
- for (const repo of c?.nodes || []) {
404
- if (repo?.owner?.__typename !== "Organization") continue;
405
- if (filterSet && !filterSet.has(repo.owner.login.toLowerCase())) continue;
406
- stars += repo.stargazers?.totalCount || 0;
407
- repos += 1;
408
- if (langMap) accumulateLanguageEdges(repo.languages?.edges || [], langMap);
409
- }
351
+ let commits = 0;
352
+ let prs = 0;
353
+ let issues = 0;
410
354
 
411
- hasNext = Boolean(c?.pageInfo?.hasNextPage);
412
- cursor = c?.pageInfo?.endCursor || null;
413
- if (!c?.nodes?.length) hasNext = false;
355
+ if (!isOrgAccount) {
356
+ const c = user.contributionsCollection;
357
+ if (opts.scope === "org") {
358
+ commits = sumContributions(c?.commitContributionsByRepository, orgsSet);
359
+ prs = sumContributions(c?.pullRequestContributionsByRepository, orgsSet);
360
+ issues = sumContributions(c?.issueContributionsByRepository, orgsSet);
361
+ } else {
362
+ commits = c?.totalCommitContributions || 0;
363
+ prs =
364
+ (user.openPRs?.totalCount || 0) +
365
+ (user.closedPRs?.totalCount || 0) +
366
+ (user.mergedPRs?.totalCount || 0);
367
+ issues = (user.openIssues?.totalCount || 0) + (user.closedIssues?.totalCount || 0);
368
+ }
414
369
  }
415
370
 
416
- return { stars, repos, langMap };
371
+ const languages = Array.from(langMap.entries())
372
+ .map(([name, { size, color }]) => ({ name, size, color }))
373
+ .sort((a, b) => b.size - a.size)
374
+ .slice(0, opts.langCount || 5);
375
+
376
+ return {
377
+ user: {
378
+ login: user.login,
379
+ name: user.name,
380
+ avatarUrl: avatarDataUrl,
381
+ bio: user.bio || user.description,
382
+ pronouns: user.pronouns,
383
+ twitter: user.twitterUsername,
384
+ },
385
+ stats: { stars, repos: reposCount, prs, issues, commits },
386
+ languages,
387
+ };
417
388
  }
418
389
 
419
390
  export async function getProfileData(
420
391
  username: string,
421
392
  opts: FetchOptions = {},
422
393
  ): Promise<ProfileData> {
423
- const includeLanguages = opts.includeLanguages ?? true;
424
- const langCount = Math.min(10, Math.max(1, opts.langCount ?? 5));
425
- const scope = normalizeScope(opts.scope);
426
- const forceRefresh = opts.forceRefresh ?? false;
427
- const orgs = Array.from(new Set((opts.orgs || []).map((org) => org.trim()).filter(Boolean)))
428
- .map((org) => org.toLowerCase())
429
- .sort();
430
- const cacheRaw = `v2:${username}:${scope}:${includeLanguages ? "langs" : "nolangs"}:${langCount}:${orgs.join("|")}`;
431
- const cacheKey = buildCacheKey(cacheRaw);
432
-
433
- if (!forceRefresh) {
434
- const cached = getCacheState(cacheKey);
435
- if (cached && !cached.stale) return cached.value;
436
-
437
- if (cached?.stale) {
438
- const existingStale = cache.get(cacheKey);
439
- if (existingStale && !existingStale.inFlight) {
440
- existingStale.inFlight = (async () => {
441
- try {
442
- const refreshed = await getProfileData(username, {
443
- includeLanguages,
444
- langCount,
445
- scope,
446
- orgs,
447
- forceRefresh: true,
448
- });
449
- return refreshed;
450
- } catch {
451
- return cached.value;
452
- } finally {
453
- const current = cache.get(cacheKey);
454
- if (current) current.inFlight = undefined;
455
- }
456
- })();
457
- }
458
- return cached.value;
394
+ const norm = normalizeUsername(username);
395
+ const orgs = normalizeOrgFilters(opts.orgs);
396
+ const cacheKey = Bun.hash(
397
+ `v${CACHE_VERSION}:${norm}:${opts.scope || "personal"}:${opts.affiliations || "affiliated"}:${opts.includeLanguages !== false}:${opts.langCount || 5}:${orgs.join("|")}`,
398
+ ).toString(36);
399
+
400
+ if (!opts.forceRefresh) {
401
+ const mem = memCache.get(cacheKey);
402
+ const now = Date.now();
403
+ if (mem && mem.expiresAt > now) {
404
+ if (mem.staleAt <= now && !refreshInFlight.has(cacheKey))
405
+ triggerBackgroundRefresh(username, opts, cacheKey);
406
+ return mem.data;
459
407
  }
460
- }
461
408
 
462
- const redis = await getRedis();
463
- if (!forceRefresh && redis) {
464
- try {
465
- const redisValue = await redis.get<ProfileData>(`profile:${cacheKey}`);
466
- if (redisValue) {
467
- setCache(cacheKey, redisValue);
468
- return redisValue;
469
- }
470
- } catch {}
409
+ const cached = await readRedisCache(cacheKey, now);
410
+ if (cached) {
411
+ setMemoryCache(cacheKey, cached);
412
+ if (cached.staleAt <= now && !refreshInFlight.has(cacheKey))
413
+ triggerBackgroundRefresh(username, opts, cacheKey);
414
+ return cached.data;
415
+ }
471
416
  }
472
417
 
473
- const existing = cache.get(cacheKey);
474
- if (!forceRefresh && existing?.inFlight) return existing.inFlight;
475
-
476
- const inFlight = (async () => {
477
- try {
478
- const now = new Date();
479
- const from = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)).toISOString();
480
- const to = now.toISOString();
481
-
482
- // Parallel data fetching based on scope
483
- let user: any;
484
- let pStars = 0,
485
- pRepos = 0,
486
- oStars = 0,
487
- oRepos = 0;
488
- let pLangs: LangMap | null = null;
489
- let oLangs: LangMap | null = null;
490
-
491
- if (scope === "personal") {
492
- const r = await fetchPersonalRepos(username, includeLanguages, from, to);
493
- user = r.user;
494
- pStars = r.stars;
495
- pRepos = r.repos;
496
- pLangs = r.langMap;
497
- } else if (scope === "org") {
498
- const [meta, org] = await Promise.all([
499
- fetchUserMeta(username, from, to),
500
- fetchOrgContributions(username, includeLanguages, orgs),
501
- ]);
502
- user = meta;
503
- oStars = org.stars;
504
- oRepos = org.repos;
505
- oLangs = org.langMap;
506
- } else {
507
- // scope === "all" — fetch personal + org in parallel
508
- const [personal, org] = await Promise.all([
509
- fetchPersonalRepos(username, includeLanguages, from, to),
510
- fetchOrgContributions(username, includeLanguages, orgs),
511
- ]);
512
- user = personal.user;
513
- pStars = personal.stars;
514
- pRepos = personal.repos;
515
- pLangs = personal.langMap;
516
- oStars = org.stars;
517
- oRepos = org.repos;
518
- oLangs = org.langMap;
519
- }
520
-
521
- const languages = includeLanguages
522
- ? buildLanguageStats(mergeLangMaps(pLangs, oLangs), langCount)
523
- : [];
524
-
525
- // Embed avatar as base64 data URI for reliable rendering in <img> contexts
526
- const avatarSized = user.avatarUrl + (user.avatarUrl.includes("?") ? "&" : "?") + "s=200";
527
- const avatarDataUrl = await fetchAvatarDataUrl(avatarSized);
528
-
529
- const profile: ProfileData = {
530
- user: {
531
- login: user.login,
532
- name: user.name,
533
- avatarUrl: avatarDataUrl,
534
- bio: user.bio,
535
- pronouns: user.pronouns,
536
- twitter: user.twitterUsername,
537
- },
538
- stats: {
539
- stars: scope === "org" ? oStars : pStars + oStars,
540
- repos: scope === "org" ? oRepos : pRepos + oRepos,
541
- prs:
542
- (user.openPRs?.totalCount || 0) +
543
- (user.closedPRs?.totalCount || 0) +
544
- (user.mergedPRs?.totalCount || 0),
545
- issues: (user.openIssues?.totalCount || 0) + (user.closedIssues?.totalCount || 0),
546
- commits: user.contributionsCollection?.totalCommitContributions || 0,
547
- },
548
- languages,
549
- };
550
-
551
- setCache(cacheKey, profile);
552
- if (redis) {
553
- try {
554
- await redis.set(`profile:${cacheKey}`, profile, {
555
- ex: CACHE_FRESH_SECONDS + CACHE_STALE_SECONDS,
556
- });
557
- } catch {}
558
- }
559
- return profile;
560
- } catch (err) {
561
- cache.delete(cacheKey);
562
- throw err;
563
- }
564
- })();
418
+ if (inFlight.has(cacheKey)) return inFlight.get(cacheKey)!;
565
419
 
566
- cache.set(cacheKey, {
567
- staleAt: Date.now() + CACHE_FRESH_SECONDS * 1000,
568
- expiresAt: Date.now() + (CACHE_FRESH_SECONDS + CACHE_STALE_SECONDS) * 1000,
569
- inFlight,
570
- });
571
- return inFlight;
420
+ const promise = directFetch(username, opts)
421
+ .then((data) => saveProfileCache(cacheKey, data))
422
+ .finally(() => {
423
+ inFlight.delete(cacheKey);
424
+ });
425
+
426
+ inFlight.set(cacheKey, promise);
427
+ return promise;
428
+ }
429
+
430
+ function triggerBackgroundRefresh(username: string, opts: FetchOptions, cacheKey: string) {
431
+ if (refreshInFlight.has(cacheKey)) return;
432
+ refreshInFlight.add(cacheKey);
433
+
434
+ void directFetch(username, { ...opts, forceRefresh: true })
435
+ .then((data) => saveProfileCache(cacheKey, data))
436
+ .catch(() => {})
437
+ .finally(() => {
438
+ refreshInFlight.delete(cacheKey);
439
+ });
572
440
  }