@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
@@ -0,0 +1,669 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ makeContributionCalendar,
4
+ makeContributionData,
5
+ makeRepo,
6
+ } from "./__fixtures__/repos.js";
7
+ import {
8
+ aggregateLanguages,
9
+ buildSections,
10
+ collectAllDependencies,
11
+ collectAllTopics,
12
+ getTopProjectsByStars,
13
+ SECTION_KEYS,
14
+ splitProjectsByRecency,
15
+ } from "./metrics.js";
16
+ import type { ManifestMap, TechHighlight } from "./types.js";
17
+
18
+ // ── aggregateLanguages ──────────────────────────────────────────────────────
19
+
20
+ describe("aggregateLanguages", () => {
21
+ it("returns top 10 sorted by bytes", () => {
22
+ const repos = Array.from({ length: 12 }, (_, i) =>
23
+ makeRepo({
24
+ name: `repo-${i}`,
25
+ languages: {
26
+ totalSize: 1000 * (i + 1),
27
+ edges: [
28
+ {
29
+ size: 1000 * (i + 1),
30
+ node: {
31
+ name: `Lang${i}`,
32
+ color: `#${String(i).padStart(6, "0")}`,
33
+ },
34
+ },
35
+ ],
36
+ },
37
+ }),
38
+ );
39
+ const result = aggregateLanguages(repos);
40
+ expect(result).toHaveLength(10);
41
+ expect(result[0].name).toBe("Lang11");
42
+ });
43
+
44
+ it("computes correct percentages", () => {
45
+ const repos = [
46
+ makeRepo({
47
+ languages: {
48
+ totalSize: 100,
49
+ edges: [
50
+ { size: 75, node: { name: "TypeScript", color: "#3178c6" } },
51
+ { size: 25, node: { name: "JavaScript", color: "#f1e05a" } },
52
+ ],
53
+ },
54
+ }),
55
+ ];
56
+ const result = aggregateLanguages(repos);
57
+ expect(result[0].percent).toBe("75.0");
58
+ expect(result[1].percent).toBe("25.0");
59
+ });
60
+
61
+ it("excludes Jupyter Notebook", () => {
62
+ const repos = [
63
+ makeRepo({
64
+ languages: {
65
+ totalSize: 200,
66
+ edges: [
67
+ { size: 100, node: { name: "Jupyter Notebook", color: "#DA5B0B" } },
68
+ { size: 100, node: { name: "Python", color: "#3572A5" } },
69
+ ],
70
+ },
71
+ }),
72
+ ];
73
+ const result = aggregateLanguages(repos);
74
+ expect(result.map((l) => l.name)).not.toContain("Jupyter Notebook");
75
+ expect(result[0].percent).toBe("100.0");
76
+ });
77
+
78
+ it("aggregates across repos", () => {
79
+ const repos = [
80
+ makeRepo({
81
+ name: "a",
82
+ languages: {
83
+ totalSize: 50,
84
+ edges: [{ size: 50, node: { name: "Go", color: "#00ADD8" } }],
85
+ },
86
+ }),
87
+ makeRepo({
88
+ name: "b",
89
+ languages: {
90
+ totalSize: 100,
91
+ edges: [{ size: 100, node: { name: "Go", color: "#00ADD8" } }],
92
+ },
93
+ }),
94
+ ];
95
+ const result = aggregateLanguages(repos);
96
+ expect(result[0].name).toBe("Go");
97
+ expect(result[0].value).toBe(150);
98
+ });
99
+
100
+ it("returns [] for empty repos", () => {
101
+ expect(aggregateLanguages([])).toEqual([]);
102
+ });
103
+ });
104
+
105
+ // ── collectAllDependencies ──────────────────────────────────────────────────
106
+
107
+ describe("collectAllDependencies", () => {
108
+ it("collects deps from manifests across repos", () => {
109
+ const repos = [makeRepo({ name: "my-app" }), makeRepo({ name: "other" })];
110
+ const manifests: ManifestMap = new Map([
111
+ [
112
+ "my-app",
113
+ {
114
+ "package.json": JSON.stringify({
115
+ dependencies: { express: "^4", lodash: "^4" },
116
+ }),
117
+ },
118
+ ],
119
+ [
120
+ "other",
121
+ { "package.json": JSON.stringify({ dependencies: { react: "^18" } }) },
122
+ ],
123
+ ]);
124
+ const result = collectAllDependencies(repos, manifests);
125
+ expect(result).toContain("express");
126
+ expect(result).toContain("lodash");
127
+ expect(result).toContain("react");
128
+ });
129
+
130
+ it("deduplicates across repos", () => {
131
+ const repos = [makeRepo({ name: "a" }), makeRepo({ name: "b" })];
132
+ const manifests: ManifestMap = new Map([
133
+ [
134
+ "a",
135
+ { "package.json": JSON.stringify({ dependencies: { express: "^4" } }) },
136
+ ],
137
+ [
138
+ "b",
139
+ { "package.json": JSON.stringify({ dependencies: { express: "^4" } }) },
140
+ ],
141
+ ]);
142
+ const result = collectAllDependencies(repos, manifests);
143
+ expect(result.filter((d) => d === "express")).toHaveLength(1);
144
+ });
145
+
146
+ it("returns sorted array", () => {
147
+ const repos = [makeRepo({ name: "app" })];
148
+ const manifests: ManifestMap = new Map([
149
+ [
150
+ "app",
151
+ {
152
+ "package.json": JSON.stringify({
153
+ dependencies: { zod: "^3", axios: "^1" },
154
+ }),
155
+ },
156
+ ],
157
+ ]);
158
+ const result = collectAllDependencies(repos, manifests);
159
+ expect(result).toEqual([...result].sort());
160
+ });
161
+
162
+ it("returns [] when no manifests", () => {
163
+ const repos = [makeRepo({ name: "empty" })];
164
+ const manifests: ManifestMap = new Map();
165
+ expect(collectAllDependencies(repos, manifests)).toEqual([]);
166
+ });
167
+ });
168
+
169
+ // ── collectAllTopics ────────────────────────────────────────────────────────
170
+
171
+ describe("collectAllTopics", () => {
172
+ it("collects topics across repos", () => {
173
+ const repos = [
174
+ makeRepo({
175
+ name: "a",
176
+ repositoryTopics: {
177
+ nodes: [
178
+ { topic: { name: "react" } },
179
+ { topic: { name: "typescript" } },
180
+ ],
181
+ },
182
+ }),
183
+ makeRepo({
184
+ name: "b",
185
+ repositoryTopics: { nodes: [{ topic: { name: "python" } }] },
186
+ }),
187
+ ];
188
+ const result = collectAllTopics(repos);
189
+ expect(result).toContain("react");
190
+ expect(result).toContain("typescript");
191
+ expect(result).toContain("python");
192
+ });
193
+
194
+ it("deduplicates topics", () => {
195
+ const repos = [
196
+ makeRepo({
197
+ name: "a",
198
+ repositoryTopics: { nodes: [{ topic: { name: "react" } }] },
199
+ }),
200
+ makeRepo({
201
+ name: "b",
202
+ repositoryTopics: { nodes: [{ topic: { name: "react" } }] },
203
+ }),
204
+ ];
205
+ const result = collectAllTopics(repos);
206
+ expect(result.filter((t) => t === "react")).toHaveLength(1);
207
+ });
208
+
209
+ it("returns sorted array", () => {
210
+ const repos = [
211
+ makeRepo({
212
+ repositoryTopics: {
213
+ nodes: [{ topic: { name: "zod" } }, { topic: { name: "api" } }],
214
+ },
215
+ }),
216
+ ];
217
+ const result = collectAllTopics(repos);
218
+ expect(result).toEqual([...result].sort());
219
+ });
220
+
221
+ it("returns [] for repos with no topics", () => {
222
+ const repos = [makeRepo()];
223
+ expect(collectAllTopics(repos)).toEqual([]);
224
+ });
225
+ });
226
+
227
+ // ── getTopProjectsByStars ───────────────────────────────────────────────────
228
+
229
+ describe("getTopProjectsByStars", () => {
230
+ it("returns top 5 sorted by stars", () => {
231
+ const repos = Array.from({ length: 8 }, (_, i) =>
232
+ makeRepo({
233
+ name: `repo-${i}`,
234
+ stargazerCount: (i + 1) * 10,
235
+ }),
236
+ );
237
+ const result = getTopProjectsByStars(repos);
238
+ expect(result).toHaveLength(5);
239
+ expect(result[0].name).toBe("repo-7");
240
+ expect(result[0].stars).toBe(80);
241
+ });
242
+
243
+ it("maps fields correctly", () => {
244
+ const repos = [
245
+ makeRepo({
246
+ name: "my-project",
247
+ url: "https://github.com/user/my-project",
248
+ description: "A cool project",
249
+ stargazerCount: 42,
250
+ }),
251
+ ];
252
+ const result = getTopProjectsByStars(repos);
253
+ expect(result[0]).toEqual({
254
+ name: "my-project",
255
+ url: "https://github.com/user/my-project",
256
+ description: "A cool project",
257
+ stars: 42,
258
+ languageCount: 2,
259
+ codeSize: 1024,
260
+ languages: ["TypeScript", "JavaScript"],
261
+ });
262
+ });
263
+
264
+ it("handles null description", () => {
265
+ const repos = [makeRepo({ description: null, stargazerCount: 5 })];
266
+ const result = getTopProjectsByStars(repos);
267
+ expect(result[0].description).toBe("");
268
+ });
269
+
270
+ it("returns [] for empty repos", () => {
271
+ expect(getTopProjectsByStars([])).toEqual([]);
272
+ });
273
+ });
274
+
275
+ // ── splitProjectsByRecency ──────────────────────────────────────────────────
276
+
277
+ describe("splitProjectsByRecency", () => {
278
+ it("classifies repos into active, maintained, and inactive", () => {
279
+ const repos = [
280
+ makeRepo({
281
+ name: "active-repo",
282
+ stargazerCount: 20,
283
+ createdAt: new Date(
284
+ Date.now() - 30 * 24 * 60 * 60 * 1000,
285
+ ).toISOString(),
286
+ }),
287
+ makeRepo({ name: "maintained-repo", stargazerCount: 15 }),
288
+ makeRepo({ name: "inactive-repo", stargazerCount: 10 }),
289
+ ];
290
+ const contribData = makeContributionData({
291
+ commitContributionsByRepository: [
292
+ {
293
+ repository: {
294
+ name: "active-repo",
295
+ nameWithOwner: "user/active-repo",
296
+ },
297
+ contributions: { totalCount: 10 },
298
+ },
299
+ {
300
+ repository: {
301
+ name: "maintained-repo",
302
+ nameWithOwner: "user/maintained-repo",
303
+ },
304
+ contributions: { totalCount: 3 },
305
+ },
306
+ ],
307
+ });
308
+ const { active, maintained, inactive } = splitProjectsByRecency(
309
+ repos,
310
+ contribData,
311
+ );
312
+ expect(active.map((p) => p.name)).toContain("active-repo");
313
+ expect(maintained.map((p) => p.name)).toContain("maintained-repo");
314
+ expect(inactive.map((p) => p.name)).toContain("inactive-repo");
315
+ });
316
+
317
+ it("sorts active repos by complexity descending", () => {
318
+ const recentDate = new Date(
319
+ Date.now() - 30 * 24 * 60 * 60 * 1000,
320
+ ).toISOString();
321
+ const repos = [
322
+ makeRepo({
323
+ name: "simple-repo",
324
+ stargazerCount: 100,
325
+ diskUsage: 512,
326
+ createdAt: recentDate,
327
+ languages: {
328
+ totalSize: 10000,
329
+ edges: [
330
+ { size: 10000, node: { name: "JavaScript", color: "#f1e05a" } },
331
+ ],
332
+ },
333
+ }),
334
+ makeRepo({
335
+ name: "complex-repo",
336
+ stargazerCount: 1,
337
+ diskUsage: 50000,
338
+ createdAt: recentDate,
339
+ languages: {
340
+ totalSize: 100000,
341
+ edges: [
342
+ { size: 40000, node: { name: "TypeScript", color: "#3178c6" } },
343
+ { size: 30000, node: { name: "Rust", color: "#dea584" } },
344
+ { size: 20000, node: { name: "Python", color: "#3572A5" } },
345
+ { size: 10000, node: { name: "Go", color: "#00ADD8" } },
346
+ ],
347
+ },
348
+ }),
349
+ ];
350
+ const contribData = makeContributionData({
351
+ commitContributionsByRepository: [
352
+ {
353
+ repository: {
354
+ name: "simple-repo",
355
+ nameWithOwner: "user/simple-repo",
356
+ },
357
+ contributions: { totalCount: 50 },
358
+ },
359
+ {
360
+ repository: {
361
+ name: "complex-repo",
362
+ nameWithOwner: "user/complex-repo",
363
+ },
364
+ contributions: { totalCount: 10 },
365
+ },
366
+ ],
367
+ });
368
+ const { active } = splitProjectsByRecency(repos, contribData);
369
+ expect(active[0].name).toBe("complex-repo");
370
+ expect(active[1].name).toBe("simple-repo");
371
+ });
372
+
373
+ it("sorts inactive repos by complexity descending", () => {
374
+ const repos = [
375
+ makeRepo({ name: "low-stars", stargazerCount: 5 }),
376
+ makeRepo({ name: "high-stars", stargazerCount: 50 }),
377
+ ];
378
+ const contribData = makeContributionData({
379
+ commitContributionsByRepository: [],
380
+ });
381
+ const { inactive } = splitProjectsByRecency(repos, contribData);
382
+ expect(inactive[0].name).toBe("high-stars");
383
+ expect(inactive[1].name).toBe("low-stars");
384
+ });
385
+
386
+ it("returns all qualifying repos without a cap", () => {
387
+ const recentDate = new Date(
388
+ Date.now() - 30 * 24 * 60 * 60 * 1000,
389
+ ).toISOString();
390
+ const repos = Array.from({ length: 8 }, (_, i) =>
391
+ makeRepo({ name: `repo-${i}`, stargazerCount: i, createdAt: recentDate }),
392
+ );
393
+ const contribData = makeContributionData({
394
+ commitContributionsByRepository: repos.map((r) => ({
395
+ repository: { name: r.name, nameWithOwner: `user/${r.name}` },
396
+ contributions: { totalCount: 10 },
397
+ })),
398
+ });
399
+ // All 8 repos have 10 commits (above threshold) → all active
400
+ const { active } = splitProjectsByRecency(repos, contribData);
401
+ expect(active).toHaveLength(8);
402
+ });
403
+
404
+ it("classifies repos below active threshold but with commits as maintained", () => {
405
+ const repos = [makeRepo({ name: "one-off-repo", stargazerCount: 50 })];
406
+ const contribData = makeContributionData({
407
+ commitContributionsByRepository: [
408
+ {
409
+ repository: {
410
+ name: "one-off-repo",
411
+ nameWithOwner: "user/one-off-repo",
412
+ },
413
+ contributions: { totalCount: 1 },
414
+ },
415
+ ],
416
+ });
417
+ const { maintained } = splitProjectsByRecency(repos, contribData);
418
+ expect(maintained.map((p) => p.name)).toEqual(["one-off-repo"]);
419
+ });
420
+
421
+ it("old repo with many commits is maintained, not active", () => {
422
+ const repos = [
423
+ makeRepo({
424
+ name: "old-sdk",
425
+ stargazerCount: 100,
426
+ createdAt: new Date(
427
+ Date.now() - 3 * 365 * 24 * 60 * 60 * 1000,
428
+ ).toISOString(),
429
+ }),
430
+ ];
431
+ const contribData = makeContributionData({
432
+ commitContributionsByRepository: [
433
+ {
434
+ repository: { name: "old-sdk", nameWithOwner: "user/old-sdk" },
435
+ contributions: { totalCount: 50 },
436
+ },
437
+ ],
438
+ });
439
+ const { active, maintained } = splitProjectsByRecency(repos, contribData);
440
+ expect(active).toEqual([]);
441
+ expect(maintained.map((p) => p.name)).toEqual(["old-sdk"]);
442
+ });
443
+
444
+ it("returns empty arrays for no repos", () => {
445
+ const contribData = makeContributionData();
446
+ const { active, maintained, inactive } = splitProjectsByRecency(
447
+ [],
448
+ contribData,
449
+ );
450
+ expect(active).toEqual([]);
451
+ expect(maintained).toEqual([]);
452
+ expect(inactive).toEqual([]);
453
+ });
454
+
455
+ it("treats all repos as inactive when commitContributionsByRepository is missing", () => {
456
+ const repos = [
457
+ makeRepo({ name: "repo-a", stargazerCount: 30 }),
458
+ makeRepo({ name: "repo-b", stargazerCount: 10 }),
459
+ ];
460
+ const contribData = makeContributionData();
461
+ // default makeContributionData has no commitContributionsByRepository
462
+ const { active, maintained, inactive } = splitProjectsByRecency(
463
+ repos,
464
+ contribData,
465
+ );
466
+ expect(active).toEqual([]);
467
+ expect(maintained).toEqual([]);
468
+ expect(inactive).toHaveLength(2);
469
+ expect(inactive[0].name).toBe("repo-a");
470
+ });
471
+
472
+ it("uses AI classifications when provided, overriding heuristic", () => {
473
+ const repos = [
474
+ makeRepo({ name: "sdk-repo", stargazerCount: 20 }),
475
+ makeRepo({ name: "old-repo", stargazerCount: 5 }),
476
+ ];
477
+ const contribData = makeContributionData({
478
+ commitContributionsByRepository: [
479
+ {
480
+ repository: { name: "sdk-repo", nameWithOwner: "user/sdk-repo" },
481
+ contributions: { totalCount: 2 }, // heuristic would say "maintained"
482
+ },
483
+ ],
484
+ });
485
+ const aiClassifications = [
486
+ {
487
+ name: "sdk-repo",
488
+ status: "active" as const,
489
+ summary: "SDK for API integration",
490
+ }, // AI overrides to active
491
+ {
492
+ name: "old-repo",
493
+ status: "inactive" as const,
494
+ summary: "Legacy project",
495
+ },
496
+ ];
497
+ const { active, maintained, inactive } = splitProjectsByRecency(
498
+ repos,
499
+ contribData,
500
+ aiClassifications,
501
+ );
502
+ expect(active.map((p) => p.name)).toEqual(["sdk-repo"]);
503
+ expect(maintained).toEqual([]);
504
+ expect(inactive.map((p) => p.name)).toEqual(["old-repo"]);
505
+ });
506
+
507
+ it("propagates AI summary to ProjectItem", () => {
508
+ const repos = [makeRepo({ name: "my-repo", stargazerCount: 10 })];
509
+ const contribData = makeContributionData({
510
+ commitContributionsByRepository: [
511
+ {
512
+ repository: { name: "my-repo", nameWithOwner: "user/my-repo" },
513
+ contributions: { totalCount: 10 },
514
+ },
515
+ ],
516
+ });
517
+ const aiClassifications = [
518
+ {
519
+ name: "my-repo",
520
+ status: "active" as const,
521
+ summary: "A great project for testing",
522
+ },
523
+ ];
524
+ const { active } = splitProjectsByRecency(
525
+ repos,
526
+ contribData,
527
+ aiClassifications,
528
+ );
529
+ expect(active[0].summary).toBe("A great project for testing");
530
+ });
531
+ });
532
+
533
+ // ── SECTION_KEYS ───────────────────────────────────────────────────────────
534
+
535
+ describe("SECTION_KEYS", () => {
536
+ it("maps all known section names to filenames", () => {
537
+ expect(SECTION_KEYS.pulse).toBe("metrics-pulse.svg");
538
+ expect(SECTION_KEYS.languages).toBe("metrics-languages.svg");
539
+ expect(SECTION_KEYS.expertise).toBe("metrics-expertise.svg");
540
+ expect(SECTION_KEYS.projects).toBe("metrics-complexity.svg");
541
+ expect(SECTION_KEYS.contributions).toBe("metrics-contributions.svg");
542
+ expect(SECTION_KEYS.calendar).toBe("metrics-calendar.svg");
543
+ });
544
+ });
545
+
546
+ // ── buildSections ───────────────────────────────────────────────────────────
547
+
548
+ describe("buildSections", () => {
549
+ const baseSectionsInput = () => ({
550
+ languages: [
551
+ { name: "TypeScript", value: 100, percent: "80.0", color: "#3178c6" },
552
+ { name: "JavaScript", value: 25, percent: "20.0", color: "#f1e05a" },
553
+ ],
554
+ techHighlights: [
555
+ {
556
+ category: "Frontend",
557
+ items: ["React", "TypeScript", "Next.js"],
558
+ score: 90,
559
+ },
560
+ { category: "Backend", items: ["Express", "PostgreSQL"], score: 75 },
561
+ ] as TechHighlight[],
562
+ projects: [
563
+ {
564
+ name: "big-project",
565
+ url: "https://github.com/user/big-project",
566
+ description: "A complex project",
567
+ stars: 85,
568
+ },
569
+ ],
570
+ contributionData: makeContributionData(),
571
+ });
572
+
573
+ it("returns correct filenames", () => {
574
+ const sections = buildSections(baseSectionsInput());
575
+ const filenames = sections.map((s) => s.filename);
576
+ expect(filenames).toContain("metrics-languages.svg");
577
+ expect(filenames).toContain("metrics-expertise.svg");
578
+ expect(filenames).toContain("metrics-complexity.svg");
579
+ expect(filenames).toContain("metrics-pulse.svg");
580
+ });
581
+
582
+ it("expertise section is conditional on non-empty techHighlights", () => {
583
+ const input = baseSectionsInput();
584
+ input.techHighlights = [];
585
+ const sections = buildSections(input);
586
+ expect(sections.map((s) => s.filename)).not.toContain(
587
+ "metrics-expertise.svg",
588
+ );
589
+ });
590
+
591
+ it("contributions section conditional on externalRepos", () => {
592
+ const input = baseSectionsInput();
593
+ input.contributionData = makeContributionData({
594
+ externalRepos: {
595
+ totalCount: 1,
596
+ nodes: [
597
+ {
598
+ nameWithOwner: "org/repo",
599
+ url: "https://github.com/org/repo",
600
+ stargazerCount: 100,
601
+ description: "A popular repo",
602
+ primaryLanguage: { name: "Go" },
603
+ },
604
+ ],
605
+ },
606
+ });
607
+ const sections = buildSections(input);
608
+ expect(sections.map((s) => s.filename)).toContain(
609
+ "metrics-contributions.svg",
610
+ );
611
+ });
612
+
613
+ it("contributions section omitted when no external repos", () => {
614
+ const sections = buildSections(baseSectionsInput());
615
+ expect(sections.map((s) => s.filename)).not.toContain(
616
+ "metrics-contributions.svg",
617
+ );
618
+ });
619
+
620
+ it("calendar section included when contributionCalendar exists", () => {
621
+ const input = baseSectionsInput();
622
+ input.contributionData = makeContributionData({
623
+ contributionCalendar: makeContributionCalendar(),
624
+ });
625
+ const sections = buildSections(input);
626
+ expect(sections.map((s) => s.filename)).toContain("metrics-calendar.svg");
627
+ });
628
+
629
+ it("calendar section omitted when no contributionCalendar", () => {
630
+ const sections = buildSections(baseSectionsInput());
631
+ expect(sections.map((s) => s.filename)).not.toContain(
632
+ "metrics-calendar.svg",
633
+ );
634
+ });
635
+
636
+ it("uses complexity-based subtitle for signature projects", () => {
637
+ const sections = buildSections(baseSectionsInput());
638
+ const projectSection = sections.find(
639
+ (s) => s.filename === "metrics-complexity.svg",
640
+ );
641
+ expect(projectSection?.subtitle).toBe(
642
+ "Top projects by technical complexity",
643
+ );
644
+ });
645
+
646
+ it("each renderBody(0) does not throw", () => {
647
+ const input = baseSectionsInput();
648
+ input.contributionData = makeContributionData({
649
+ externalRepos: {
650
+ totalCount: 1,
651
+ nodes: [
652
+ {
653
+ nameWithOwner: "org/repo",
654
+ url: "https://github.com/org/repo",
655
+ stargazerCount: 50,
656
+ description: null,
657
+ primaryLanguage: null,
658
+ },
659
+ ],
660
+ },
661
+ });
662
+ const sections = buildSections(input);
663
+ for (const section of sections) {
664
+ if (section.renderBody) {
665
+ expect(() => section.renderBody?.(0)).not.toThrow();
666
+ }
667
+ }
668
+ });
669
+ });