@stats-forge/github-stats-forge-core 0.1.0 → 0.2.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 (142) hide show
  1. package/build/api/contributed-to.d.ts +23 -0
  2. package/build/api/contributed-to.d.ts.map +1 -0
  3. package/build/api/contributed-to.js +43 -0
  4. package/build/api/gist.d.ts +8 -15
  5. package/build/api/gist.d.ts.map +1 -1
  6. package/build/api/gist.js +14 -29
  7. package/build/api/handler.d.ts +16 -0
  8. package/build/api/handler.d.ts.map +1 -0
  9. package/build/api/handler.js +29 -0
  10. package/build/api/index.d.ts +1 -0
  11. package/build/api/index.d.ts.map +1 -1
  12. package/build/api/index.js +1 -0
  13. package/build/api/params.d.ts +11 -5
  14. package/build/api/params.d.ts.map +1 -1
  15. package/build/api/params.js +26 -6
  16. package/build/api/pin.d.ts +11 -15
  17. package/build/api/pin.d.ts.map +1 -1
  18. package/build/api/pin.js +34 -46
  19. package/build/api/stats.d.ts +15 -22
  20. package/build/api/stats.d.ts.map +1 -1
  21. package/build/api/stats.js +59 -72
  22. package/build/api/top-langs.d.ts +11 -22
  23. package/build/api/top-langs.d.ts.map +1 -1
  24. package/build/api/top-langs.js +34 -51
  25. package/build/api/wakatime.d.ts +10 -24
  26. package/build/api/wakatime.d.ts.map +1 -1
  27. package/build/api/wakatime.js +27 -47
  28. package/build/cards/contributed-to.d.ts +19 -0
  29. package/build/cards/contributed-to.d.ts.map +1 -0
  30. package/build/cards/contributed-to.js +242 -0
  31. package/build/cards/gist.d.ts +1 -0
  32. package/build/cards/gist.d.ts.map +1 -1
  33. package/build/cards/gist.js +13 -9
  34. package/build/cards/repo.d.ts +12 -1
  35. package/build/cards/repo.d.ts.map +1 -1
  36. package/build/cards/repo.js +40 -18
  37. package/build/cards/stats.d.ts +15 -3
  38. package/build/cards/stats.d.ts.map +1 -1
  39. package/build/cards/stats.js +91 -76
  40. package/build/cards/top-languages.d.ts +13 -2
  41. package/build/cards/top-languages.d.ts.map +1 -1
  42. package/build/cards/top-languages.js +26 -9
  43. package/build/cards/wakatime.d.ts +13 -2
  44. package/build/cards/wakatime.d.ts.map +1 -1
  45. package/build/cards/wakatime.js +22 -9
  46. package/build/common/Card.d.ts +9 -0
  47. package/build/common/Card.d.ts.map +1 -1
  48. package/build/common/Card.js +44 -9
  49. package/build/common/I18n.d.ts +1 -1
  50. package/build/common/I18n.d.ts.map +1 -1
  51. package/build/common/I18n.js +4 -2
  52. package/build/common/brand.d.ts +76 -0
  53. package/build/common/brand.d.ts.map +1 -0
  54. package/build/common/brand.js +73 -0
  55. package/build/common/constants.d.ts +3 -1
  56. package/build/common/constants.d.ts.map +1 -1
  57. package/build/common/constants.js +3 -1
  58. package/build/common/date.d.ts +50 -3
  59. package/build/common/date.d.ts.map +1 -1
  60. package/build/common/date.js +104 -2
  61. package/build/common/icons.d.ts +4 -2
  62. package/build/common/icons.d.ts.map +1 -1
  63. package/build/common/icons.js +10 -5
  64. package/build/common/ops.d.ts +8 -3
  65. package/build/common/ops.d.ts.map +1 -1
  66. package/build/common/ops.js +8 -14
  67. package/build/common/render.d.ts +7 -1
  68. package/build/common/render.d.ts.map +1 -1
  69. package/build/common/render.js +60 -58
  70. package/build/fetchers/contributed-to.d.ts +62 -0
  71. package/build/fetchers/contributed-to.d.ts.map +1 -0
  72. package/build/fetchers/contributed-to.js +184 -0
  73. package/build/fetchers/graphql-error.d.ts +13 -0
  74. package/build/fetchers/graphql-error.d.ts.map +1 -0
  75. package/build/fetchers/graphql-error.js +29 -0
  76. package/build/fetchers/index.d.ts +2 -1
  77. package/build/fetchers/index.d.ts.map +1 -1
  78. package/build/fetchers/index.js +1 -0
  79. package/build/fetchers/stats.d.ts +14 -8
  80. package/build/fetchers/stats.d.ts.map +1 -1
  81. package/build/fetchers/stats.js +72 -195
  82. package/build/fetchers/top-languages.d.ts.map +1 -1
  83. package/build/fetchers/top-languages.js +3 -18
  84. package/build/fetchers/types.d.ts +24 -0
  85. package/build/fetchers/types.d.ts.map +1 -1
  86. package/build/graphql/contributionsCollection.d.ts +9 -0
  87. package/build/graphql/contributionsCollection.d.ts.map +1 -0
  88. package/build/graphql/contributionsCollection.js +12 -0
  89. package/build/graphql/contributionsDocument.d.ts +5 -5
  90. package/build/graphql/contributionsDocument.d.ts.map +1 -1
  91. package/build/graphql/contributionsDocument.js +8 -15
  92. package/build/graphql/generated/contributed-to.d.ts +22 -0
  93. package/build/graphql/generated/contributed-to.d.ts.map +1 -0
  94. package/build/graphql/generated/contributed-to.js +11 -0
  95. package/build/graphql/generated/stats.d.ts +13 -1
  96. package/build/graphql/generated/stats.d.ts.map +1 -1
  97. package/build/graphql/generated/stats.js +2 -2
  98. package/build/graphql/graphqlDocument.d.ts +1 -1
  99. package/build/graphql/reposContributedToDocument.d.ts.map +1 -1
  100. package/build/graphql/reposContributedToDocument.js +11 -5
  101. package/build/translations.d.ts +64 -1
  102. package/build/translations.d.ts.map +1 -1
  103. package/build/translations.js +65 -1
  104. package/package.json +1 -1
  105. package/src/api/contributed-to.ts +84 -0
  106. package/src/api/gist.ts +20 -37
  107. package/src/api/handler.ts +42 -0
  108. package/src/api/index.ts +1 -0
  109. package/src/api/params.ts +48 -9
  110. package/src/api/pin.ts +33 -45
  111. package/src/api/stats.ts +78 -89
  112. package/src/api/top-langs.ts +34 -56
  113. package/src/api/wakatime.ts +33 -54
  114. package/src/cards/contributed-to.ts +373 -0
  115. package/src/cards/gist.ts +17 -8
  116. package/src/cards/repo.ts +44 -20
  117. package/src/cards/stats.ts +119 -90
  118. package/src/cards/top-languages.ts +27 -18
  119. package/src/cards/wakatime.ts +23 -13
  120. package/src/common/Card.ts +56 -11
  121. package/src/common/I18n.ts +4 -2
  122. package/src/common/brand.ts +109 -0
  123. package/src/common/constants.ts +4 -1
  124. package/src/common/date.ts +135 -3
  125. package/src/common/icons.ts +10 -5
  126. package/src/common/ops.ts +10 -19
  127. package/src/common/render.ts +65 -62
  128. package/src/fetchers/contributed-to.ts +271 -0
  129. package/src/fetchers/graphql-error.ts +36 -0
  130. package/src/fetchers/index.ts +3 -0
  131. package/src/fetchers/stats.ts +123 -274
  132. package/src/fetchers/top-languages.ts +5 -19
  133. package/src/fetchers/types.ts +26 -0
  134. package/src/graphql/contributionsCollection.ts +18 -0
  135. package/src/graphql/contributionsDocument.ts +11 -18
  136. package/src/graphql/generated/contributed-to.ts +25 -0
  137. package/src/graphql/generated/stats.ts +19 -6
  138. package/src/graphql/graphqlDocument.ts +1 -1
  139. package/src/graphql/queries/contributed-to.graphql +8 -0
  140. package/src/graphql/queries/stats.graphql +14 -2
  141. package/src/graphql/reposContributedToDocument.ts +11 -9
  142. package/src/translations.ts +77 -0
package/src/common/ops.ts CHANGED
@@ -40,14 +40,10 @@ const parseArray = (str: string | undefined): Array<string> => {
40
40
  /**
41
41
  * Clamp the given number between the given range.
42
42
  *
43
- * @returns The clamped number.
43
+ * @returns The clamped number; `min` for `NaN`.
44
44
  */
45
- const clampValue = (number: string | number, min: number, max: number): number => {
46
- if (Number.isNaN(Number.parseInt(String(number), 10))) {
47
- return min;
48
- }
49
- return Math.max(min, Math.min(Number(number), max));
50
- };
45
+ const clampValue = (number: number, min: number, max: number): number =>
46
+ Number.isNaN(number) ? min : Math.max(min, Math.min(number, max));
51
47
 
52
48
  /**
53
49
  * Lowercase and trim string.
@@ -112,18 +108,13 @@ const parseOwnerAffiliations = (affiliations: Array<string>): Array<RepositoryAf
112
108
  return normalized;
113
109
  };
114
110
 
115
- const buildSearchFilter = (
116
- repos: Array<string> | string = [],
117
- owners: Array<string> | string = [],
118
- ): string => {
119
- const repoFilter =
120
- Array.isArray(repos) && repos.length > 0 ? repos.map((repo) => `repo:${repo} `).join('') : '';
121
- const orgFilter =
122
- Array.isArray(owners) && owners.length > 0
123
- ? owners.map((owner) => `owner:${owner} `).join('')
124
- : '';
125
- return repoFilter + orgFilter;
126
- };
111
+ /**
112
+ * The qualifiers that scope a REST search to repositories and owners.
113
+ *
114
+ * @returns e.g. `repo:a/b owner:c `, each qualifier followed by a space; empty when both lists are.
115
+ */
116
+ const buildSearchFilter = (repos: Array<string>, owners: Array<string>): string =>
117
+ [...repos.map((repo) => `repo:${repo} `), ...owners.map((owner) => `owner:${owner} `)].join('');
127
118
 
128
119
  export {
129
120
  parseBoolean,
@@ -265,6 +265,13 @@ const iconWithLabel = (
265
265
  });
266
266
  };
267
267
 
268
+ /**
269
+ * How a stat value is written.
270
+ * Not validated at the boundary: anything but `long` reads as `short`,
271
+ * so an unknown value falls back rather than failing.
272
+ */
273
+ const NUMBER_FORMATS = ['short', 'long'] as const;
274
+
268
275
  /**
269
276
  * Create a labelled stat text item
270
277
  * (a label and its value, optionally with an icon and link).
@@ -439,10 +446,10 @@ const renderError = ({
439
446
  fill: String(bgColor),
440
447
  xmlns: 'http://www.w3.org/2000/svg',
441
448
  role: 'img',
442
- 'aria-labelledby': 'titleId descId',
449
+ 'aria-labelledby': 'title-id desc-id',
443
450
  },
444
- el('title', { id: 'titleId' }, message),
445
- el('desc', { id: 'descId' }, secondaryMessage),
451
+ el('title', { id: 'title-id' }, message),
452
+ el('desc', { id: 'desc-id' }, secondaryMessage),
446
453
  style(
447
454
  rule('.text', { font: "600 16px 'Segoe UI', Ubuntu, Sans-Serif", fill: titleColor }),
448
455
  rule('.small', { font: "600 12px 'Segoe UI', Ubuntu, Sans-Serif", fill: textColor }),
@@ -468,6 +475,51 @@ const renderError = ({
468
475
  );
469
476
  };
470
477
 
478
+ /** Advance of each ASCII code point, as a fraction of the font size. */
479
+ // prettier-ignore
480
+ const CHAR_WIDTHS = [
481
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
482
+ 0, 0, 0, 0, 0, 0, 0, 0.2733333110809326, 0.28499999046325686,
483
+ 0.39166667461395266, 0.5900000095367431, 0.5383333206176758,
484
+ 0.8183333396911621, 0.8, 0.22999999523162842, 0.30166666507720946,
485
+ 0.30166666507720946, 0.41666665077209475, 0.6833333492279052,
486
+ 0.21666667461395264, 0.4, 0.21666667461395264, 0.3900000095367432,
487
+ 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
488
+ 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
489
+ 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
490
+ 0.5383333206176758, 0.21666667461395264, 0.21666667461395264,
491
+ 0.6833333492279052, 0.6833333492279052, 0.6833333492279052,
492
+ 0.4483333110809326, 0.9550000190734863, 0.6449999809265137,
493
+ 0.5733333110809327, 0.6183333396911621, 0.7016666889190674,
494
+ 0.5066666603088379, 0.48833332061767576, 0.6866666793823242,
495
+ 0.7099999904632568, 0.26666667461395266, 0.35666666030883787,
496
+ 0.5800000190734863, 0.4699999809265137, 0.8983333587646485,
497
+ 0.7483333110809326, 0.753333330154419, 0.5599999904632569,
498
+ 0.753333330154419, 0.5983333110809326, 0.5316666603088379,
499
+ 0.5233333110809326, 0.6866666793823242, 0.621666669845581,
500
+ 0.9333333015441895, 0.5900000095367431, 0.553333330154419,
501
+ 0.5699999809265137, 0.30166666507720946, 0.37833333015441895,
502
+ 0.30166666507720946, 0.6833333492279052, 0.41500000953674315,
503
+ 0.26833333969116213, 0.5083333492279053, 0.5883333206176757,
504
+ 0.4616666793823242, 0.5883333206176757, 0.5233333110809326,
505
+ 0.3133333444595337, 0.5883333206176757, 0.5666666507720948,
506
+ 0.24166667461395264, 0.24166667461395264, 0.49666666984558105,
507
+ 0.24166667461395264, 0.8616666793823242, 0.5666666507720948,
508
+ 0.5866666793823242, 0.5883333206176757, 0.5883333206176757,
509
+ 0.3483333349227905, 0.425, 0.33833334445953367, 0.5666666507720948,
510
+ 0.4783333301544189, 0.7233333110809326, 0.45833334922790525,
511
+ 0.4833333492279053, 0.45166668891906736, 0.30166666507720946,
512
+ 0.24000000953674316, 0.30166666507720946, 0.6833333492279052,
513
+ ];
514
+
515
+ /** Advance of a code point the table does not cover. */
516
+ const AVERAGE_CHAR_WIDTH = 0.5131403493881227;
517
+ /**
518
+ * Full-width glyphs: U+3000–U+9FFF (CJK symbols and punctuation, Hiragana, Katakana,
519
+ * CJK Unified Ideographs incl. Extension A) plus U+FF00–U+FFEF (halfwidth and fullwidth forms).
520
+ */
521
+ const FULL_WIDTH_PATTERN = /[\u3000-\u9FFF\uFF00-\uFFEF]/;
522
+
471
523
  /**
472
524
  * Retrieve text length based on Segoe UI font.
473
525
  *
@@ -475,65 +527,15 @@ const renderError = ({
475
527
  *
476
528
  * @returns Text length.
477
529
  */
478
- const measureText = (str: string, fontSize = 10): number => {
479
- // prettier-ignore
480
- const widths = [
481
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
482
- 0, 0, 0, 0, 0, 0, 0, 0.2733333110809326, 0.28499999046325686,
483
- 0.39166667461395266, 0.5900000095367431, 0.5383333206176758,
484
- 0.8183333396911621, 0.8, 0.22999999523162842, 0.30166666507720946,
485
- 0.30166666507720946, 0.41666665077209475, 0.6833333492279052,
486
- 0.21666667461395264, 0.4, 0.21666667461395264, 0.3900000095367432,
487
- 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
488
- 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
489
- 0.5383333206176758, 0.5383333206176758, 0.5383333206176758,
490
- 0.5383333206176758, 0.21666667461395264, 0.21666667461395264,
491
- 0.6833333492279052, 0.6833333492279052, 0.6833333492279052,
492
- 0.4483333110809326, 0.9550000190734863, 0.6449999809265137,
493
- 0.5733333110809327, 0.6183333396911621, 0.7016666889190674,
494
- 0.5066666603088379, 0.48833332061767576, 0.6866666793823242,
495
- 0.7099999904632568, 0.26666667461395266, 0.35666666030883787,
496
- 0.5800000190734863, 0.4699999809265137, 0.8983333587646485,
497
- 0.7483333110809326, 0.753333330154419, 0.5599999904632569,
498
- 0.753333330154419, 0.5983333110809326, 0.5316666603088379,
499
- 0.5233333110809326, 0.6866666793823242, 0.621666669845581,
500
- 0.9333333015441895, 0.5900000095367431, 0.553333330154419,
501
- 0.5699999809265137, 0.30166666507720946, 0.37833333015441895,
502
- 0.30166666507720946, 0.6833333492279052, 0.41500000953674315,
503
- 0.26833333969116213, 0.5083333492279053, 0.5883333206176757,
504
- 0.4616666793823242, 0.5883333206176757, 0.5233333110809326,
505
- 0.3133333444595337, 0.5883333206176757, 0.5666666507720948,
506
- 0.24166667461395264, 0.24166667461395264, 0.49666666984558105,
507
- 0.24166667461395264, 0.8616666793823242, 0.5666666507720948,
508
- 0.5866666793823242, 0.5883333206176757, 0.5883333206176757,
509
- 0.3483333349227905, 0.425, 0.33833334445953367, 0.5666666507720948,
510
- 0.4783333301544189, 0.7233333110809326, 0.45833334922790525,
511
- 0.4833333492279053, 0.45166668891906736, 0.30166666507720946,
512
- 0.24000000953674316, 0.30166666507720946, 0.6833333492279052,
513
- ];
514
-
515
- const avg = 0.5131403493881227;
516
- // CJK character range: U+3000–U+9FFF (CJK Symbols/Punctuation, Hiragana,
517
- // Katakana, CJK Unified Ideographs incl. Extension A) plus U+FF00–U+FFEF
518
- // (Halfwidth/Fullwidth Forms — fullwidth ASCII, fullwidth punctuation).
519
- const cjkRange = /[\u3000-\u9FFF\uFF00-\uFFEF]/;
520
-
521
- return (
522
- [...str]
523
- .map((char) => {
524
- if (cjkRange.test(char) || char === '\u3000') {
525
- // CJK glyphs and U+3000 IDEOGRAPHIC SPACE are full-width by default;
526
- return 1;
527
- }
528
- const code = char.codePointAt(0) ?? 0;
529
- if (code < widths.length) {
530
- return widths[code] ?? avg;
531
- }
532
- return avg;
533
- })
534
- .reduce((cur, acc) => acc + cur) * fontSize
535
- );
536
- };
530
+ const measureText = (str: string, fontSize = 10): number =>
531
+ [...str]
532
+ .map((char) => {
533
+ if (FULL_WIDTH_PATTERN.test(char)) {
534
+ return 1;
535
+ }
536
+ return CHAR_WIDTHS[char.codePointAt(0) ?? 0] ?? AVERAGE_CHAR_WIDTH;
537
+ })
538
+ .reduce((total, width) => total + width, 0) * fontSize;
537
539
 
538
540
  /**
539
541
  * Split text into the lines it would wrap to when laid out greedily at the
@@ -673,6 +675,7 @@ const countWrappedLines = (
673
675
  ): number => Math.min(Math.max(1, splitWrappedText(text, fontSize, maxWidth).length), maxLines);
674
676
 
675
677
  export {
678
+ NUMBER_FORMATS,
676
679
  renderError,
677
680
  createLanguageNode,
678
681
  createProgressNode,
@@ -0,0 +1,271 @@
1
+ import type { CardConfig } from '../common/config.ts';
2
+ import type { GitHubDateRange } from '../common/date.ts';
3
+ import { toContributionRanges, toRange } from '../common/date.ts';
4
+ import { CardError, USER_NOT_FOUND } from '../common/error.ts';
5
+ import { createGraphQLFetcher } from '../common/http.ts';
6
+ import { logger } from '../common/log.ts';
7
+ import { chunkArray, clampValue } from '../common/ops.ts';
8
+ import { retryer } from '../common/retryer.ts';
9
+ import { UserContributionYearsDocument } from '../graphql/generated/contributed-to.ts';
10
+ import {
11
+ MAX_REPOSITORIES_LIMIT,
12
+ buildReposContributedToDocument,
13
+ } from '../graphql/reposContributedToDocument.ts';
14
+
15
+ import { graphqlError } from './graphql-error.ts';
16
+ import type { ContributedToData, ContributedRepo } from './types.ts';
17
+
18
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
19
+
20
+ /**
21
+ * Ranges per request.
22
+ * Each costs roughly `4 * MAX_REPOSITORIES_LIMIT` nodes,
23
+ * so an unchunked round of a heavily split account would breach GitHub's 500k node ceiling
24
+ * and lose the ranges that had already resolved along with it.
25
+ */
26
+ const MAX_RANGES_PER_REQUEST = 100;
27
+
28
+ const REPOS_CONTRIBUTED_TO_ERROR =
29
+ 'Something went wrong while trying to retrieve the repository contributions data using the GraphQL API.';
30
+
31
+ /** Rows the card ranks when the query names no count, and the most it will rank. */
32
+ const DEFAULT_REPOS_COUNT = 5;
33
+ const MAX_REPOS_COUNT = 20;
34
+
35
+ const CONTRIBUTION_YEARS_ERROR =
36
+ 'Something went wrong while trying to retrieve the contribution years using the GraphQL API.';
37
+
38
+ /** What one repository accumulated over the walk. */
39
+ interface RepoContributions {
40
+ /**
41
+ * Commit-days plus issues and pull requests opened.
42
+ * `CreatedCommitContribution` is one node per repository per day,
43
+ * so its `totalCount` counts days with commits, not commits —
44
+ * true commit counts would mean fetching the connection's nodes.
45
+ */
46
+ contributions: number;
47
+ /** Every year the user contributed to it. */
48
+ years: Set<number>;
49
+ }
50
+
51
+ const yearsFetcher = createGraphQLFetcher(UserContributionYearsDocument, 'bearer');
52
+
53
+ /**
54
+ * The repositories a user contributed to across every contribution year, with what each one got.
55
+ *
56
+ * `repositoriesContributedTo` spans at most one year,
57
+ * so every range is fetched as an aliased `contributionsCollection(from, to)` in one request and the repos merged.
58
+ * A range returning `MAX_REPOSITORIES_LIMIT` results may have more,
59
+ * so it is halved and requeried in the next round — a split stays inside its own calendar year,
60
+ * which is what makes `range.from`'s year the contribution's year.
61
+ *
62
+ * Whether private contributions are included depends on the used PAT.
63
+ *
64
+ * @returns Every repository found, keyed by `nameWithOwner`.
65
+ */
66
+ const fetchReposContributedTo = async (
67
+ /** `nameWithOwner` uses GitHub's casing, which the query-string username need not match */
68
+ canonicalUsername: string,
69
+ /** One per calendar year, so a contribution's year is the one its range starts in. */
70
+ ranges: Array<GitHubDateRange>,
71
+ includeOwnRepos: boolean,
72
+ config: CardConfig,
73
+ ): Promise<Map<string, RepoContributions>> => {
74
+ const repos = new Map<string, RepoContributions>();
75
+ let pending = ranges;
76
+
77
+ while (pending.length > 0) {
78
+ const nextPending: Array<GitHubDateRange> = [];
79
+
80
+ for (const chunk of chunkArray(pending, MAX_RANGES_PER_REQUEST)) {
81
+ const chunkFetcher = createGraphQLFetcher(
82
+ buildReposContributedToDocument(chunk, includeOwnRepos),
83
+ 'bearer',
84
+ );
85
+ const res = await retryer(
86
+ chunkFetcher,
87
+ { login: canonicalUsername, maxRepositories: MAX_REPOSITORIES_LIMIT },
88
+ config,
89
+ );
90
+ if (res.data.errors) {
91
+ throw graphqlError(res.data.errors, res.statusText, REPOS_CONTRIBUTED_TO_ERROR);
92
+ }
93
+ const { user } = res.data.data;
94
+ if (!user) {
95
+ throw new CardError(REPOS_CONTRIBUTED_TO_ERROR, { code: 'upstream' });
96
+ }
97
+
98
+ for (const [index, range] of chunk.entries()) {
99
+ const rangeResponse = user[`range_${index}`];
100
+ if (!rangeResponse) {
101
+ throw new CardError(REPOS_CONTRIBUTED_TO_ERROR, {
102
+ code: 'upstream',
103
+ });
104
+ }
105
+
106
+ const counted = [
107
+ rangeResponse.commitContributionsByRepository,
108
+ rangeResponse.issueContributionsByRepository,
109
+ rangeResponse.pullRequestContributionsByRepository,
110
+ ];
111
+ // creating a repository is one contribution, and the field carries no count of its own
112
+ const created = (rangeResponse.repositoryContributions?.nodes ?? [])
113
+ .filter((node) => node !== null)
114
+ .map((node) => ({ repository: node.repository, contributions: { totalCount: 1 } }));
115
+ const lists = [...counted, created];
116
+ const isSaturated = lists.some((list) => list.length >= MAX_REPOSITORIES_LIMIT);
117
+
118
+ const rangeDays = Math.round((range.to.getTime() - range.from.getTime()) / MS_PER_DAY);
119
+ // a range of 1 day or less can't be split any further
120
+ if (isSaturated && rangeDays >= 2) {
121
+ // every `from` sits on UTC midnight, so the split lands on a day boundary too
122
+ const mid = new Date(range.from.getTime() + Math.floor(rangeDays / 2) * MS_PER_DAY);
123
+ // GitHub only reads the date portion,
124
+ // so the first half ends 1 second before `mid` to keep the halves from sharing a day
125
+ nextPending.push(
126
+ { from: range.from, to: new Date(mid.getTime() - 1000) },
127
+ { from: mid, to: range.to },
128
+ );
129
+ continue;
130
+ }
131
+ if (isSaturated) {
132
+ logger.log(
133
+ `Range ${range.from.toISOString()} - ${range.to.toISOString()} is saturated but cannot be split further.`,
134
+ );
135
+ }
136
+
137
+ const year = range.from.getUTCFullYear();
138
+ for (const { repository, contributions } of lists.flat()) {
139
+ const name = repository.nameWithOwner;
140
+ if (!includeOwnRepos && name.startsWith(`${canonicalUsername}/`)) {
141
+ continue;
142
+ }
143
+ const existing = repos.get(name);
144
+ if (existing) {
145
+ existing.contributions += contributions.totalCount;
146
+ existing.years.add(year);
147
+ } else {
148
+ repos.set(name, {
149
+ contributions: contributions.totalCount,
150
+ years: new Set([year]),
151
+ });
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ // each saturated range pushes both of its halves
158
+ const saturatedCount = nextPending.length / 2;
159
+ if (saturatedCount > 0) {
160
+ logger.log(`found ${saturatedCount} saturated ranges, splitting and retrying...`);
161
+ }
162
+ pending = nextPending;
163
+ }
164
+
165
+ return repos;
166
+ };
167
+
168
+ /**
169
+ * Whether a repository was excluded by the query or by the deployment.
170
+ *
171
+ * An entry matches either the full `owner/name` or the bare `name`,
172
+ * so a host's `EXCLUDE_REPO` — which the other cards match against bare names — still bites here,
173
+ * where a repository is identified by its owner as well.
174
+ *
175
+ * @returns The test, over lowercased entries.
176
+ */
177
+ const excludes = (entries: Array<string>): ((nameWithOwner: string) => boolean) => {
178
+ const excluded = new Set(entries.map((entry) => entry.toLowerCase()));
179
+ return (nameWithOwner) => {
180
+ const lowered = nameWithOwner.toLowerCase();
181
+ return excluded.has(lowered) || excluded.has(lowered.slice(lowered.indexOf('/') + 1));
182
+ };
183
+ };
184
+
185
+ /**
186
+ * Rank the repositories a user has contributed to, most contributions first.
187
+ *
188
+ * `from` and `to` narrow the walk to the range they name,
189
+ * so the ranking, the totals and the year marks all cover that range and nothing else.
190
+ * A range the account contributed in no year of draws an empty card rather than an error.
191
+ *
192
+ * Ties keep the walk's order, which is chronological:
193
+ * the repository first seen in the earliest range comes first.
194
+ *
195
+ * An excluded repository is dropped before the total is counted,
196
+ * so the footer's "top n of m" never counts one the card was told not to show.
197
+ *
198
+ * @returns The ranked slice, and the totals it was taken from.
199
+ */
200
+ const fetchContributedTo = async (
201
+ {
202
+ username,
203
+ include_own_repos = false,
204
+ repos_count,
205
+ exclude_repo = [],
206
+ from,
207
+ to,
208
+ }: {
209
+ username: string | undefined;
210
+ include_own_repos?: boolean | undefined;
211
+ /** Rows to rank; out of range or unparsed, the default stands. */
212
+ repos_count?: number | undefined;
213
+ /** Each entry is an `owner/name` or a bare `name`. */
214
+ exclude_repo?: Array<string>;
215
+ /** Start of the range to walk; as early as GitHub goes when absent. */
216
+ from?: Date | undefined;
217
+ /** End of that range. */
218
+ to?: Date | undefined;
219
+ },
220
+ config: CardConfig,
221
+ ): Promise<ContributedToData> => {
222
+ if (!username) {
223
+ throw CardError.missingParam(['username']);
224
+ }
225
+
226
+ const count =
227
+ repos_count !== undefined && Number.isFinite(repos_count)
228
+ ? clampValue(repos_count, 1, MAX_REPOS_COUNT)
229
+ : DEFAULT_REPOS_COUNT;
230
+
231
+ const res = await retryer(yearsFetcher, { login: username }, config);
232
+ if (res.data.errors) {
233
+ throw graphqlError(res.data.errors, res.statusText, CONTRIBUTION_YEARS_ERROR);
234
+ }
235
+
236
+ const { user } = res.data.data;
237
+ if (!user) {
238
+ throw new CardError('Could not fetch user.', {
239
+ code: 'not_found',
240
+ secondaryMessage: USER_NOT_FOUND,
241
+ });
242
+ }
243
+
244
+ const ranges = toContributionRanges(
245
+ user.contributionsCollection.contributionYears,
246
+ toRange(from, to),
247
+ );
248
+ const years = ranges.map((range) => range.from.getUTCFullYear());
249
+
250
+ const found = await fetchReposContributedTo(user.login, ranges, include_own_repos, config);
251
+
252
+ const isExcluded = excludes([...exclude_repo, ...config.excludeRepositories]);
253
+ const kept = [...found].filter(([nameWithOwner]) => !isExcluded(nameWithOwner));
254
+
255
+ const ranked: Array<ContributedRepo> = kept
256
+ .map(([nameWithOwner, { contributions, years: repoYears }]) => ({
257
+ nameWithOwner,
258
+ contributions,
259
+ years: [...repoYears].toSorted((a, b) => a - b),
260
+ }))
261
+ .toSorted((a, b) => b.contributions - a.contributions);
262
+
263
+ return {
264
+ login: user.login,
265
+ repos: ranked.slice(0, count),
266
+ totalRepos: kept.length,
267
+ years,
268
+ };
269
+ };
270
+
271
+ export { fetchContributedTo, fetchReposContributedTo };
@@ -0,0 +1,36 @@
1
+ import { CardError, USER_NOT_FOUND } from '../common/error.ts';
2
+ import type { GraphQLResponse } from '../common/http.ts';
3
+ import { logger } from '../common/log.ts';
4
+ import { wrapTextMultiline } from '../common/render.ts';
5
+
6
+ /**
7
+ * Turn the GraphQL `errors` a user query answered with into the error to throw.
8
+ *
9
+ * Its own module because the fetchers classify with it, and two of them may not import each other:
10
+ * `stats.ts` reads the repository walk out of `contributed-to.ts`.
11
+ *
12
+ * @returns `not_found` when the user does not resolve; otherwise `upstream`, worded from the first entry or from `fallback`.
13
+ */
14
+ const graphqlError = (
15
+ errors: NonNullable<GraphQLResponse<unknown>['data']['errors']>,
16
+ statusText: string,
17
+ fallback: string,
18
+ ): CardError => {
19
+ logger.error(errors);
20
+ const [firstError] = errors;
21
+ if (firstError?.type === 'NOT_FOUND') {
22
+ return new CardError(firstError.message || 'Could not fetch user.', {
23
+ code: 'not_found',
24
+ secondaryMessage: USER_NOT_FOUND,
25
+ });
26
+ }
27
+ const message = firstError?.message;
28
+ return message
29
+ ? new CardError(wrapTextMultiline(message, 525, 12)[0] ?? '', {
30
+ code: 'upstream',
31
+ secondaryMessage: statusText,
32
+ })
33
+ : new CardError(fallback, { code: 'upstream' });
34
+ };
35
+
36
+ export { graphqlError };
@@ -1,3 +1,4 @@
1
+ export { fetchContributedTo } from './contributed-to.ts';
1
2
  export { fetchGist } from './gist.ts';
2
3
  export { fetchRepo } from './repo.ts';
3
4
  export { fetchRepoUserStats, fetchStats } from './stats.ts';
@@ -5,6 +6,8 @@ export { fetchTopLanguages } from './top-languages.ts';
5
6
  export { fetchWakatimeStats } from './wakatime.ts';
6
7
 
7
8
  export type {
9
+ ContributedRepo,
10
+ ContributedToData,
8
11
  GistData,
9
12
  Lang,
10
13
  RepoUserStats,