@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/README.md +134 -293
- package/dist/card.d.ts +16 -0
- package/dist/lib.d.ts +13 -0
- package/dist/lib.js +1209 -0
- package/dist/types.d.ts +37 -0
- package/dist/utils/colors.d.ts +2 -0
- package/dist/utils/font.d.ts +2 -0
- package/dist/utils/format.d.ts +3 -0
- package/dist/utils/icons.d.ts +9 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/languages.d.ts +2 -0
- package/dist/utils/themes.d.ts +16 -0
- package/package.json +29 -18
- package/src/card.ts +634 -113
- package/src/env.d.ts +4 -0
- package/src/github.ts +351 -483
- package/src/index.ts +147 -134
- package/src/input.css +1 -0
- package/src/tailwind.css +3837 -0
- package/src/types.ts +1 -0
- package/src/utils/colors.ts +85 -0
- package/src/utils/font.ts +3 -19
- package/src/utils/format.ts +16 -16
- package/src/utils/github-client.ts +193 -0
- package/src/utils/icons.ts +2 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/themes.ts +154 -19
package/src/github.ts
CHANGED
|
@@ -1,572 +1,440 @@
|
|
|
1
|
-
import type { ProfileData
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
192
|
-
if (entry.expiresAt <= now)
|
|
193
|
-
|
|
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:
|
|
121
|
+
total: memCache.size,
|
|
122
|
+
maxEntries: MAX_L1_ENTRIES,
|
|
202
123
|
fresh,
|
|
203
124
|
stale,
|
|
204
125
|
expired,
|
|
205
|
-
ttlFreshSeconds:
|
|
206
|
-
ttlStaleSeconds:
|
|
126
|
+
ttlFreshSeconds: 1800,
|
|
127
|
+
ttlStaleSeconds: 1800,
|
|
207
128
|
};
|
|
208
129
|
}
|
|
209
130
|
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
222
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
311
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
330
|
-
return `data:${
|
|
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;
|
|
243
|
+
return url;
|
|
333
244
|
}
|
|
334
245
|
}
|
|
335
246
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
if (
|
|
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
|
-
|
|
377
|
-
}
|
|
349
|
+
const [avatarDataUrl] = await Promise.all([fetchAvatarAsBase64(user.avatarUrl), ...tasks]);
|
|
378
350
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
if (
|
|
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
|
-
|
|
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
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
}
|