@shiinasaku/github-card 2.0.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 +21 -0
- package/README.md +354 -0
- package/fonts/Roboto-subset.woff2 +0 -0
- package/package.json +72 -0
- package/src/card.ts +137 -0
- package/src/github.ts +572 -0
- package/src/index.ts +355 -0
- package/src/lib.ts +21 -0
- package/src/types.ts +40 -0
- package/src/utils/font.ts +19 -0
- package/src/utils/format.ts +42 -0
- package/src/utils/icons.ts +17 -0
- package/src/utils/index.ts +5 -0
- package/src/utils/languages.ts +656 -0
- package/src/utils/themes.ts +47 -0
package/src/github.ts
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
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";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
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");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
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
|
+
}
|
|
67
|
+
|
|
68
|
+
if (status === 404) {
|
|
69
|
+
return new NotFoundError("User not found");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return new UpstreamError(`GitHub API error (${status})`);
|
|
73
|
+
}
|
|
74
|
+
|
|
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
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
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
|
+
}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
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
|
+
}
|
|
134
|
+
|
|
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
|
+
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
|
+
}
|
|
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
|
+
}
|
|
174
|
+
|
|
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
|
+
}
|
|
184
|
+
|
|
185
|
+
export function getCacheMetrics() {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
let fresh = 0;
|
|
188
|
+
let stale = 0;
|
|
189
|
+
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++;
|
|
197
|
+
else fresh++;
|
|
198
|
+
}
|
|
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
|
+
}
|
|
209
|
+
|
|
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
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
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() };
|
|
229
|
+
}
|
|
230
|
+
|
|
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" });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
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
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return merged;
|
|
269
|
+
}
|
|
270
|
+
|
|
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 }));
|
|
277
|
+
}
|
|
278
|
+
|
|
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
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!body.data) {
|
|
311
|
+
throw new UpstreamError("GitHub API returned no data");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return body;
|
|
315
|
+
}
|
|
316
|
+
|
|
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> {
|
|
322
|
+
try {
|
|
323
|
+
const res = await fetch(url, {
|
|
324
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
325
|
+
headers: { "User-Agent": "github-card" },
|
|
326
|
+
});
|
|
327
|
+
if (!res.ok) return url;
|
|
328
|
+
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")}`;
|
|
331
|
+
} catch {
|
|
332
|
+
return url; // fallback to raw URL
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
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;
|
|
341
|
+
}
|
|
342
|
+
|
|
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;
|
|
355
|
+
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);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
hasNext = Boolean(r?.pageInfo?.hasNextPage);
|
|
372
|
+
cursor = r?.pageInfo?.endCursor ?? null;
|
|
373
|
+
if (!r?.nodes?.length) hasNext = false;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return { user, stars, repos, langMap };
|
|
377
|
+
}
|
|
378
|
+
|
|
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
|
+
}
|
|
410
|
+
|
|
411
|
+
hasNext = Boolean(c?.pageInfo?.hasNextPage);
|
|
412
|
+
cursor = c?.pageInfo?.endCursor || null;
|
|
413
|
+
if (!c?.nodes?.length) hasNext = false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { stars, repos, langMap };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function getProfileData(
|
|
420
|
+
username: string,
|
|
421
|
+
opts: FetchOptions = {},
|
|
422
|
+
): 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;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
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 {}
|
|
471
|
+
}
|
|
472
|
+
|
|
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
|
+
})();
|
|
565
|
+
|
|
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;
|
|
572
|
+
}
|