@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.
Files changed (102) hide show
  1. package/.gitattributes +28 -0
  2. package/.github/dependabot.yml +6 -0
  3. package/.github/pull_request_template.md +14 -0
  4. package/.github/workflows/ci.yml +93 -0
  5. package/.github/workflows/release.yml +59 -0
  6. package/.nvmrc +1 -0
  7. package/.pre-commit-config.yaml +5 -0
  8. package/AGENTS.md +69 -0
  9. package/CHANGELOG.md +260 -0
  10. package/CONTRIBUTING.md +87 -0
  11. package/LICENSE +190 -0
  12. package/README.md +188 -0
  13. package/action.yml +45 -0
  14. package/biome.json +40 -0
  15. package/examples/classic/README.md +9 -0
  16. package/examples/classic/index.svg +14 -0
  17. package/examples/classic/metrics-calendar.svg +14 -0
  18. package/examples/classic/metrics-complexity.svg +14 -0
  19. package/examples/classic/metrics-contributions.svg +14 -0
  20. package/examples/classic/metrics-expertise.svg +14 -0
  21. package/examples/classic/metrics-languages.svg +14 -0
  22. package/examples/classic/metrics-pulse.svg +14 -0
  23. package/examples/ecosystem/README.md +59 -0
  24. package/examples/ecosystem/index.svg +14 -0
  25. package/examples/ecosystem/metrics-calendar.svg +14 -0
  26. package/examples/ecosystem/metrics-complexity.svg +14 -0
  27. package/examples/ecosystem/metrics-contributions.svg +14 -0
  28. package/examples/ecosystem/metrics-expertise.svg +14 -0
  29. package/examples/ecosystem/metrics-languages.svg +14 -0
  30. package/examples/ecosystem/metrics-pulse.svg +14 -0
  31. package/examples/minimal/README.md +9 -0
  32. package/examples/minimal/index.svg +14 -0
  33. package/examples/minimal/metrics-calendar.svg +14 -0
  34. package/examples/minimal/metrics-complexity.svg +14 -0
  35. package/examples/minimal/metrics-contributions.svg +14 -0
  36. package/examples/minimal/metrics-expertise.svg +14 -0
  37. package/examples/minimal/metrics-languages.svg +14 -0
  38. package/examples/minimal/metrics-pulse.svg +14 -0
  39. package/examples/modern/README.md +111 -0
  40. package/examples/modern/index.svg +14 -0
  41. package/examples/modern/metrics-calendar.svg +14 -0
  42. package/examples/modern/metrics-complexity.svg +14 -0
  43. package/examples/modern/metrics-contributions.svg +14 -0
  44. package/examples/modern/metrics-expertise.svg +14 -0
  45. package/examples/modern/metrics-languages.svg +14 -0
  46. package/examples/modern/metrics-pulse.svg +14 -0
  47. package/llms.txt +24 -0
  48. package/metrics/index.svg +14 -0
  49. package/metrics/metrics-calendar.svg +14 -0
  50. package/metrics/metrics-complexity.svg +14 -0
  51. package/metrics/metrics-contributions.svg +14 -0
  52. package/metrics/metrics-domains.svg +14 -0
  53. package/metrics/metrics-expertise.svg +14 -0
  54. package/metrics/metrics-languages.svg +14 -0
  55. package/metrics/metrics-pulse.svg +14 -0
  56. package/metrics/metrics-tech-stack.svg +14 -0
  57. package/package.json +35 -0
  58. package/skills/github-insights/SKILL.md +237 -0
  59. package/sr.yaml +16 -0
  60. package/src/__fixtures__/repos.ts +84 -0
  61. package/src/api.ts +729 -0
  62. package/src/components/bar-chart.test.tsx +38 -0
  63. package/src/components/bar-chart.tsx +54 -0
  64. package/src/components/contribution-calendar.test.tsx +44 -0
  65. package/src/components/contribution-calendar.tsx +94 -0
  66. package/src/components/contribution-cards.test.tsx +36 -0
  67. package/src/components/contribution-cards.tsx +58 -0
  68. package/src/components/donut-chart.test.tsx +36 -0
  69. package/src/components/donut-chart.tsx +102 -0
  70. package/src/components/full-svg.test.tsx +54 -0
  71. package/src/components/full-svg.tsx +59 -0
  72. package/src/components/project-cards.test.tsx +46 -0
  73. package/src/components/project-cards.tsx +66 -0
  74. package/src/components/section.test.tsx +69 -0
  75. package/src/components/section.tsx +79 -0
  76. package/src/components/stat-cards.test.tsx +32 -0
  77. package/src/components/stat-cards.tsx +57 -0
  78. package/src/components/style-defs.test.tsx +26 -0
  79. package/src/components/style-defs.tsx +27 -0
  80. package/src/components/tech-highlights.test.tsx +63 -0
  81. package/src/components/tech-highlights.tsx +109 -0
  82. package/src/config.test.ts +127 -0
  83. package/src/config.ts +103 -0
  84. package/src/index.ts +363 -0
  85. package/src/jsx-factory.test.tsx +86 -0
  86. package/src/jsx-factory.ts +46 -0
  87. package/src/jsx.d.ts +6 -0
  88. package/src/metrics.test.ts +669 -0
  89. package/src/metrics.ts +365 -0
  90. package/src/parsers.test.ts +247 -0
  91. package/src/parsers.ts +146 -0
  92. package/src/readme.test.ts +189 -0
  93. package/src/readme.ts +70 -0
  94. package/src/svg-utils.test.ts +66 -0
  95. package/src/svg-utils.ts +33 -0
  96. package/src/templates.test.ts +412 -0
  97. package/src/templates.ts +296 -0
  98. package/src/theme.ts +33 -0
  99. package/src/types.ts +235 -0
  100. package/teasr.toml +14 -0
  101. package/tsconfig.json +21 -0
  102. 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
+ };