@shiinasaku/github-card 2.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/github.ts CHANGED
@@ -1,572 +1,498 @@
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";
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 ?? "",
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 = 8;
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);
21
34
  }
35
+ } catch (e) {
36
+ console.error("Bun RedisClient failed to initialize", e);
22
37
  }
23
38
 
24
- export class UpstreamError extends Error {
25
- constructor(message = "GitHub API request failed") {
26
- super(message);
27
- this.name = "UpstreamError";
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;
28
45
  }
29
46
  }
30
47
 
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
- }
48
+ type CachedData = {
49
+ v: number;
50
+ staleAt: number;
51
+ expiresAt: number;
52
+ data: ProfileData;
53
+ };
41
54
 
42
- if (lower.includes("rate limit") || lower.includes("api rate limit exceeded")) {
43
- return new RateLimitError("GitHub API rate limit exceeded");
44
- }
55
+ const memCache = new Map<string, CachedData>();
56
+ const inFlight = new Map<string, Promise<ProfileData>>();
57
+ const refreshInFlight = new Set<string>();
45
58
 
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");
52
- }
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);
63
+ }
53
64
 
54
- return null;
65
+ function setMemoryCache(cacheKey: string, payload: CachedData) {
66
+ memCache.set(cacheKey, payload);
67
+ evictL1IfNeeded();
55
68
  }
56
69
 
57
- function classifyHttpError(status: number, bodyText: string): Error {
58
- const lower = bodyText.toLowerCase();
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 {}
80
+ }
59
81
 
60
- if (status === 401 || lower.includes("bad credentials")) {
61
- return new AuthError("GitHub authentication failed");
62
- }
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;
63
87
 
64
- if (status === 403 && (lower.includes("rate limit") || lower.includes("abuse detection"))) {
65
- return new RateLimitError("GitHub API rate limit exceeded");
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;
66
93
  }
94
+ }
67
95
 
68
- if (status === 404) {
69
- return new NotFoundError("User not found");
70
- }
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
+ };
71
104
 
72
- return new UpstreamError(`GitHub API error (${status})`);
105
+ setMemoryCache(cacheKey, payload);
106
+ await writeRedisCache(cacheKey, payload);
107
+ return data;
73
108
  }
74
109
 
75
- function getHeaders() {
76
- const token = Bun.env.GITHUB_TOKEN;
77
- if (!token) throw new AuthError("GITHUB_TOKEN is missing");
110
+ export function getCacheMetrics() {
111
+ let fresh = 0;
112
+ let stale = 0;
113
+ let expired = 0;
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++;
118
+ else fresh++;
119
+ }
78
120
  return {
79
- Authorization: `bearer ${token}`,
80
- "Content-Type": "application/json",
81
- "User-Agent": "github-card",
121
+ total: memCache.size,
122
+ maxEntries: MAX_L1_ENTRIES,
123
+ fresh,
124
+ stale,
125
+ expired,
126
+ ttlFreshSeconds: CACHE_FRESH_MS / 1000,
127
+ ttlStaleSeconds: CACHE_STALE_MS / 1000,
82
128
  };
83
129
  }
84
130
 
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
- }`;
131
+ function normalizeUsername(username: string): string {
132
+ return username.trim().toLowerCase();
112
133
  }
113
134
 
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
- }`;
135
+ function normalizeOrgFilters(orgs?: string[]): string[] {
136
+ return Array.from(new Set((orgs || []).map((o) => o.trim().toLowerCase()).filter(Boolean)));
133
137
  }
134
138
 
135
- const QUERY_USER_META = `
136
- query userMeta($login: String!, $from: DateTime!, $to: DateTime!) {
137
- user(login: $login) {
138
- ${USER_META_FIELDS}
139
- }
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
139
  type FetchOptions = {
155
140
  includeLanguages?: boolean;
156
141
  langCount?: number;
157
142
  scope?: "personal" | "org" | "all";
158
143
  orgs?: string[];
144
+ affiliations?: "owner" | "affiliated";
159
145
  forceRefresh?: boolean;
160
146
  };
161
147
 
162
- function normalizeScope(value?: string): "personal" | "org" | "all" {
163
- if (value === "org" || value === "all") return value;
164
- return "personal";
165
- }
166
-
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
- }
148
+ type RepoNode = {
149
+ nameWithOwner: string;
150
+ stargazers: { totalCount: number };
151
+ owner?: { login: string; __typename: string } | null;
152
+ languages?: { edges: LangEdge[] | null } | null;
153
+ };
174
154
 
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;
183
- }
155
+ type PageInfo = { hasNextPage: boolean; endCursor: string | null };
156
+ type RepoConnection = { pageInfo: PageInfo; nodes: RepoNode[] | null };
157
+
158
+ type ProfileUser = {
159
+ login: string;
160
+ name: string | null;
161
+ avatarUrl: string;
162
+ bio: string | null;
163
+ pronouns: string | null;
164
+ twitterUsername: string | null;
165
+ openPRs: { totalCount: number };
166
+ closedPRs: { totalCount: number };
167
+ mergedPRs: { totalCount: number };
168
+ openIssues: { totalCount: number };
169
+ closedIssues: { totalCount: number };
170
+ contributionsCollection: {
171
+ totalCommitContributions: number;
172
+ commitContributionsByRepository?: ContributionEntry[] | null;
173
+ pullRequestContributionsByRepository?: ContributionEntry[] | null;
174
+ issueContributionsByRepository?: ContributionEntry[] | null;
175
+ };
176
+ };
184
177
 
185
- export function getCacheMetrics() {
186
- const now = Date.now();
187
- let fresh = 0;
188
- let stale = 0;
189
- let expired = 0;
178
+ type OrgProfile = {
179
+ login: string;
180
+ name: string | null;
181
+ avatarUrl: string;
182
+ description: string | null;
183
+ twitterUsername: string | null;
184
+ };
190
185
 
191
- for (const entry of cache.values()) {
192
- if (entry.expiresAt <= now) {
193
- expired++;
194
- continue;
186
+ const QUERY_PROFILE = `
187
+ query fullProfile($login: String!, $from: DateTime!, $to: DateTime!, $isOrg: Boolean!) {
188
+ user(login: $login) {
189
+ login name avatarUrl bio pronouns twitterUsername
190
+ openPRs: pullRequests(states: OPEN) { totalCount }
191
+ closedPRs: pullRequests(states: CLOSED) { totalCount }
192
+ mergedPRs: pullRequests(states: MERGED) { totalCount }
193
+ openIssues: issues(states: OPEN) { totalCount }
194
+ closedIssues: issues(states: CLOSED) { totalCount }
195
+ contributionsCollection(from: $from, to: $to) {
196
+ totalCommitContributions
197
+ commitContributionsByRepository(maxRepositories: 100) @include(if: $isOrg) { repository { owner { login __typename } } contributions(first:1) { totalCount } }
198
+ pullRequestContributionsByRepository(maxRepositories: 100) @include(if: $isOrg) { repository { owner { login __typename } } contributions(first:1) { totalCount } }
199
+ issueContributionsByRepository(maxRepositories: 100) @include(if: $isOrg) { repository { owner { login __typename } } contributions(first:1) { totalCount } }
195
200
  }
196
- if (entry.staleAt <= now) stale++;
197
- else fresh++;
198
201
  }
199
-
200
- return {
201
- total: cache.size,
202
- fresh,
203
- stale,
204
- expired,
205
- ttlFreshSeconds: CACHE_FRESH_SECONDS,
206
- ttlStaleSeconds: CACHE_STALE_SECONDS,
207
- };
208
202
  }
203
+ `;
209
204
 
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;
205
+ const QUERY_ORG = `
206
+ query fullOrg($login: String!) {
207
+ organization(login: $login) {
208
+ login name avatarUrl description twitterUsername
218
209
  }
219
210
  }
211
+ `;
220
212
 
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;
213
+ const QUERY_PAGINATE_USER_REPOS = `
214
+ query paginateRepos($login: String!, $cursor: String, $pageSize: Int!, $affiliations: [RepositoryAffiliation!], $fetchLangs: Boolean!) {
215
+ user(login: $login) {
216
+ repositories(first: $pageSize, after: $cursor, ownerAffiliations: $affiliations, isFork: false, orderBy: {direction: DESC, field: STARGAZERS}) {
217
+ pageInfo { hasNextPage endCursor }
218
+ nodes { nameWithOwner stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
219
+ }
227
220
  }
228
- return { value: entry.value, stale: entry.staleAt <= Date.now() };
229
221
  }
222
+ `;
230
223
 
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
- }
239
-
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" });
224
+ const QUERY_PAGINATE_ORG_CONTRIBS = `
225
+ query paginateOrgContribs($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs: Boolean!) {
226
+ user(login: $login) {
227
+ repositoriesContributedTo(first: $pageSize, contributionTypes: [COMMIT, PULL_REQUEST, REPOSITORY], includeUserRepositories: false, after: $cursor) {
228
+ pageInfo { hasNextPage endCursor }
229
+ nodes { nameWithOwner owner { login __typename } stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
251
230
  }
252
231
  }
253
232
  }
233
+ `;
254
234
 
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 });
235
+ const QUERY_PAGINATE_ORG_REPOS = `
236
+ query paginateOrg($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs: Boolean!) {
237
+ organization(login: $login) {
238
+ repositories(first: $pageSize, isFork: false, orderBy: {direction: DESC, field: STARGAZERS}, after: $cursor) {
239
+ pageInfo { hasNextPage endCursor }
240
+ nodes { nameWithOwner stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
266
241
  }
267
242
  }
268
- return merged;
269
243
  }
244
+ `;
270
245
 
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 }));
246
+ async function postGraphQL<T>(query: string, variables: Record<string, unknown>): Promise<T> {
247
+ return githubClient.request<T>(query, {
248
+ variables,
249
+ timeoutMs: FETCH_TIMEOUT_MS,
250
+ });
277
251
  }
278
252
 
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");
291
- }
292
- throw new UpstreamError("GitHub API request failed");
293
- }
294
-
295
- if (!res.ok) {
296
- const text = await res.text();
297
- throw classifyHttpError(res.status, text);
298
- }
299
-
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
- }
253
+ type LangMap = Map<string, { size: number; color: string }>;
254
+ type LangEdge = { size: number; node: { name: string; color: string | null } };
309
255
 
310
- if (!body.data) {
311
- throw new UpstreamError("GitHub API returned no data");
256
+ function accLangs(edges: LangEdge[] | null | undefined, map: LangMap): void {
257
+ if (!edges?.length) return;
258
+ for (const edge of edges) {
259
+ if (!edge?.node?.name || !edge.size) continue;
260
+ const existing = map.get(edge.node.name);
261
+ if (existing) existing.size += edge.size;
262
+ else map.set(edge.node.name, { size: edge.size, color: edge.node.color ?? "#ccc" });
312
263
  }
313
-
314
- return body;
315
264
  }
316
265
 
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> {
266
+ async function fetchAvatarAsBase64(url: string | null | undefined): Promise<string> {
267
+ if (!url) return "";
322
268
  try {
323
- const res = await fetch(url, {
324
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
325
- headers: { "User-Agent": "github-card" },
269
+ const target = new URL(url);
270
+ target.searchParams.set("s", "150");
271
+ const res = await fetch(target.toString(), {
272
+ signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS),
326
273
  });
327
274
  if (!res.ok) return url;
275
+ const contentType = res.headers.get("content-type") || "image/png";
276
+ if (!contentType.toLowerCase().startsWith("image/")) return url;
277
+ const contentLength = Number(res.headers.get("content-length") || 0);
278
+ if (contentLength > MAX_AVATAR_BYTES) return url;
328
279
  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")}`;
280
+ if (buf.byteLength > MAX_AVATAR_BYTES) return url;
281
+ return `data:${contentType};base64,${Buffer.from(buf).toString("base64")}`;
331
282
  } catch {
332
- return url; // fallback to raw URL
283
+ return url;
333
284
  }
334
285
  }
335
286
 
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;
287
+ type ContributionEntry = {
288
+ repository?: { owner?: { login?: string; __typename?: string } | null } | null;
289
+ contributions?: { totalCount?: number } | null;
290
+ };
291
+
292
+ function sumContributions(
293
+ entries: ContributionEntry[] | null | undefined,
294
+ orgsSet: Set<string> | null,
295
+ ): number {
296
+ if (!entries?.length) return 0;
297
+ let total = 0;
298
+ for (const entry of entries) {
299
+ const owner = entry?.repository?.owner;
300
+ if (owner?.__typename !== "Organization" || !owner?.login) continue;
301
+ if (orgsSet && !orgsSet.has(owner.login.toLowerCase())) continue;
302
+ total += entry?.contributions?.totalCount ?? 0;
303
+ }
304
+ return total;
341
305
  }
342
306
 
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;
354
- let user: any = null;
307
+ async function directFetch(username: string, opts: FetchOptions): Promise<ProfileData> {
308
+ const isPersonal = opts.scope !== "org";
309
+ const isOrg = opts.scope === "org" || opts.scope === "all";
310
+ const fetchLangs = opts.includeLanguages ?? true;
311
+ const orgsArr = normalizeOrgFilters(opts.orgs);
312
+ const orgsSet = orgsArr.length > 0 ? new Set(orgsArr) : null;
313
+ const affiliations =
314
+ opts.affiliations === "owner" ? ["OWNER"] : ["OWNER", "ORGANIZATION_MEMBER", "COLLABORATOR"];
315
+
316
+ const now = new Date();
317
+ const from = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)).toISOString();
318
+ const to = now.toISOString();
319
+
320
+ let user: ProfileUser | OrgProfile | null = null;
321
+ let isOrgAccount = false;
322
+ try {
323
+ const data = await postGraphQL<{ user: ProfileUser | null }>(QUERY_PROFILE, {
324
+ login: username,
325
+ from,
326
+ to,
327
+ isOrg,
328
+ });
329
+ user = data.user;
330
+ if (!user) throw new NotFoundError();
331
+ } catch (err) {
332
+ if (err instanceof NotFoundError) {
333
+ const data = await postGraphQL<{ organization: OrgProfile | null }>(QUERY_ORG, {
334
+ login: username,
335
+ });
336
+ user = data.organization;
337
+ if (!user) throw new NotFoundError();
338
+ isOrgAccount = true;
339
+ } else throw err;
340
+ }
341
+
342
+ const langMap: LangMap = new Map();
343
+ const seenRepos = new Set<string>();
355
344
  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);
345
+ let reposCount = 0;
346
+
347
+ const processNode = (node: RepoNode, scopeCheck: boolean): void => {
348
+ if (!node?.nameWithOwner || seenRepos.has(node.nameWithOwner)) return;
349
+ if (
350
+ scopeCheck &&
351
+ node.owner?.__typename === "Organization" &&
352
+ orgsSet &&
353
+ !orgsSet.has(node.owner.login.toLowerCase())
354
+ )
355
+ return;
356
+ seenRepos.add(node.nameWithOwner);
357
+ stars += node.stargazers?.totalCount ?? 0;
358
+ reposCount++;
359
+ if (fetchLangs) accLangs(node.languages?.edges ?? null, langMap);
360
+ };
361
+
362
+ type PaginateData = {
363
+ user?: { repositories?: RepoConnection; repositoriesContributedTo?: RepoConnection } | null;
364
+ organization?: { repositories: RepoConnection } | null;
365
+ };
366
+
367
+ const getConnection = (data: PaginateData, query: string): RepoConnection | undefined =>
368
+ data.user
369
+ ? query.includes("repositoriesContributedTo")
370
+ ? (data.user.repositoriesContributedTo ?? undefined)
371
+ : (data.user.repositories ?? undefined)
372
+ : data.organization?.repositories;
373
+
374
+ const traversePages = async (query: string, scopeCheck: boolean): Promise<void> => {
375
+ let cursor: string | null = null;
376
+ let page = 0;
377
+ let hasNext = true;
378
+
379
+ while (hasNext && page++ < MAX_PAGES) {
380
+ const data = await postGraphQL<PaginateData>(query, {
381
+ login: username,
382
+ cursor,
383
+ pageSize: REPO_PAGE_SIZE,
384
+ fetchLangs,
385
+ affiliations,
386
+ });
387
+ const nextConn = getConnection(data, query);
388
+ for (const node of nextConn?.nodes ?? []) processNode(node, scopeCheck);
389
+ hasNext = nextConn?.pageInfo?.hasNextPage ?? false;
390
+ cursor = nextConn?.pageInfo?.endCursor ?? null;
391
+ if (hasNext && !cursor) break;
369
392
  }
393
+ };
370
394
 
371
- hasNext = Boolean(r?.pageInfo?.hasNextPage);
372
- cursor = r?.pageInfo?.endCursor ?? null;
373
- if (!r?.nodes?.length) hasNext = false;
395
+ const tasks: Promise<void>[] = [];
396
+ if (!isOrgAccount) {
397
+ if (isPersonal) tasks.push(traversePages(QUERY_PAGINATE_USER_REPOS, false));
398
+ if (isOrg) tasks.push(traversePages(QUERY_PAGINATE_ORG_CONTRIBS, true));
399
+ } else {
400
+ tasks.push(traversePages(QUERY_PAGINATE_ORG_REPOS, false));
374
401
  }
375
402
 
376
- return { user, stars, repos, langMap };
377
- }
403
+ const [avatarDataUrl] = await Promise.all([fetchAvatarAsBase64(user.avatarUrl), ...tasks]);
378
404
 
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
- }
405
+ let commits = 0;
406
+ let prs = 0;
407
+ let issues = 0;
410
408
 
411
- hasNext = Boolean(c?.pageInfo?.hasNextPage);
412
- cursor = c?.pageInfo?.endCursor || null;
413
- if (!c?.nodes?.length) hasNext = false;
409
+ if (!isOrgAccount) {
410
+ const profile = user as ProfileUser;
411
+ const c = profile.contributionsCollection;
412
+ if (opts.scope === "org") {
413
+ commits = sumContributions(c?.commitContributionsByRepository, orgsSet);
414
+ prs = sumContributions(c?.pullRequestContributionsByRepository, orgsSet);
415
+ issues = sumContributions(c?.issueContributionsByRepository, orgsSet);
416
+ } else {
417
+ commits = c?.totalCommitContributions ?? 0;
418
+ prs =
419
+ (profile.openPRs?.totalCount ?? 0) +
420
+ (profile.closedPRs?.totalCount ?? 0) +
421
+ (profile.mergedPRs?.totalCount ?? 0);
422
+ issues = (profile.openIssues?.totalCount ?? 0) + (profile.closedIssues?.totalCount ?? 0);
423
+ }
414
424
  }
415
425
 
416
- return { stars, repos, langMap };
426
+ const languages = Array.from(langMap.entries())
427
+ .map(([name, { size, color }]) => ({ name, size, color }))
428
+ .sort((a, b) => b.size - a.size)
429
+ .slice(0, opts.langCount || 5);
430
+
431
+ const profileUser = !isOrgAccount ? (user as ProfileUser) : null;
432
+ const orgUser = isOrgAccount ? (user as OrgProfile) : null;
433
+
434
+ return {
435
+ user: {
436
+ login: user!.login,
437
+ name: user!.name,
438
+ avatarUrl: avatarDataUrl,
439
+ bio: isOrgAccount ? (orgUser?.description ?? null) : (profileUser?.bio ?? null),
440
+ pronouns: isOrgAccount ? null : (profileUser?.pronouns ?? null),
441
+ twitter: user!.twitterUsername ?? null,
442
+ },
443
+ stats: { stars, repos: reposCount, prs, issues, commits },
444
+ languages,
445
+ };
417
446
  }
418
447
 
419
448
  export async function getProfileData(
420
449
  username: string,
421
450
  opts: FetchOptions = {},
422
451
  ): 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;
452
+ const norm = normalizeUsername(username);
453
+ const orgs = normalizeOrgFilters(opts.orgs);
454
+ const cacheKey = Bun.hash(
455
+ `v${CACHE_VERSION}:${norm}:${opts.scope || "personal"}:${opts.affiliations || "owner"}:${opts.includeLanguages !== false}:${opts.langCount || 5}:${orgs.join("|")}`,
456
+ ).toString(36);
457
+
458
+ if (!opts.forceRefresh) {
459
+ const mem = memCache.get(cacheKey);
460
+ const now = Date.now();
461
+ if (mem && mem.expiresAt > now) {
462
+ if (mem.staleAt <= now && !refreshInFlight.has(cacheKey))
463
+ triggerBackgroundRefresh(username, opts, cacheKey);
464
+ return mem.data;
459
465
  }
460
- }
461
466
 
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 {}
467
+ const cached = await readRedisCache(cacheKey, now);
468
+ if (cached) {
469
+ setMemoryCache(cacheKey, cached);
470
+ if (cached.staleAt <= now && !refreshInFlight.has(cacheKey))
471
+ triggerBackgroundRefresh(username, opts, cacheKey);
472
+ return cached.data;
473
+ }
471
474
  }
472
475
 
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
- })();
476
+ if (inFlight.has(cacheKey)) return inFlight.get(cacheKey)!;
565
477
 
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;
478
+ const promise = directFetch(username, opts)
479
+ .then((data) => saveProfileCache(cacheKey, data))
480
+ .finally(() => {
481
+ inFlight.delete(cacheKey);
482
+ });
483
+
484
+ inFlight.set(cacheKey, promise);
485
+ return promise;
486
+ }
487
+
488
+ function triggerBackgroundRefresh(username: string, opts: FetchOptions, cacheKey: string) {
489
+ if (refreshInFlight.has(cacheKey)) return;
490
+ refreshInFlight.add(cacheKey);
491
+
492
+ void directFetch(username, { ...opts, forceRefresh: true })
493
+ .then((data) => saveProfileCache(cacheKey, data))
494
+ .catch(() => {})
495
+ .finally(() => {
496
+ refreshInFlight.delete(cacheKey);
497
+ });
572
498
  }