@urmzd/github-insights 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/.gitattributes +28 -0
- package/.github/dependabot.yml +6 -0
- package/.github/pull_request_template.md +14 -0
- package/.github/workflows/ci.yml +93 -0
- package/.github/workflows/release.yml +59 -0
- package/.nvmrc +1 -0
- package/.pre-commit-config.yaml +5 -0
- package/AGENTS.md +69 -0
- package/CHANGELOG.md +260 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +190 -0
- package/README.md +188 -0
- package/action.yml +45 -0
- package/biome.json +40 -0
- package/examples/classic/README.md +9 -0
- package/examples/classic/index.svg +14 -0
- package/examples/classic/metrics-calendar.svg +14 -0
- package/examples/classic/metrics-complexity.svg +14 -0
- package/examples/classic/metrics-contributions.svg +14 -0
- package/examples/classic/metrics-expertise.svg +14 -0
- package/examples/classic/metrics-languages.svg +14 -0
- package/examples/classic/metrics-pulse.svg +14 -0
- package/examples/ecosystem/README.md +59 -0
- package/examples/ecosystem/index.svg +14 -0
- package/examples/ecosystem/metrics-calendar.svg +14 -0
- package/examples/ecosystem/metrics-complexity.svg +14 -0
- package/examples/ecosystem/metrics-contributions.svg +14 -0
- package/examples/ecosystem/metrics-expertise.svg +14 -0
- package/examples/ecosystem/metrics-languages.svg +14 -0
- package/examples/ecosystem/metrics-pulse.svg +14 -0
- package/examples/minimal/README.md +9 -0
- package/examples/minimal/index.svg +14 -0
- package/examples/minimal/metrics-calendar.svg +14 -0
- package/examples/minimal/metrics-complexity.svg +14 -0
- package/examples/minimal/metrics-contributions.svg +14 -0
- package/examples/minimal/metrics-expertise.svg +14 -0
- package/examples/minimal/metrics-languages.svg +14 -0
- package/examples/minimal/metrics-pulse.svg +14 -0
- package/examples/modern/README.md +111 -0
- package/examples/modern/index.svg +14 -0
- package/examples/modern/metrics-calendar.svg +14 -0
- package/examples/modern/metrics-complexity.svg +14 -0
- package/examples/modern/metrics-contributions.svg +14 -0
- package/examples/modern/metrics-expertise.svg +14 -0
- package/examples/modern/metrics-languages.svg +14 -0
- package/examples/modern/metrics-pulse.svg +14 -0
- package/llms.txt +24 -0
- package/metrics/index.svg +14 -0
- package/metrics/metrics-calendar.svg +14 -0
- package/metrics/metrics-complexity.svg +14 -0
- package/metrics/metrics-contributions.svg +14 -0
- package/metrics/metrics-domains.svg +14 -0
- package/metrics/metrics-expertise.svg +14 -0
- package/metrics/metrics-languages.svg +14 -0
- package/metrics/metrics-pulse.svg +14 -0
- package/metrics/metrics-tech-stack.svg +14 -0
- package/package.json +35 -0
- package/skills/github-insights/SKILL.md +237 -0
- package/sr.yaml +16 -0
- package/src/__fixtures__/repos.ts +84 -0
- package/src/api.ts +729 -0
- package/src/components/bar-chart.test.tsx +38 -0
- package/src/components/bar-chart.tsx +54 -0
- package/src/components/contribution-calendar.test.tsx +44 -0
- package/src/components/contribution-calendar.tsx +94 -0
- package/src/components/contribution-cards.test.tsx +36 -0
- package/src/components/contribution-cards.tsx +58 -0
- package/src/components/donut-chart.test.tsx +36 -0
- package/src/components/donut-chart.tsx +102 -0
- package/src/components/full-svg.test.tsx +54 -0
- package/src/components/full-svg.tsx +59 -0
- package/src/components/project-cards.test.tsx +46 -0
- package/src/components/project-cards.tsx +66 -0
- package/src/components/section.test.tsx +69 -0
- package/src/components/section.tsx +79 -0
- package/src/components/stat-cards.test.tsx +32 -0
- package/src/components/stat-cards.tsx +57 -0
- package/src/components/style-defs.test.tsx +26 -0
- package/src/components/style-defs.tsx +27 -0
- package/src/components/tech-highlights.test.tsx +63 -0
- package/src/components/tech-highlights.tsx +109 -0
- package/src/config.test.ts +127 -0
- package/src/config.ts +103 -0
- package/src/index.ts +363 -0
- package/src/jsx-factory.test.tsx +86 -0
- package/src/jsx-factory.ts +46 -0
- package/src/jsx.d.ts +6 -0
- package/src/metrics.test.ts +669 -0
- package/src/metrics.ts +365 -0
- package/src/parsers.test.ts +247 -0
- package/src/parsers.ts +146 -0
- package/src/readme.test.ts +189 -0
- package/src/readme.ts +70 -0
- package/src/svg-utils.test.ts +66 -0
- package/src/svg-utils.ts +33 -0
- package/src/templates.test.ts +412 -0
- package/src/templates.ts +296 -0
- package/src/theme.ts +33 -0
- package/src/types.ts +235 -0
- package/teasr.toml +14 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +12 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import * as github from "@actions/github";
|
|
2
|
+
import type {
|
|
3
|
+
ContributionData,
|
|
4
|
+
ManifestMap,
|
|
5
|
+
ProjectItem,
|
|
6
|
+
ReadmeMap,
|
|
7
|
+
RepoClassificationInput,
|
|
8
|
+
RepoClassificationOutput,
|
|
9
|
+
RepoNode,
|
|
10
|
+
TechHighlight,
|
|
11
|
+
UserConfig,
|
|
12
|
+
UserProfile,
|
|
13
|
+
} from "./types.js";
|
|
14
|
+
|
|
15
|
+
const MAX_RETRIES = 3;
|
|
16
|
+
|
|
17
|
+
const fetchWithRetry = async (
|
|
18
|
+
url: string,
|
|
19
|
+
init: RequestInit,
|
|
20
|
+
label: string,
|
|
21
|
+
): Promise<Response | null> => {
|
|
22
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
23
|
+
const res = await fetch(url, init);
|
|
24
|
+
if (res.status !== 429) return res;
|
|
25
|
+
|
|
26
|
+
if (attempt === MAX_RETRIES) {
|
|
27
|
+
console.warn(`${label}: rate limited after ${MAX_RETRIES + 1} attempts`);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const retryAfter = res.headers.get("retry-after");
|
|
32
|
+
const waitSec = retryAfter ? Math.min(Number(retryAfter) || 10, 60) : 10;
|
|
33
|
+
console.warn(
|
|
34
|
+
`${label}: rate limited, retrying in ${waitSec}s (attempt ${attempt + 1}/${MAX_RETRIES})`,
|
|
35
|
+
);
|
|
36
|
+
await new Promise((r) => setTimeout(r, waitSec * 1000));
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const MANIFEST_FILES = [
|
|
42
|
+
"package.json",
|
|
43
|
+
"Cargo.toml",
|
|
44
|
+
"go.mod",
|
|
45
|
+
"pyproject.toml",
|
|
46
|
+
"requirements.txt",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
export type GraphQL = ReturnType<typeof github.getOctokit>["graphql"];
|
|
50
|
+
|
|
51
|
+
export const makeGraphql = (token: string): GraphQL =>
|
|
52
|
+
github.getOctokit(token).graphql;
|
|
53
|
+
|
|
54
|
+
export const fetchAllRepoData = async (
|
|
55
|
+
graphql: GraphQL,
|
|
56
|
+
username: string,
|
|
57
|
+
): Promise<RepoNode[]> => {
|
|
58
|
+
const data: {
|
|
59
|
+
user: { repositories: { nodes: RepoNode[] } };
|
|
60
|
+
} = await graphql(
|
|
61
|
+
`query($username: String!) {
|
|
62
|
+
user(login: $username) {
|
|
63
|
+
repositories(first: 100, orderBy: {field: STARGAZERS, direction: DESC}, ownerAffiliations: OWNER, privacy: PUBLIC) {
|
|
64
|
+
nodes {
|
|
65
|
+
name
|
|
66
|
+
description
|
|
67
|
+
url
|
|
68
|
+
stargazerCount
|
|
69
|
+
diskUsage
|
|
70
|
+
primaryLanguage { name color }
|
|
71
|
+
isArchived
|
|
72
|
+
isFork
|
|
73
|
+
createdAt
|
|
74
|
+
pushedAt
|
|
75
|
+
repositoryTopics(first: 20) {
|
|
76
|
+
nodes { topic { name } }
|
|
77
|
+
}
|
|
78
|
+
languages(first: 20, orderBy: {field: SIZE, direction: DESC}) {
|
|
79
|
+
totalSize
|
|
80
|
+
edges { size node { name color } }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}`,
|
|
86
|
+
{ username },
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return data.user.repositories.nodes.filter((r) => !r.isArchived && !r.isFork);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const fetchManifestsForRepos = async (
|
|
93
|
+
graphql: GraphQL,
|
|
94
|
+
username: string,
|
|
95
|
+
repos: RepoNode[],
|
|
96
|
+
): Promise<ManifestMap> => {
|
|
97
|
+
const manifests: ManifestMap = new Map();
|
|
98
|
+
const batchSize = 10;
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < repos.length; i += batchSize) {
|
|
101
|
+
const batch = repos.slice(i, i + batchSize);
|
|
102
|
+
const varDefs = batch.map((_, idx) => `$name_${idx}: String!`).join(", ");
|
|
103
|
+
const aliases = batch
|
|
104
|
+
.map((_, idx) => {
|
|
105
|
+
const alias = `repo_${idx}`;
|
|
106
|
+
const fileQueries = MANIFEST_FILES.map((file) => {
|
|
107
|
+
const fieldName = file.replace(/[-.]/g, "_");
|
|
108
|
+
return `${fieldName}: object(expression: "HEAD:${file}") { ... on Blob { text } }`;
|
|
109
|
+
}).join("\n ");
|
|
110
|
+
return `${alias}: repository(owner: $owner, name: $name_${idx}) {
|
|
111
|
+
${fileQueries}
|
|
112
|
+
}`;
|
|
113
|
+
})
|
|
114
|
+
.join("\n ");
|
|
115
|
+
|
|
116
|
+
const variables: Record<string, string> = { owner: username };
|
|
117
|
+
batch.forEach((repo, idx) => {
|
|
118
|
+
variables[`name_${idx}`] = repo.name;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const data: Record<
|
|
123
|
+
string,
|
|
124
|
+
Record<string, { text?: string } | null>
|
|
125
|
+
> = await graphql(
|
|
126
|
+
`query($owner: String!, ${varDefs}) { ${aliases} }`,
|
|
127
|
+
variables,
|
|
128
|
+
);
|
|
129
|
+
batch.forEach((repo, idx) => {
|
|
130
|
+
const repoData = data[`repo_${idx}`];
|
|
131
|
+
if (!repoData) return;
|
|
132
|
+
const files: Record<string, string> = {};
|
|
133
|
+
for (const file of MANIFEST_FILES) {
|
|
134
|
+
const fieldName = file.replace(/[-.]/g, "_");
|
|
135
|
+
const entry = repoData[fieldName] as { text?: string } | null;
|
|
136
|
+
if (entry?.text) {
|
|
137
|
+
files[file] = entry.text;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (Object.keys(files).length > 0) {
|
|
141
|
+
manifests.set(repo.name, files);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
} catch (err: unknown) {
|
|
145
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
146
|
+
console.warn(`Warning: manifest batch fetch failed: ${msg}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return manifests;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const fetchContributionData = async (
|
|
154
|
+
graphql: GraphQL,
|
|
155
|
+
username: string,
|
|
156
|
+
): Promise<ContributionData> => {
|
|
157
|
+
try {
|
|
158
|
+
const now = new Date();
|
|
159
|
+
const from = new Date(now);
|
|
160
|
+
from.setFullYear(from.getFullYear() - 1);
|
|
161
|
+
|
|
162
|
+
const data = await graphql(
|
|
163
|
+
`query($username: String!, $from: DateTime!, $to: DateTime!) {
|
|
164
|
+
user(login: $username) {
|
|
165
|
+
contributionsCollection(from: $from, to: $to) {
|
|
166
|
+
totalCommitContributions
|
|
167
|
+
totalPullRequestContributions
|
|
168
|
+
totalPullRequestReviewContributions
|
|
169
|
+
totalRepositoriesWithContributedCommits
|
|
170
|
+
commitContributionsByRepository(maxRepositories: 100) {
|
|
171
|
+
repository { name nameWithOwner }
|
|
172
|
+
contributions { totalCount }
|
|
173
|
+
}
|
|
174
|
+
contributionCalendar {
|
|
175
|
+
totalContributions
|
|
176
|
+
weeks {
|
|
177
|
+
contributionDays {
|
|
178
|
+
contributionCount
|
|
179
|
+
date
|
|
180
|
+
color
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
repositoriesContributedTo(first: 50, includeUserRepositories: false, contributionTypes: [COMMIT, PULL_REQUEST]) {
|
|
186
|
+
totalCount
|
|
187
|
+
nodes { nameWithOwner url stargazerCount description primaryLanguage { name } }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}`,
|
|
191
|
+
{ username, from: from.toISOString(), to: now.toISOString() },
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const user = (data as Record<string, Record<string, unknown>>).user;
|
|
195
|
+
const collection = user.contributionsCollection as Record<string, unknown>;
|
|
196
|
+
return {
|
|
197
|
+
contributions: {
|
|
198
|
+
totalCommitContributions: collection.totalCommitContributions,
|
|
199
|
+
totalPullRequestContributions: collection.totalPullRequestContributions,
|
|
200
|
+
totalPullRequestReviewContributions:
|
|
201
|
+
collection.totalPullRequestReviewContributions,
|
|
202
|
+
totalRepositoriesWithContributedCommits:
|
|
203
|
+
collection.totalRepositoriesWithContributedCommits,
|
|
204
|
+
},
|
|
205
|
+
externalRepos: user.repositoriesContributedTo,
|
|
206
|
+
contributionCalendar: collection.contributionCalendar,
|
|
207
|
+
commitContributionsByRepository:
|
|
208
|
+
collection.commitContributionsByRepository,
|
|
209
|
+
} as ContributionData;
|
|
210
|
+
} catch (err: unknown) {
|
|
211
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
212
|
+
console.warn(`Contribution data fetch failed (non-fatal): ${msg}`);
|
|
213
|
+
return {
|
|
214
|
+
contributions: {
|
|
215
|
+
totalCommitContributions: 0,
|
|
216
|
+
totalPullRequestContributions: 0,
|
|
217
|
+
totalPullRequestReviewContributions: 0,
|
|
218
|
+
totalRepositoriesWithContributedCommits: 0,
|
|
219
|
+
},
|
|
220
|
+
externalRepos: { totalCount: 0, nodes: [] },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const fetchReadmeForRepos = async (
|
|
226
|
+
graphql: GraphQL,
|
|
227
|
+
username: string,
|
|
228
|
+
repos: RepoNode[],
|
|
229
|
+
): Promise<ReadmeMap> => {
|
|
230
|
+
const readmeMap: ReadmeMap = new Map();
|
|
231
|
+
const batchSize = 10;
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < repos.length; i += batchSize) {
|
|
234
|
+
const batch = repos.slice(i, i + batchSize);
|
|
235
|
+
const varDefs = batch.map((_, idx) => `$name_${idx}: String!`).join(", ");
|
|
236
|
+
const aliases = batch
|
|
237
|
+
.map((_, idx) => {
|
|
238
|
+
const alias = `repo_${idx}`;
|
|
239
|
+
return `${alias}: repository(owner: $owner, name: $name_${idx}) {
|
|
240
|
+
readme: object(expression: "HEAD:README.md") { ... on Blob { text } }
|
|
241
|
+
}`;
|
|
242
|
+
})
|
|
243
|
+
.join("\n ");
|
|
244
|
+
|
|
245
|
+
const variables: Record<string, string> = { owner: username };
|
|
246
|
+
batch.forEach((repo, idx) => {
|
|
247
|
+
variables[`name_${idx}`] = repo.name;
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const data: Record<string, { readme?: { text?: string } } | null> =
|
|
252
|
+
await graphql(
|
|
253
|
+
`query($owner: String!, ${varDefs}) { ${aliases} }`,
|
|
254
|
+
variables,
|
|
255
|
+
);
|
|
256
|
+
batch.forEach((repo, idx) => {
|
|
257
|
+
const repoData = data[`repo_${idx}`];
|
|
258
|
+
if (repoData?.readme?.text) {
|
|
259
|
+
readmeMap.set(repo.name, repoData.readme.text);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
} catch (err: unknown) {
|
|
263
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
264
|
+
console.warn(`Warning: README batch fetch failed: ${msg}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return readmeMap;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const fetchUserProfile = async (
|
|
272
|
+
graphql: GraphQL,
|
|
273
|
+
username: string,
|
|
274
|
+
): Promise<UserProfile> => {
|
|
275
|
+
try {
|
|
276
|
+
const data = await graphql(
|
|
277
|
+
`query($username: String!) {
|
|
278
|
+
user(login: $username) {
|
|
279
|
+
name
|
|
280
|
+
bio
|
|
281
|
+
company
|
|
282
|
+
location
|
|
283
|
+
websiteUrl
|
|
284
|
+
twitterUsername
|
|
285
|
+
socialAccounts(first: 10) { nodes { provider url } }
|
|
286
|
+
}
|
|
287
|
+
}`,
|
|
288
|
+
{ username },
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
const user = (data as Record<string, Record<string, unknown>>).user;
|
|
292
|
+
return {
|
|
293
|
+
name: (user.name as string) || null,
|
|
294
|
+
bio: (user.bio as string) || null,
|
|
295
|
+
company: (user.company as string) || null,
|
|
296
|
+
location: (user.location as string) || null,
|
|
297
|
+
websiteUrl: (user.websiteUrl as string) || null,
|
|
298
|
+
twitterUsername: (user.twitterUsername as string) || null,
|
|
299
|
+
socialAccounts:
|
|
300
|
+
(user.socialAccounts as { nodes: { provider: string; url: string }[] })
|
|
301
|
+
?.nodes || [],
|
|
302
|
+
};
|
|
303
|
+
} catch (err: unknown) {
|
|
304
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
305
|
+
console.warn(`User profile fetch failed (non-fatal): ${msg}`);
|
|
306
|
+
return {
|
|
307
|
+
name: null,
|
|
308
|
+
bio: null,
|
|
309
|
+
company: null,
|
|
310
|
+
location: null,
|
|
311
|
+
websiteUrl: null,
|
|
312
|
+
twitterUsername: null,
|
|
313
|
+
socialAccounts: [],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
export interface PreambleContext {
|
|
319
|
+
username: string;
|
|
320
|
+
profile: UserProfile;
|
|
321
|
+
userConfig: UserConfig;
|
|
322
|
+
languages: { name: string; percent: string }[];
|
|
323
|
+
techHighlights: TechHighlight[];
|
|
324
|
+
activeProjects: ProjectItem[];
|
|
325
|
+
complexProjects: ProjectItem[];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export const fetchAIPreamble = async (
|
|
329
|
+
token: string,
|
|
330
|
+
context: PreambleContext,
|
|
331
|
+
): Promise<string | undefined> => {
|
|
332
|
+
try {
|
|
333
|
+
const {
|
|
334
|
+
profile,
|
|
335
|
+
userConfig,
|
|
336
|
+
languages,
|
|
337
|
+
techHighlights,
|
|
338
|
+
activeProjects,
|
|
339
|
+
complexProjects,
|
|
340
|
+
} = context;
|
|
341
|
+
|
|
342
|
+
const langLines = languages
|
|
343
|
+
.map((l) => `- ${l.name}: ${l.percent}%`)
|
|
344
|
+
.join("\n");
|
|
345
|
+
const techLines = techHighlights
|
|
346
|
+
.map((h) => `- ${h.category}: ${h.items.join(", ")} (score: ${h.score})`)
|
|
347
|
+
.join("\n");
|
|
348
|
+
|
|
349
|
+
const formatProject = (p: ProjectItem): string => {
|
|
350
|
+
const langs = p.languages?.length ? ` [${p.languages.join(", ")}]` : "";
|
|
351
|
+
const size = p.codeSize ? ` ~${Math.round(p.codeSize / 1024)}MB` : "";
|
|
352
|
+
return `- ${p.name} (${p.stars} stars${size})${langs}: ${p.description}`;
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const activeProjectLines = activeProjects.map(formatProject).join("\n");
|
|
356
|
+
const complexProjectLines = complexProjects.map(formatProject).join("\n");
|
|
357
|
+
|
|
358
|
+
const profileLines = [
|
|
359
|
+
profile.name ? `Name: ${profile.name}` : null,
|
|
360
|
+
profile.bio ? `Bio: ${profile.bio}` : null,
|
|
361
|
+
profile.company ? `Company: ${profile.company}` : null,
|
|
362
|
+
profile.location ? `Location: ${profile.location}` : null,
|
|
363
|
+
userConfig.title ? `Title: ${userConfig.title}` : null,
|
|
364
|
+
]
|
|
365
|
+
.filter(Boolean)
|
|
366
|
+
.join("\n");
|
|
367
|
+
|
|
368
|
+
const prompt = `You are generating a very short tagline for a developer's GitHub profile README.
|
|
369
|
+
|
|
370
|
+
Profile:
|
|
371
|
+
${profileLines}
|
|
372
|
+
|
|
373
|
+
Languages (by code volume):
|
|
374
|
+
${langLines}
|
|
375
|
+
|
|
376
|
+
Expertise areas:
|
|
377
|
+
${techLines}
|
|
378
|
+
|
|
379
|
+
Most technically complex projects (by language diversity, codebase size, and depth):
|
|
380
|
+
${complexProjectLines || "None"}
|
|
381
|
+
|
|
382
|
+
Active projects (recently committed to):
|
|
383
|
+
${activeProjectLines || "None"}
|
|
384
|
+
|
|
385
|
+
Generate 1-2 sentences that:
|
|
386
|
+
- Write in first person (use I/my). Describe what you work on
|
|
387
|
+
- Lead with the most technically impressive or complex work — projects with multiple languages, large codebases, or deep domain expertise
|
|
388
|
+
- Reference their top 2-3 languages or technologies naturally
|
|
389
|
+
- Keep tone professional but friendly
|
|
390
|
+
- Do NOT include social links, badges, or contact info
|
|
391
|
+
- Do NOT include a heading — the README already has one
|
|
392
|
+
- Do NOT wrap your response in code fences or backtick blocks — output raw markdown only
|
|
393
|
+
- Do NOT include any conversational preface (e.g., "Certainly!", "Here's...", "Sure!") — start directly with the tagline`;
|
|
394
|
+
|
|
395
|
+
const res = await fetchWithRetry(
|
|
396
|
+
"https://models.github.ai/inference/chat/completions",
|
|
397
|
+
{
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers: {
|
|
400
|
+
Authorization: `bearer ${token}`,
|
|
401
|
+
"Content-Type": "application/json",
|
|
402
|
+
},
|
|
403
|
+
body: JSON.stringify({
|
|
404
|
+
model: "gpt-4.1",
|
|
405
|
+
messages: [
|
|
406
|
+
{
|
|
407
|
+
role: "system",
|
|
408
|
+
content:
|
|
409
|
+
"You are a markdown content generator. Output ONLY the requested markdown content. " +
|
|
410
|
+
"Never include conversational text, confirmations, or commentary like " +
|
|
411
|
+
'"Certainly", "Here\'s", "Sure", "Of course", etc. ' +
|
|
412
|
+
"Start directly with the substantive content.",
|
|
413
|
+
},
|
|
414
|
+
{ role: "user", content: prompt },
|
|
415
|
+
],
|
|
416
|
+
temperature: 0.3,
|
|
417
|
+
response_format: {
|
|
418
|
+
type: "json_schema",
|
|
419
|
+
json_schema: {
|
|
420
|
+
name: "preamble",
|
|
421
|
+
strict: true,
|
|
422
|
+
schema: {
|
|
423
|
+
type: "object",
|
|
424
|
+
properties: { preamble: { type: "string" } },
|
|
425
|
+
required: ["preamble"],
|
|
426
|
+
additionalProperties: false,
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
}),
|
|
431
|
+
},
|
|
432
|
+
"Preamble",
|
|
433
|
+
);
|
|
434
|
+
|
|
435
|
+
if (!res?.ok) {
|
|
436
|
+
if (res)
|
|
437
|
+
console.warn(`GitHub Models API error (preamble): ${res.status}`);
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const json = (await res.json()) as {
|
|
442
|
+
choices?: { message?: { content?: string } }[];
|
|
443
|
+
};
|
|
444
|
+
const content = json.choices?.[0]?.message?.content || "{}";
|
|
445
|
+
const parsed = JSON.parse(content) as { preamble?: string };
|
|
446
|
+
const raw = parsed.preamble || undefined;
|
|
447
|
+
if (!raw) return undefined;
|
|
448
|
+
|
|
449
|
+
const cleaned = raw
|
|
450
|
+
// Strip conversational preface (safety net)
|
|
451
|
+
.replace(
|
|
452
|
+
/^(?:certainly|sure|of course|here(?:'s| is| are)|absolutely|great)[\s\S]*?(?::\s*\n|\.\s*\n)/i,
|
|
453
|
+
"",
|
|
454
|
+
)
|
|
455
|
+
// Strip wrapping code fences
|
|
456
|
+
.replace(/^```(?:markdown|md)?\s*\n?/, "")
|
|
457
|
+
.replace(/\n?```\s*$/, "")
|
|
458
|
+
.trim();
|
|
459
|
+
|
|
460
|
+
// Reject degenerate output (conversational filler with no real content)
|
|
461
|
+
const minLength = 20;
|
|
462
|
+
if (cleaned.length < minLength) {
|
|
463
|
+
console.warn(
|
|
464
|
+
`AI preamble too short after cleaning (${cleaned.length} chars), discarding`,
|
|
465
|
+
);
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return cleaned;
|
|
470
|
+
} catch (err: unknown) {
|
|
471
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
472
|
+
console.warn(`AI preamble generation failed (non-fatal): ${msg}`);
|
|
473
|
+
return undefined;
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
export const fetchExpertiseAnalysis = async (
|
|
478
|
+
token: string,
|
|
479
|
+
languages: { name: string; percent: string }[],
|
|
480
|
+
allDeps: string[],
|
|
481
|
+
allTopics: string[],
|
|
482
|
+
repos: RepoNode[],
|
|
483
|
+
readmeMap: ReadmeMap,
|
|
484
|
+
userConfig: UserConfig = {},
|
|
485
|
+
): Promise<TechHighlight[]> => {
|
|
486
|
+
try {
|
|
487
|
+
const langLines = languages
|
|
488
|
+
.map((l) => `- ${l.name}: ${l.percent}%`)
|
|
489
|
+
.join("\n");
|
|
490
|
+
|
|
491
|
+
const repoSummaries = repos
|
|
492
|
+
.slice(0, 20)
|
|
493
|
+
.map((r) => {
|
|
494
|
+
const readme = readmeMap.get(r.name) || "";
|
|
495
|
+
const snippet = readme.slice(0, 500).replace(/\n/g, " ");
|
|
496
|
+
const desc = r.description || "";
|
|
497
|
+
return `- ${r.name}: ${desc} | ${snippet}`;
|
|
498
|
+
})
|
|
499
|
+
.join("\n");
|
|
500
|
+
|
|
501
|
+
const desiredTitle = userConfig.desired_title || userConfig.title;
|
|
502
|
+
let titleContext = "";
|
|
503
|
+
if (userConfig.title) {
|
|
504
|
+
titleContext = `\nDeveloper context:\n- Current title: ${userConfig.title}`;
|
|
505
|
+
if (desiredTitle && desiredTitle !== userConfig.title) {
|
|
506
|
+
titleContext += `\n- Desired title: ${desiredTitle}`;
|
|
507
|
+
}
|
|
508
|
+
titleContext += `\n- Tailor the expertise categories to highlight skills most relevant to ${desiredTitle}. Prioritize domains and technologies that align with this role.\n`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const prompt = `You are analyzing a developer's GitHub profile to create a curated expertise showcase.
|
|
512
|
+
${titleContext}
|
|
513
|
+
Languages (by code volume):
|
|
514
|
+
${langLines}
|
|
515
|
+
|
|
516
|
+
Dependencies found across repositories:
|
|
517
|
+
${allDeps.join(", ")}
|
|
518
|
+
|
|
519
|
+
Repository topics:
|
|
520
|
+
${allTopics.join(", ")}
|
|
521
|
+
|
|
522
|
+
Repository descriptions and README excerpts:
|
|
523
|
+
${repoSummaries}
|
|
524
|
+
|
|
525
|
+
From this data, produce a curated expertise profile:
|
|
526
|
+
- Group the most notable technologies into 3-6 expertise categories
|
|
527
|
+
- Use domain-oriented category names (e.g., "Machine Learning", "Web Development", "DevOps", "Backend & APIs", "Data Science", "Systems Programming")
|
|
528
|
+
- Include 3-6 of the most relevant technologies/tools per category
|
|
529
|
+
- Normalize names to their common display form (e.g., "pg" → "PostgreSQL", "torch" → "PyTorch", "boto3" → "AWS SDK")
|
|
530
|
+
- Skip trivial utility libraries (lodash, uuid, etc.) that don't showcase meaningful expertise
|
|
531
|
+
- Only include categories where there's meaningful evidence of usage
|
|
532
|
+
- Assign each category a proficiency score from 0 to 100 based on evidence strength:
|
|
533
|
+
language code volume, dependency count, topic mentions, and README depth.
|
|
534
|
+
Use the full range (e.g. 80-95 for primary stack, 50-70 for secondary, 30-50 for minor).`;
|
|
535
|
+
|
|
536
|
+
const res = await fetchWithRetry(
|
|
537
|
+
"https://models.github.ai/inference/chat/completions",
|
|
538
|
+
{
|
|
539
|
+
method: "POST",
|
|
540
|
+
headers: {
|
|
541
|
+
Authorization: `bearer ${token}`,
|
|
542
|
+
"Content-Type": "application/json",
|
|
543
|
+
},
|
|
544
|
+
body: JSON.stringify({
|
|
545
|
+
model: "gpt-4.1",
|
|
546
|
+
messages: [{ role: "user", content: prompt }],
|
|
547
|
+
temperature: 0.1,
|
|
548
|
+
response_format: {
|
|
549
|
+
type: "json_schema",
|
|
550
|
+
json_schema: {
|
|
551
|
+
name: "tech_highlights",
|
|
552
|
+
strict: true,
|
|
553
|
+
schema: {
|
|
554
|
+
type: "object",
|
|
555
|
+
properties: {
|
|
556
|
+
highlights: {
|
|
557
|
+
type: "array",
|
|
558
|
+
items: {
|
|
559
|
+
type: "object",
|
|
560
|
+
properties: {
|
|
561
|
+
category: { type: "string" },
|
|
562
|
+
items: { type: "array", items: { type: "string" } },
|
|
563
|
+
score: { type: "number" },
|
|
564
|
+
},
|
|
565
|
+
required: ["category", "items", "score"],
|
|
566
|
+
additionalProperties: false,
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
required: ["highlights"],
|
|
571
|
+
additionalProperties: false,
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
}),
|
|
576
|
+
},
|
|
577
|
+
"Expertise",
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
if (!res?.ok) {
|
|
581
|
+
if (res) console.warn(`GitHub Models API error: ${res.status}`);
|
|
582
|
+
return [];
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const json = (await res.json()) as {
|
|
586
|
+
choices?: { message?: { content?: string } }[];
|
|
587
|
+
};
|
|
588
|
+
const content = json.choices?.[0]?.message?.content || "{}";
|
|
589
|
+
const parsed = JSON.parse(content) as { highlights?: TechHighlight[] };
|
|
590
|
+
return (parsed.highlights || [])
|
|
591
|
+
.filter((h) => h.category && Array.isArray(h.items) && h.items.length > 0)
|
|
592
|
+
.map((h) => ({ ...h, score: Math.max(0, Math.min(100, h.score || 0)) }))
|
|
593
|
+
.sort((a, b) => b.score - a.score);
|
|
594
|
+
} catch (err: unknown) {
|
|
595
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
596
|
+
console.warn(`Expertise analysis failed (non-fatal): ${msg}`);
|
|
597
|
+
return [];
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
export const fetchProjectClassifications = async (
|
|
602
|
+
token: string,
|
|
603
|
+
repos: RepoClassificationInput[],
|
|
604
|
+
): Promise<RepoClassificationOutput[]> => {
|
|
605
|
+
try {
|
|
606
|
+
const repoData = JSON.stringify(repos, null, 2);
|
|
607
|
+
|
|
608
|
+
const prompt = `You are classifying GitHub repositories by their maintenance status, purpose category, and generating a brief summary for each.
|
|
609
|
+
|
|
610
|
+
For each repository, determine its status, category, and write a 1-2 sentence summary:
|
|
611
|
+
|
|
612
|
+
Status (project lifecycle — pick exactly one):
|
|
613
|
+
- "active": The project is young and under active development. It was created or significantly reworked recently, AND has frequent, sustained commits indicating ongoing feature work or rapid iteration. A mature project with a recent burst of commits is NOT active — it's maintained.
|
|
614
|
+
- "maintained": The project is established and functional. It may receive occasional updates — bug fixes, dependency bumps, documentation, or even periodic feature additions — but the core is stable. Most working projects fall here. An old project with recent commits is maintained, not active.
|
|
615
|
+
- "inactive": The project has no meaningful recent activity. It may be a completed experiment, archived, or abandoned.
|
|
616
|
+
|
|
617
|
+
Category (project purpose — pick exactly one):
|
|
618
|
+
- "Developer Tools": CLIs, build tools, code generators, automation utilities, GitHub Actions
|
|
619
|
+
- "SDKs": Libraries and SDKs meant to be imported by other projects
|
|
620
|
+
- "Applications": End-user applications, desktop apps, web apps, APIs
|
|
621
|
+
- "Research & Experiments": Academic projects, ML experiments, algorithm research, educational repos, game clones
|
|
622
|
+
|
|
623
|
+
Repository data:
|
|
624
|
+
${repoData}
|
|
625
|
+
|
|
626
|
+
Classification guidelines:
|
|
627
|
+
- commitsLastYear is the number of commits in the past 12 months
|
|
628
|
+
- pushedAt is the last push date (any git push, not just commits)
|
|
629
|
+
- The key distinction between active and maintained is project MATURITY, not just commit recency
|
|
630
|
+
- A project created in the last ~6 months with sustained commits → active
|
|
631
|
+
- A project older than ~1 year with any level of recent commits → maintained (unless it was clearly rearchitected/rewritten recently)
|
|
632
|
+
- commitsLastYear alone does NOT determine active vs maintained — a 3-year-old project with 50 commits/year is maintained, not active
|
|
633
|
+
- A repo with 0 commits but a very recent pushedAt might still be maintained (rebases, CI fixes)
|
|
634
|
+
- Profile READMEs (e.g. repos named after the username) should be "maintained" (they get auto-generated updates but aren't actively developed)
|
|
635
|
+
- SDKs and tools that are stable and working are "maintained" even with frequent commits — unless they're brand new
|
|
636
|
+
- For category, judge by what the repo IS, not by its activity level. A game clone is "Research & Experiments" even if actively developed. A CLI tool is "Developer Tools" even if inactive.
|
|
637
|
+
|
|
638
|
+
Summary guidelines:
|
|
639
|
+
- Write 1-2 factual sentences describing what the project IS and what technologies it uses
|
|
640
|
+
- Do NOT mention commit counts, activity status, maintenance status, or how recently it was updated — that information is already conveyed by the section heading
|
|
641
|
+
- Do NOT hallucinate features or details not present in the input data
|
|
642
|
+
- Base the summary only on: name, description, languages, stars, and disk usage`;
|
|
643
|
+
|
|
644
|
+
const res = await fetchWithRetry(
|
|
645
|
+
"https://models.github.ai/inference/chat/completions",
|
|
646
|
+
{
|
|
647
|
+
method: "POST",
|
|
648
|
+
headers: {
|
|
649
|
+
Authorization: `bearer ${token}`,
|
|
650
|
+
"Content-Type": "application/json",
|
|
651
|
+
},
|
|
652
|
+
body: JSON.stringify({
|
|
653
|
+
model: "gpt-4.1",
|
|
654
|
+
messages: [{ role: "user", content: prompt }],
|
|
655
|
+
temperature: 0.1,
|
|
656
|
+
response_format: {
|
|
657
|
+
type: "json_schema",
|
|
658
|
+
json_schema: {
|
|
659
|
+
name: "project_classifications",
|
|
660
|
+
strict: true,
|
|
661
|
+
schema: {
|
|
662
|
+
type: "object",
|
|
663
|
+
properties: {
|
|
664
|
+
classifications: {
|
|
665
|
+
type: "array",
|
|
666
|
+
items: {
|
|
667
|
+
type: "object",
|
|
668
|
+
properties: {
|
|
669
|
+
name: { type: "string" },
|
|
670
|
+
status: {
|
|
671
|
+
type: "string",
|
|
672
|
+
enum: ["active", "maintained", "inactive"],
|
|
673
|
+
},
|
|
674
|
+
summary: { type: "string" },
|
|
675
|
+
category: {
|
|
676
|
+
type: "string",
|
|
677
|
+
enum: [
|
|
678
|
+
"Developer Tools",
|
|
679
|
+
"SDKs",
|
|
680
|
+
"Applications",
|
|
681
|
+
"Research & Experiments",
|
|
682
|
+
],
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
required: ["name", "status", "summary", "category"],
|
|
686
|
+
additionalProperties: false,
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
required: ["classifications"],
|
|
691
|
+
additionalProperties: false,
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
},
|
|
697
|
+
"Classifications",
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
if (!res?.ok) {
|
|
701
|
+
if (res)
|
|
702
|
+
console.warn(
|
|
703
|
+
`GitHub Models API error (classifications): ${res.status}`,
|
|
704
|
+
);
|
|
705
|
+
return [];
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const json = (await res.json()) as {
|
|
709
|
+
choices?: { message?: { content?: string } }[];
|
|
710
|
+
};
|
|
711
|
+
const content = json.choices?.[0]?.message?.content || "{}";
|
|
712
|
+
const parsed = JSON.parse(content) as {
|
|
713
|
+
classifications?: RepoClassificationOutput[];
|
|
714
|
+
};
|
|
715
|
+
return (parsed.classifications || [])
|
|
716
|
+
.filter(
|
|
717
|
+
(c) =>
|
|
718
|
+
c.name &&
|
|
719
|
+
(c.status === "active" ||
|
|
720
|
+
c.status === "maintained" ||
|
|
721
|
+
c.status === "inactive"),
|
|
722
|
+
)
|
|
723
|
+
.map((c) => ({ ...c, summary: c.summary || "" }));
|
|
724
|
+
} catch (err: unknown) {
|
|
725
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
726
|
+
console.warn(`Project classification failed (non-fatal): ${msg}`);
|
|
727
|
+
return [];
|
|
728
|
+
}
|
|
729
|
+
};
|