@stats-forge/github-stats-forge-core 0.0.2 → 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 (249) hide show
  1. package/README.md +0 -10
  2. package/build/api/api-result.d.ts +6 -7
  3. package/build/api/api-result.d.ts.map +1 -1
  4. package/build/api/api-result.js +4 -5
  5. package/build/api/contributed-to.d.ts +23 -0
  6. package/build/api/contributed-to.d.ts.map +1 -0
  7. package/build/api/contributed-to.js +43 -0
  8. package/build/api/gist.d.ts +8 -22
  9. package/build/api/gist.d.ts.map +1 -1
  10. package/build/api/gist.js +14 -36
  11. package/build/api/handler.d.ts +16 -0
  12. package/build/api/handler.d.ts.map +1 -0
  13. package/build/api/handler.js +29 -0
  14. package/build/api/index.d.ts +13 -12
  15. package/build/api/index.d.ts.map +1 -1
  16. package/build/api/index.js +1 -0
  17. package/build/api/params.d.ts +21 -12
  18. package/build/api/params.d.ts.map +1 -1
  19. package/build/api/params.js +41 -19
  20. package/build/api/pin.d.ts +11 -31
  21. package/build/api/pin.d.ts.map +1 -1
  22. package/build/api/pin.js +35 -63
  23. package/build/api/stats.d.ts +15 -16
  24. package/build/api/stats.d.ts.map +1 -1
  25. package/build/api/stats.js +60 -99
  26. package/build/api/top-langs.d.ts +13 -18
  27. package/build/api/top-langs.d.ts.map +1 -1
  28. package/build/api/top-langs.js +35 -72
  29. package/build/api/wakatime.d.ts +11 -17
  30. package/build/api/wakatime.d.ts.map +1 -1
  31. package/build/api/wakatime.js +27 -64
  32. package/build/calculateRank.d.ts +0 -9
  33. package/build/calculateRank.d.ts.map +1 -1
  34. package/build/calculateRank.js +12 -17
  35. package/build/cards/contributed-to.d.ts +19 -0
  36. package/build/cards/contributed-to.d.ts.map +1 -0
  37. package/build/cards/contributed-to.js +242 -0
  38. package/build/cards/gist.d.ts +3 -4
  39. package/build/cards/gist.d.ts.map +1 -1
  40. package/build/cards/gist.js +38 -43
  41. package/build/cards/options.d.ts +1 -1
  42. package/build/cards/repo.d.ts +14 -5
  43. package/build/cards/repo.d.ts.map +1 -1
  44. package/build/cards/repo.js +93 -103
  45. package/build/cards/stats.d.ts +18 -11
  46. package/build/cards/stats.d.ts.map +1 -1
  47. package/build/cards/stats.js +191 -222
  48. package/build/cards/top-languages.d.ts +18 -33
  49. package/build/cards/top-languages.d.ts.map +1 -1
  50. package/build/cards/top-languages.js +196 -387
  51. package/build/cards/wakatime.d.ts +17 -8
  52. package/build/cards/wakatime.d.ts.map +1 -1
  53. package/build/cards/wakatime.js +133 -187
  54. package/build/common/Card.d.ts +20 -59
  55. package/build/common/Card.d.ts.map +1 -1
  56. package/build/common/Card.js +131 -198
  57. package/build/common/I18n.d.ts +1 -7
  58. package/build/common/I18n.d.ts.map +1 -1
  59. package/build/common/I18n.js +7 -8
  60. package/build/common/brand.d.ts +76 -0
  61. package/build/common/brand.d.ts.map +1 -0
  62. package/build/common/brand.js +73 -0
  63. package/build/common/color.d.ts +4 -17
  64. package/build/common/color.d.ts.map +1 -1
  65. package/build/common/color.js +67 -49
  66. package/build/common/config.d.ts +4 -14
  67. package/build/common/config.d.ts.map +1 -1
  68. package/build/common/config.js +3 -16
  69. package/build/common/constants.d.ts +6 -2
  70. package/build/common/constants.d.ts.map +1 -1
  71. package/build/common/constants.js +5 -1
  72. package/build/common/date.d.ts +50 -5
  73. package/build/common/date.d.ts.map +1 -1
  74. package/build/common/date.js +104 -4
  75. package/build/common/emojiMap.d.ts +8 -0
  76. package/build/common/emojiMap.d.ts.map +1 -0
  77. package/build/common/emojiMap.js +10 -0
  78. package/build/common/emojiMap.json +1915 -0
  79. package/build/common/error.d.ts +1 -10
  80. package/build/common/error.d.ts.map +1 -1
  81. package/build/common/error.js +1 -10
  82. package/build/common/fmt.d.ts +3 -15
  83. package/build/common/fmt.d.ts.map +1 -1
  84. package/build/common/fmt.js +7 -35
  85. package/build/common/http.d.ts +2 -7
  86. package/build/common/http.d.ts.map +1 -1
  87. package/build/common/http.js +8 -17
  88. package/build/common/icons.d.ts +20 -20
  89. package/build/common/icons.d.ts.map +1 -1
  90. package/build/common/icons.js +108 -67
  91. package/build/common/languageColors.d.ts +0 -1
  92. package/build/common/languageColors.d.ts.map +1 -1
  93. package/build/common/languageColors.js +1 -4
  94. package/build/common/languageColors.json +18 -2
  95. package/build/common/ops.d.ts +11 -17
  96. package/build/common/ops.d.ts.map +1 -1
  97. package/build/common/ops.js +22 -39
  98. package/build/common/render.d.ts +34 -82
  99. package/build/common/render.d.ts.map +1 -1
  100. package/build/common/render.js +225 -259
  101. package/build/common/retryer.d.ts +2 -7
  102. package/build/common/retryer.d.ts.map +1 -1
  103. package/build/common/retryer.js +4 -11
  104. package/build/fetchers/contributed-to.d.ts +62 -0
  105. package/build/fetchers/contributed-to.d.ts.map +1 -0
  106. package/build/fetchers/contributed-to.js +184 -0
  107. package/build/fetchers/gist.d.ts +2 -5
  108. package/build/fetchers/gist.d.ts.map +1 -1
  109. package/build/fetchers/gist.js +1 -5
  110. package/build/fetchers/graphql-error.d.ts +13 -0
  111. package/build/fetchers/graphql-error.d.ts.map +1 -0
  112. package/build/fetchers/graphql-error.js +29 -0
  113. package/build/fetchers/index.d.ts +13 -12
  114. package/build/fetchers/index.d.ts.map +1 -1
  115. package/build/fetchers/index.js +1 -0
  116. package/build/fetchers/repo.d.ts +2 -11
  117. package/build/fetchers/repo.d.ts.map +1 -1
  118. package/build/fetchers/repo.js +20 -33
  119. package/build/fetchers/stats.d.ts +16 -40
  120. package/build/fetchers/stats.d.ts.map +1 -1
  121. package/build/fetchers/stats.js +89 -271
  122. package/build/fetchers/top-languages.d.ts +2 -9
  123. package/build/fetchers/top-languages.d.ts.map +1 -1
  124. package/build/fetchers/top-languages.js +16 -47
  125. package/build/fetchers/types.d.ts +25 -1
  126. package/build/fetchers/types.d.ts.map +1 -1
  127. package/build/fetchers/wakatime.d.ts +2 -6
  128. package/build/fetchers/wakatime.d.ts.map +1 -1
  129. package/build/fetchers/wakatime.js +1 -5
  130. package/build/graphql/contributionsCollection.d.ts +9 -0
  131. package/build/graphql/contributionsCollection.d.ts.map +1 -0
  132. package/build/graphql/contributionsCollection.js +12 -0
  133. package/build/graphql/contributionsDocument.d.ts +6 -6
  134. package/build/graphql/contributionsDocument.d.ts.map +1 -1
  135. package/build/graphql/contributionsDocument.js +8 -16
  136. package/build/graphql/generated/contributed-to.d.ts +22 -0
  137. package/build/graphql/generated/contributed-to.d.ts.map +1 -0
  138. package/build/graphql/generated/contributed-to.js +11 -0
  139. package/build/graphql/generated/gist.d.ts +1 -1
  140. package/build/graphql/generated/repo.d.ts +1 -1
  141. package/build/graphql/generated/stats.d.ts +16 -4
  142. package/build/graphql/generated/stats.d.ts.map +1 -1
  143. package/build/graphql/generated/stats.js +2 -2
  144. package/build/graphql/generated/top-languages.d.ts +2 -2
  145. package/build/graphql/graphqlDocument.d.ts +1 -2
  146. package/build/graphql/graphqlDocument.d.ts.map +1 -1
  147. package/build/graphql/graphqlDocument.js +0 -1
  148. package/build/graphql/reposContributedToDocument.d.ts +4 -5
  149. package/build/graphql/reposContributedToDocument.d.ts.map +1 -1
  150. package/build/graphql/reposContributedToDocument.js +11 -7
  151. package/build/markup/css.d.ts +35 -0
  152. package/build/markup/css.d.ts.map +1 -0
  153. package/build/markup/css.js +64 -0
  154. package/build/markup/element.d.ts +41 -0
  155. package/build/markup/element.d.ts.map +1 -0
  156. package/build/markup/element.js +127 -0
  157. package/build/markup/html.d.ts +13 -0
  158. package/build/markup/html.d.ts.map +1 -0
  159. package/build/markup/html.js +27 -0
  160. package/build/markup/index.d.ts +9 -0
  161. package/build/markup/index.d.ts.map +1 -0
  162. package/build/markup/index.js +6 -0
  163. package/build/markup/serialize.d.ts +16 -0
  164. package/build/markup/serialize.d.ts.map +1 -0
  165. package/build/markup/serialize.js +17 -0
  166. package/build/themes/index.d.ts +332 -333
  167. package/build/themes/index.d.ts.map +1 -1
  168. package/build/themes/index.js +1 -1
  169. package/build/translations.d.ts +66 -7
  170. package/build/translations.d.ts.map +1 -1
  171. package/build/translations.js +686 -629
  172. package/package.json +17 -26
  173. package/src/api/api-result.ts +10 -9
  174. package/src/api/contributed-to.ts +84 -0
  175. package/src/api/gist.ts +22 -46
  176. package/src/api/handler.ts +42 -0
  177. package/src/api/index.ts +13 -12
  178. package/src/api/params.ts +74 -29
  179. package/src/api/pin.ts +38 -65
  180. package/src/api/stats.ts +81 -118
  181. package/src/api/top-langs.ts +38 -80
  182. package/src/api/wakatime.ts +35 -73
  183. package/src/calculateRank.ts +12 -23
  184. package/src/cards/contributed-to.ts +373 -0
  185. package/src/cards/gist.ts +55 -49
  186. package/src/cards/options.ts +1 -1
  187. package/src/cards/repo.ts +134 -120
  188. package/src/cards/stats.ts +262 -252
  189. package/src/cards/top-languages.ts +315 -422
  190. package/src/cards/wakatime.ts +207 -225
  191. package/src/common/Card.ts +207 -214
  192. package/src/common/I18n.ts +7 -8
  193. package/src/common/brand.ts +109 -0
  194. package/src/common/color.ts +80 -57
  195. package/src/common/config.ts +5 -18
  196. package/src/common/constants.ts +8 -2
  197. package/src/common/date.ts +135 -5
  198. package/src/common/emojiMap.json +1915 -0
  199. package/src/common/emojiMap.ts +13 -0
  200. package/src/common/error.ts +2 -11
  201. package/src/common/fmt.ts +7 -44
  202. package/src/common/http.ts +9 -15
  203. package/src/common/icons.ts +122 -42
  204. package/src/common/languageColors.json +18 -2
  205. package/src/common/languageColors.ts +1 -4
  206. package/src/common/ops.ts +31 -48
  207. package/src/common/render.ts +334 -284
  208. package/src/common/retryer.ts +8 -15
  209. package/src/fetchers/contributed-to.ts +271 -0
  210. package/src/fetchers/gist.ts +8 -12
  211. package/src/fetchers/graphql-error.ts +36 -0
  212. package/src/fetchers/index.ts +15 -12
  213. package/src/fetchers/repo.ts +27 -40
  214. package/src/fetchers/stats.ts +157 -366
  215. package/src/fetchers/top-languages.ts +27 -55
  216. package/src/fetchers/types.ts +27 -1
  217. package/src/fetchers/wakatime.ts +5 -9
  218. package/src/graphql/contributionsCollection.ts +18 -0
  219. package/src/graphql/contributionsDocument.ts +14 -19
  220. package/src/graphql/generated/contributed-to.ts +25 -0
  221. package/src/graphql/generated/gist.ts +1 -1
  222. package/src/graphql/generated/repo.ts +1 -1
  223. package/src/graphql/generated/stats.ts +21 -8
  224. package/src/graphql/generated/top-languages.ts +2 -2
  225. package/src/graphql/graphqlDocument.ts +1 -2
  226. package/src/graphql/queries/contributed-to.graphql +8 -0
  227. package/src/graphql/queries/stats.graphql +14 -2
  228. package/src/graphql/reposContributedToDocument.ts +16 -15
  229. package/src/markup/css.ts +118 -0
  230. package/src/markup/element.ts +246 -0
  231. package/src/markup/html.ts +34 -0
  232. package/src/markup/index.ts +9 -0
  233. package/src/markup/serialize.ts +27 -0
  234. package/src/themes/index.ts +1 -1
  235. package/src/translations.ts +699 -629
  236. package/build/cards/index.d.ts +0 -11
  237. package/build/cards/index.d.ts.map +0 -1
  238. package/build/cards/index.js +0 -7
  239. package/build/common/html.d.ts +0 -8
  240. package/build/common/html.d.ts.map +0 -1
  241. package/build/common/html.js +0 -14
  242. package/build/index.d.ts +0 -15
  243. package/build/index.d.ts.map +0 -1
  244. package/build/index.js +0 -10
  245. package/src/_emoji-name-map.d.ts +0 -10
  246. package/src/_github-username-regex.d.ts +0 -4
  247. package/src/cards/index.ts +0 -21
  248. package/src/common/html.ts +0 -17
  249. package/src/index.ts +0 -18
@@ -1,7 +1,7 @@
1
- import type { CardConfig } from './config.js';
2
- import { CardError } from './error.js';
3
- import type { FetcherContext, HttpResponse } from './http.js';
4
- import { logger } from './log.js';
1
+ import type { CardConfig } from './config.ts';
2
+ import { CardError } from './error.ts';
3
+ import type { FetcherContext, HttpResponse } from './http.ts';
4
+ import { logger } from './log.ts';
5
5
 
6
6
  /**
7
7
  * Error-detection fields the retryer inspects to detect rate-limiting and credential failures.
@@ -19,8 +19,6 @@ interface ResponseErrors {
19
19
  * The value is generated using `Math.random()` and uniformly distributed
20
20
  * across the range.
21
21
  *
22
- * @param max The upper bound (exclusive). Must be a positive number.
23
- *
24
22
  * @returns A random integer `n` such that `0 <= n < max`.
25
23
  */
26
24
  function getRandomInt(max: number): number {
@@ -44,11 +42,6 @@ type FetcherFunction<TData = unknown, TVariables = Record<string, unknown>> = (
44
42
  /**
45
43
  * Try to execute the fetcher function until it succeeds or the max number of retries is reached.
46
44
  *
47
- * @template TData Shape of `response.data` returned by the fetcher.
48
- * @template TVariables Variables the fetcher accepts.
49
- * @param fetcher The fetcher function.
50
- * @param variables Object with arguments to pass to the fetcher function.
51
- * @param config Deployment config supplying the PAT pool and the transport.
52
45
  * @returns The response from the fetcher function.
53
46
  */
54
47
  const retryer = async <TData = unknown, TVariables = Record<string, unknown>>(
@@ -58,12 +51,12 @@ const retryer = async <TData = unknown, TVariables = Record<string, unknown>>(
58
51
  ): Promise<FetcherResponse<TData>> => {
59
52
  const PATs = config.pats;
60
53
 
61
- if (!PATs.length) {
54
+ if (PATs.length === 0) {
62
55
  throw new CardError('No GitHub API tokens found', { code: 'no_tokens' });
63
56
  }
64
57
  const startPAT = getRandomInt(PATs.length);
65
58
 
66
- for (let retries = 0; retries < PATs.length; retries++) {
59
+ for (let retries = 0; retries < PATs.length; retries += 1) {
67
60
  const currentPAT = PATs[(startPAT + retries) % PATs.length];
68
61
  if (!currentPAT) {
69
62
  continue;
@@ -77,7 +70,7 @@ const retryer = async <TData = unknown, TVariables = Record<string, unknown>>(
77
70
 
78
71
  // react on both type and message-based rate-limit signals.
79
72
  // https://github.com/anuraghazra/github-readme-stats/issues/4425
80
- const errors = response.data.errors;
73
+ const { errors } = response.data;
81
74
  const errorType = errors?.[0]?.type;
82
75
  const errorMsg = errors?.[0]?.message ?? '';
83
76
  const isRateLimited =
@@ -89,7 +82,7 @@ const retryer = async <TData = unknown, TVariables = Record<string, unknown>>(
89
82
  }
90
83
 
91
84
  // also checking for bad credentials if any tokens gets invalidated
92
- const message = response.data.message;
85
+ const { message } = response.data;
93
86
  const isBadCredential = message === 'Bad credentials';
94
87
  const isAccountSuspended = message === 'Sorry. Your account was suspended.';
95
88
 
@@ -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 };
@@ -1,18 +1,17 @@
1
- import type { CardConfig } from '../common/config.js';
2
- import { CardError, GIST_NOT_FOUND } from '../common/error.js';
3
- import { createGraphQLFetcher } from '../common/http.js';
4
- import { retryer } from '../common/retryer.js';
5
- import { GistInfoDocument } from '../graphql/generated/gist.js';
6
- import type { GistFileInfoFragment } from '../graphql/generated/gist.js';
1
+ import type { CardConfig } from '../common/config.ts';
2
+ import { CardError, GIST_NOT_FOUND } from '../common/error.ts';
3
+ import { createGraphQLFetcher } from '../common/http.ts';
4
+ import { retryer } from '../common/retryer.ts';
5
+ import { GistInfoDocument } from '../graphql/generated/gist.ts';
6
+ import type { GistFileInfoFragment } from '../graphql/generated/gist.ts';
7
7
 
8
- import type { GistData } from './types.js';
8
+ import type { GistData } from './types.ts';
9
9
 
10
10
  const fetcher = createGraphQLFetcher(GistInfoDocument, 'token');
11
11
 
12
12
  /**
13
13
  * This function calculates the primary language of a gist by files size.
14
14
  *
15
- * @param files Files.
16
15
  * @returns Primary language, or `null` when no file has a language.
17
16
  */
18
17
  const calculatePrimaryLanguage = (files: Array<GistFileInfoFragment>): string | null => {
@@ -39,9 +38,6 @@ const calculatePrimaryLanguage = (files: Array<GistFileInfoFragment>): string |
39
38
  /**
40
39
  * Fetch GitHub gist information by given username and ID.
41
40
  *
42
- * @param props Fetcher props.
43
- * @param props.id GitHub gist ID.
44
- * @param config Deployment config supplying the PAT pool.
45
41
  * @returns Gist data.
46
42
  */
47
43
  const fetchGist = async (
@@ -57,7 +53,7 @@ const fetchGist = async (
57
53
  code: 'upstream',
58
54
  });
59
55
  }
60
- const gist = res.data.data.viewer.gist;
56
+ const { gist } = res.data.data.viewer;
61
57
  if (!gist) {
62
58
  throw new CardError('Gist not found', {
63
59
  code: 'not_found',
@@ -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,10 +1,13 @@
1
- export { fetchGist } from './gist.js';
2
- export { fetchRepo } from './repo.js';
3
- export { fetchRepoUserStats, fetchStats } from './stats.js';
4
- export { fetchTopLanguages } from './top-languages.js';
5
- export { fetchWakatimeStats } from './wakatime.js';
1
+ export { fetchContributedTo } from './contributed-to.ts';
2
+ export { fetchGist } from './gist.ts';
3
+ export { fetchRepo } from './repo.ts';
4
+ export { fetchRepoUserStats, fetchStats } from './stats.ts';
5
+ export { fetchTopLanguages } from './top-languages.ts';
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,
@@ -13,13 +16,13 @@ export type {
13
16
  TopLangData,
14
17
  WakaTimeData,
15
18
  WakaTimeLang,
16
- } from './types.js';
19
+ } from './types.ts';
17
20
 
18
21
  // every fetcher takes a config, so this entry carries it rather than sending a
19
22
  // consumer back to the package root for it.
20
- export { CardConfig } from '../common/config.js';
21
- export type { CardConfigInit, PersonalAccessToken } from '../common/config.js';
22
- export type { FetchLike } from '../common/http.js';
23
- export { retryer } from '../common/retryer.js';
24
- export { CardError } from '../common/error.js';
25
- export type { ErrorCode } from '../common/error.js';
23
+ export { CardConfig } from '../common/config.ts';
24
+ export type { CardConfigInit, PersonalAccessToken } from '../common/config.ts';
25
+ export type { FetchLike } from '../common/http.ts';
26
+ export { retryer } from '../common/retryer.ts';
27
+ export { CardError } from '../common/error.ts';
28
+ export type { ErrorCode } from '../common/error.ts';
@@ -1,11 +1,11 @@
1
- import type { CardConfig } from '../common/config.js';
2
- import { CardError, REPO_NOT_FOUND } from '../common/error.js';
3
- import { createGraphQLFetcher } from '../common/http.js';
4
- import { retryer } from '../common/retryer.js';
5
- import { GetRepoDocument } from '../graphql/generated/repo.js';
1
+ import type { CardConfig } from '../common/config.ts';
2
+ import { CardError, REPO_NOT_FOUND } from '../common/error.ts';
3
+ import { createGraphQLFetcher } from '../common/http.ts';
4
+ import { retryer } from '../common/retryer.ts';
5
+ import { GetRepoDocument } from '../graphql/generated/repo.ts';
6
6
 
7
- import { fetchRepoUserStats } from './stats.js';
8
- import type { RepositoryData } from './types.js';
7
+ import { fetchRepoUserStats } from './stats.ts';
8
+ import type { RepositoryData } from './types.ts';
9
9
 
10
10
  const fetcher = createGraphQLFetcher(GetRepoDocument, 'token');
11
11
 
@@ -14,15 +14,6 @@ const urlExample = '/api/pin?username=USERNAME&repo=REPO_NAME';
14
14
  /**
15
15
  * Fetch repository data.
16
16
  *
17
- * @param props Fetcher props.
18
- * @param props.username GitHub username.
19
- * @param props.reponame GitHub repository name.
20
- * @param props.include_prs_authored Include count of PRs authored.
21
- * @param props.include_prs_commented Include count of PRs commented.
22
- * @param props.include_prs_reviewed Include count of PRs reviewed.
23
- * @param props.include_issues_authored Include count of issues authored.
24
- * @param props.include_issues_commented Include count of issues commented.
25
- * @param config Deployment config supplying the PAT pool.
26
17
  * @returns Repository data.
27
18
  */
28
19
  const fetchRepo = async (
@@ -45,35 +36,31 @@ const fetchRepo = async (
45
36
  },
46
37
  config: CardConfig,
47
38
  ): Promise<RepositoryData> => {
48
- let owner = username;
49
- if (reponame && reponame.includes('/')) {
50
- const [parsedOwner, parsedRepo] = reponame.split('/');
51
- owner = parsedOwner ?? '';
52
- reponame = parsedRepo ?? '';
39
+ // `?repo=owner/name` carries the owner itself, and then it wins over `?username=`.
40
+ let parsedOwner = username;
41
+ let repo = reponame;
42
+ if (repo?.includes('/')) {
43
+ const [ownerFromRepo, nameFromRepo] = repo.split('/');
44
+ parsedOwner = ownerFromRepo ?? '';
45
+ repo = nameFromRepo ?? '';
53
46
  }
54
47
 
55
- if (owner && !username) {
56
- username = owner;
57
- }
58
- if (username && !owner) {
59
- owner = username;
60
- }
61
- if (!username && !reponame) {
48
+ const login = username || parsedOwner;
49
+ if (!login && !repo) {
62
50
  throw CardError.missingParam(['username', 'repo'], urlExample);
63
51
  }
64
- if (!username) {
52
+ if (!login) {
65
53
  throw CardError.missingParam(['username'], urlExample);
66
54
  }
67
- if (!reponame) {
55
+ if (!repo) {
68
56
  throw CardError.missingParam(['repo'], urlExample);
69
57
  }
70
58
 
71
- // the guards above leave `username` set, and `owner` mirrors it when `repo` carried none
72
- const repoOwner = owner ?? username;
59
+ const repoOwner = parsedOwner || login;
73
60
 
74
- const res = await retryer(fetcher, { login: repoOwner, repo: reponame }, config);
61
+ const res = await retryer(fetcher, { login: repoOwner, repo }, config);
75
62
 
76
- const data = res.data.data;
63
+ const { data } = res.data;
77
64
 
78
65
  if (!data.user && !data.organization) {
79
66
  throw new CardError('Not found', {
@@ -83,7 +70,7 @@ const fetchRepo = async (
83
70
  }
84
71
 
85
72
  if (data.organization === null && data.user) {
86
- const repository = data.user.repository;
73
+ const { repository } = data.user;
87
74
  if (!repository || repository.isPrivate) {
88
75
  throw new CardError('User Repository Not found', {
89
76
  code: 'not_found',
@@ -92,8 +79,8 @@ const fetchRepo = async (
92
79
  }
93
80
  const repoUserStats = await fetchRepoUserStats(
94
81
  {
95
- username,
96
- repo: [`${repoOwner}/${reponame}`],
82
+ username: login,
83
+ repo: [`${repoOwner}/${repo}`],
97
84
  include_prs_authored,
98
85
  include_prs_commented,
99
86
  include_prs_reviewed,
@@ -109,7 +96,7 @@ const fetchRepo = async (
109
96
  }
110
97
 
111
98
  if (data.user === null && data.organization) {
112
- const repository = data.organization.repository;
99
+ const { repository } = data.organization;
113
100
  if (!repository || repository.isPrivate) {
114
101
  throw new CardError('Organization Repository Not found', {
115
102
  code: 'not_found',
@@ -118,8 +105,8 @@ const fetchRepo = async (
118
105
  }
119
106
  const repoUserStats = await fetchRepoUserStats(
120
107
  {
121
- username,
122
- repo: [`${repoOwner}/${reponame}`],
108
+ username: login,
109
+ repo: [`${repoOwner}/${repo}`],
123
110
  include_prs_authored,
124
111
  include_prs_commented,
125
112
  include_prs_reviewed,