@stats-forge/github-stats-forge-core 0.1.0 → 0.3.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 +69 -61
  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 +75 -65
  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
@@ -2,27 +2,31 @@ import { calculateRank } from '../calculateRank.ts';
2
2
  import type { CardConfig } from '../common/config.ts';
3
3
  import { GITHUB_USERNAME_PATTERN } from '../common/constants.ts';
4
4
  import type { GitHubDateRange } from '../common/date.ts';
5
- import { getGitHubYearRange, toGitHubDateTime } from '../common/date.ts';
5
+ import {
6
+ getWidestRange,
7
+ toContributionRanges,
8
+ toGitHubDateTime,
9
+ toRange,
10
+ toYearRanges,
11
+ } from '../common/date.ts';
6
12
  import { CardError, USER_NOT_FOUND } from '../common/error.ts';
7
13
  import { createGraphQLFetcher, httpRequest } from '../common/http.ts';
8
14
  import type { FetcherContext, GraphQLResponse } from '../common/http.ts';
9
15
  import { logger } from '../common/log.ts';
10
- import { buildSearchFilter, chunkArray, parseOwnerAffiliations } from '../common/ops.ts';
11
- import { wrapTextMultiline } from '../common/render.ts';
16
+ import { buildSearchFilter, parseOwnerAffiliations } from '../common/ops.ts';
12
17
  import { retryer } from '../common/retryer.ts';
13
18
  import type { FetcherResponse } from '../common/retryer.ts';
14
19
  import { buildContributionsDocument } from '../graphql/contributionsDocument.ts';
15
20
  import { UserInfoDocument, UserReposDocument } from '../graphql/generated/stats.ts';
16
21
  import type {
22
+ RangeContributionsFragment,
17
23
  RepoNodeFragment,
18
24
  UserInfoQuery,
19
25
  UserInfoQueryVariables,
20
26
  } from '../graphql/generated/stats.ts';
21
- import {
22
- MAX_REPOSITORIES_LIMIT,
23
- buildReposContributedToDocument,
24
- } from '../graphql/reposContributedToDocument.ts';
25
27
 
28
+ import { fetchReposContributedTo } from './contributed-to.ts';
29
+ import { graphqlError } from './graphql-error.ts';
26
30
  import type { RepoUserStats, StatsData } from './types.ts';
27
31
 
28
32
  /** The subset of the stats response `statsFetcher` returns and threads on. */
@@ -46,7 +50,7 @@ const statsFetcher = async (
46
50
  includeMergedPullRequests,
47
51
  includeDiscussions,
48
52
  includeDiscussionsAnswers,
49
- startTime,
53
+ commitsRange,
50
54
  ownerAffiliations,
51
55
  includeUserRepositories,
52
56
  }: {
@@ -54,7 +58,8 @@ const statsFetcher = async (
54
58
  includeMergedPullRequests: boolean;
55
59
  includeDiscussions: boolean;
56
60
  includeDiscussionsAnswers: boolean;
57
- startTime: string | undefined;
61
+ /** The range the commit count covers; the last year, as GitHub reckons it, when absent. */
62
+ commitsRange: GitHubDateRange | undefined;
58
63
  ownerAffiliations: UserInfoQueryVariables['ownerAffiliations'];
59
64
  includeUserRepositories: boolean;
60
65
  },
@@ -69,7 +74,9 @@ const statsFetcher = async (
69
74
  includeMergedPullRequests,
70
75
  includeDiscussions,
71
76
  includeDiscussionsAnswers,
72
- startTime,
77
+ // both ends together: an open `to` would run the range into the next 1st of January
78
+ startTime: commitsRange && toGitHubDateTime(commitsRange.from),
79
+ endTime: commitsRange && toGitHubDateTime(commitsRange.to),
73
80
  ownerAffiliations,
74
81
  includeUserRepositories,
75
82
  },
@@ -123,6 +130,16 @@ const statsFetcher = async (
123
130
  return stats;
124
131
  };
125
132
 
133
+ /** One REST search: which index, the qualifiers scoping it, and the ones naming what to count. */
134
+ interface SearchVariables {
135
+ /** `commits` or `issues`, the latter covering pull requests too. */
136
+ type: string;
137
+ /** Qualifiers appended after the scope, already `+`-joined. */
138
+ filter: string;
139
+ repo: Array<string>;
140
+ owner: Array<string>;
141
+ }
142
+
126
143
  /**
127
144
  * Fetch total items count using the REST search API.
128
145
  *
@@ -131,15 +148,11 @@ const statsFetcher = async (
131
148
  * @returns The search response, carrying `total_count`.
132
149
  */
133
150
  const fetchTotalItems = (
134
- variables: Record<string, unknown>,
151
+ { type, filter, repo, owner }: SearchVariables,
135
152
  token: string,
136
153
  { fetch }: FetcherContext,
137
- ): Promise<FetcherResponse<{ total_count?: number }>> => {
138
- const type = String(variables['type']);
139
- const filter = String(variables['filter']);
140
- const repo = variables['repo'] as Array<string> | string;
141
- const owner = variables['owner'] as Array<string> | string;
142
- return httpRequest(
154
+ ): Promise<FetcherResponse<{ total_count?: number }>> =>
155
+ httpRequest(
143
156
  fetch,
144
157
  `https://api.github.com/search/${type}?per_page=1&q=${buildSearchFilter(repo, owner).replaceAll(
145
158
  ' ',
@@ -154,7 +167,6 @@ const fetchTotalItems = (
154
167
  },
155
168
  },
156
169
  );
157
- };
158
170
 
159
171
  /**
160
172
  * Fetch a total count for a given username via the REST search API.
@@ -166,19 +178,7 @@ const fetchTotalItems = (
166
178
  * @returns Total count.
167
179
  */
168
180
  const totalItemsFetcher = async (
169
- {
170
- username,
171
- repo,
172
- owner,
173
- type,
174
- filter,
175
- }: {
176
- username: string;
177
- repo: Array<string>;
178
- owner: Array<string>;
179
- type: string;
180
- filter: string;
181
- },
181
+ { username, ...search }: SearchVariables & { username: string },
182
182
  config: CardConfig,
183
183
  ): Promise<number> => {
184
184
  if (!GITHUB_USERNAME_PATTERN.test(username)) {
@@ -191,11 +191,7 @@ const totalItemsFetcher = async (
191
191
 
192
192
  let res: FetcherResponse<{ total_count?: number }>;
193
193
  try {
194
- res = await retryer<{ total_count?: number }>(
195
- fetchTotalItems,
196
- { login: username, repo, owner, type, filter },
197
- config,
198
- );
194
+ res = await retryer(fetchTotalItems, search, config);
199
195
  } catch (error) {
200
196
  logger.log(error);
201
197
  throw error;
@@ -211,6 +207,38 @@ const totalItemsFetcher = async (
211
207
  return totalCount;
212
208
  };
213
209
 
210
+ /** The flags that ask for a per-repository count, as the query names them. */
211
+ interface RepoUserStatsFlags {
212
+ include_prs_authored?: boolean | undefined;
213
+ include_prs_commented?: boolean | undefined;
214
+ include_prs_reviewed?: boolean | undefined;
215
+ include_issues_authored?: boolean | undefined;
216
+ include_issues_commented?: boolean | undefined;
217
+ }
218
+
219
+ /** Each count the REST search API answers: the flag asking for it, and the qualifiers that find it. */
220
+ const REPO_USER_SEARCHES: ReadonlyArray<
221
+ [flag: keyof RepoUserStatsFlags, stat: keyof RepoUserStats, filter: (username: string) => string]
222
+ > = [
223
+ ['include_prs_authored', 'totalPRsAuthored', (username) => `author:${username}+type:pr`],
224
+ [
225
+ 'include_prs_commented',
226
+ 'totalPRsCommented',
227
+ (username) => `commenter:${username}+-author:${username}+type:pr`,
228
+ ],
229
+ [
230
+ 'include_prs_reviewed',
231
+ 'totalPRsReviewed',
232
+ (username) => `reviewed-by:${username}+-author:${username}+type:pr`,
233
+ ],
234
+ ['include_issues_authored', 'totalIssuesAuthored', (username) => `author:${username}+type:issue`],
235
+ [
236
+ 'include_issues_commented',
237
+ 'totalIssuesCommented',
238
+ (username) => `commenter:${username}+-author:${username}+type:issue`,
239
+ ],
240
+ ];
241
+
214
242
  /**
215
243
  * Fetch the per-repository counts the REST search API answers, one request each.
216
244
  *
@@ -221,122 +249,46 @@ const fetchRepoUserStats = async (
221
249
  username,
222
250
  repo = [],
223
251
  owner = [],
224
- include_prs_authored = false,
225
- include_prs_commented = false,
226
- include_prs_reviewed = false,
227
- include_issues_authored = false,
228
- include_issues_commented = false,
229
- }: {
252
+ ...flags
253
+ }: RepoUserStatsFlags & {
230
254
  username: string;
231
255
  repo?: Array<string>;
232
256
  owner?: Array<string>;
233
- include_prs_authored?: boolean | undefined;
234
- include_prs_commented?: boolean | undefined;
235
- include_prs_reviewed?: boolean | undefined;
236
- include_issues_authored?: boolean | undefined;
237
- include_issues_commented?: boolean | undefined;
238
257
  },
239
258
  config: CardConfig,
240
259
  ): Promise<RepoUserStats> => {
241
260
  const stats: RepoUserStats = {};
242
- if (include_prs_authored) {
243
- stats.totalPRsAuthored = await totalItemsFetcher(
244
- {
245
- username,
246
- repo,
247
- owner,
248
- type: 'issues',
249
- filter: `author:${username}+type:pr`,
250
- },
251
- config,
252
- );
253
- }
254
- if (include_prs_commented) {
255
- stats.totalPRsCommented = await totalItemsFetcher(
256
- {
257
- username,
258
- repo,
259
- owner,
260
- type: 'issues',
261
- filter: `commenter:${username}+-author:${username}+type:pr`,
262
- },
263
- config,
264
- );
265
- }
266
- if (include_prs_reviewed) {
267
- stats.totalPRsReviewed = await totalItemsFetcher(
268
- {
269
- username,
270
- repo,
271
- owner,
272
- type: 'issues',
273
- filter: `reviewed-by:${username}+-author:${username}+type:pr`,
274
- },
275
- config,
276
- );
277
- }
278
- if (include_issues_authored) {
279
- stats.totalIssuesAuthored = await totalItemsFetcher(
280
- {
281
- username,
282
- repo,
283
- owner,
284
- type: 'issues',
285
- filter: `author:${username}+type:issue`,
286
- },
287
- config,
288
- );
289
- }
290
- if (include_issues_commented) {
291
- stats.totalIssuesCommented = await totalItemsFetcher(
292
- {
293
- username,
294
- repo,
295
- owner,
296
- type: 'issues',
297
- filter: `commenter:${username}+-author:${username}+type:issue`,
298
- },
299
- config,
300
- );
261
+ for (const [flag, stat, filter] of REPO_USER_SEARCHES) {
262
+ if (flags[flag]) {
263
+ stats[stat] = await totalItemsFetcher(
264
+ { username, repo, owner, type: 'issues', filter: filter(username) },
265
+ config,
266
+ );
267
+ }
301
268
  }
302
269
  return stats;
303
270
  };
304
271
 
305
272
  /**
306
- * Turn a GraphQL `errors` payload into the error to throw.
307
- */
308
- const graphqlError = (
309
- errors: NonNullable<GraphQLResponse<unknown>['data']['errors']>,
310
- statusText: string,
311
- fallback: string,
312
- ): CardError => {
313
- logger.error(errors);
314
- const message = errors[0]?.message;
315
- return message
316
- ? new CardError(wrapTextMultiline(message, 525, 12)[0] ?? '', {
317
- code: 'upstream',
318
- secondaryMessage: statusText,
319
- })
320
- : new CardError(fallback, { code: 'upstream' });
321
- };
322
-
323
- /**
324
- * Fetch all-time contributions by building a single GraphQL query
325
- * for all the given years.
273
+ * Sum one figure over a set of ranges, fetched as aliased fields of a single query.
326
274
  *
327
275
  * Whether private contributions are included depends on the user's profile settings:
328
276
  * https://docs.github.com/en/account-and-profile/how-tos/contribution-settings/manage-visibility-settings-for-private-contributions-and-achievements#changing-the-visibility-of-your-private-contributions
277
+ *
278
+ * @returns The total over every range, and 0 when there are none.
329
279
  */
330
- const fetchTotalContributions = async (
280
+ const sumOverRanges = async (
331
281
  username: string,
332
- years: Array<number>,
282
+ ranges: Array<GitHubDateRange>,
283
+ /** What one range contributes to the total. */
284
+ pick: (block: RangeContributionsFragment) => number,
333
285
  config: CardConfig,
334
286
  ): Promise<number> => {
335
- if (years.length === 0) {
287
+ if (ranges.length === 0) {
336
288
  return 0;
337
289
  }
338
290
 
339
- const contributionsFetcher = createGraphQLFetcher(buildContributionsDocument(years), 'bearer');
291
+ const contributionsFetcher = createGraphQLFetcher(buildContributionsDocument(ranges), 'bearer');
340
292
 
341
293
  const contribRes = await retryer(contributionsFetcher, { login: username }, config);
342
294
 
@@ -354,126 +306,12 @@ const fetchTotalContributions = async (
354
306
  }
355
307
 
356
308
  let total = 0;
357
- for (const year of years) {
358
- const yearBlock = user[`year_${year}`];
359
- if (yearBlock?.contributionCalendar.totalContributions) {
360
- total += yearBlock.contributionCalendar.totalContributions;
361
- }
309
+ for (const block of Object.values(user)) {
310
+ total += pick(block);
362
311
  }
363
312
  return total;
364
313
  };
365
314
 
366
- const MS_PER_DAY = 24 * 60 * 60 * 1000;
367
-
368
- /**
369
- * Ranges per request.
370
- * Each costs roughly `4 * MAX_REPOSITORIES_LIMIT` nodes,
371
- * so an unchunked round of a heavily split account would breach GitHub's 500k node ceiling
372
- * and lose the ranges that had already resolved along with it.
373
- */
374
- const MAX_RANGES_PER_REQUEST = 100;
375
-
376
- const REPOS_CONTRIBUTED_TO_ERROR =
377
- 'Something went wrong while trying to retrieve the repository contributions data using the GraphQL API.';
378
-
379
- /**
380
- * Count the repositories a user contributed to across every contribution year.
381
- *
382
- * `repositoriesContributedTo` spans at most one year,
383
- * so every year is fetched as an aliased `contributionsCollection(from, to)` in one request and the repos de-duplicated.
384
- * A range returning `MAX_REPOSITORIES_LIMIT` results may have more,
385
- * so it is halved and requeried in the next round.
386
- *
387
- * Whether private contributions are included depends on the used PAT.
388
- *
389
- * @returns Count of repositories.
390
- */
391
- const fetchAllTimeReposContributedTo = async (
392
- /** `nameWithOwner` uses GitHub's casing, which the query-string username need not match */
393
- canonicalUsername: string,
394
- years: Array<number>,
395
- includeOwnRepos: boolean,
396
- config: CardConfig,
397
- ): Promise<number> => {
398
- const repos = new Set<string>();
399
- let pending = years.map((year) => getGitHubYearRange(year));
400
-
401
- while (pending.length > 0) {
402
- const nextPending: Array<GitHubDateRange> = [];
403
-
404
- for (const chunk of chunkArray(pending, MAX_RANGES_PER_REQUEST)) {
405
- const chunkFetcher = createGraphQLFetcher(
406
- buildReposContributedToDocument(chunk, includeOwnRepos),
407
- 'bearer',
408
- );
409
- const res = await retryer(
410
- chunkFetcher,
411
- { login: canonicalUsername, maxRepositories: MAX_REPOSITORIES_LIMIT },
412
- config,
413
- );
414
- if (res.data.errors) {
415
- throw graphqlError(res.data.errors, res.statusText, REPOS_CONTRIBUTED_TO_ERROR);
416
- }
417
- const { user } = res.data.data;
418
- if (!user) {
419
- throw new CardError(REPOS_CONTRIBUTED_TO_ERROR, { code: 'upstream' });
420
- }
421
-
422
- for (const [index, range] of chunk.entries()) {
423
- const rangeResponse = user[`range_${index}`];
424
- if (!rangeResponse) {
425
- throw new CardError(REPOS_CONTRIBUTED_TO_ERROR, {
426
- code: 'upstream',
427
- });
428
- }
429
-
430
- const lists = [
431
- rangeResponse.commitContributionsByRepository,
432
- rangeResponse.issueContributionsByRepository,
433
- rangeResponse.pullRequestContributionsByRepository,
434
- (rangeResponse.repositoryContributions?.nodes ?? []).filter((node) => node !== null),
435
- ];
436
- const isSaturated = lists.some((list) => list.length >= MAX_REPOSITORIES_LIMIT);
437
-
438
- const rangeDays = Math.round((range.to.getTime() - range.from.getTime()) / MS_PER_DAY);
439
- // a range of 1 day or less can't be split any further
440
- if (isSaturated && rangeDays >= 2) {
441
- // every `from` sits on UTC midnight, so the split lands on a day boundary too
442
- const mid = new Date(range.from.getTime() + Math.floor(rangeDays / 2) * MS_PER_DAY);
443
- // GitHub only reads the date portion,
444
- // so the first half ends 1 second before `mid` to keep the halves from sharing a day
445
- nextPending.push(
446
- { from: range.from, to: new Date(mid.getTime() - 1000) },
447
- { from: mid, to: range.to },
448
- );
449
- continue;
450
- }
451
- if (isSaturated) {
452
- logger.log(
453
- `Range ${range.from.toISOString()} - ${range.to.toISOString()} is saturated but cannot be split further.`,
454
- );
455
- }
456
-
457
- for (const { repository } of lists.flat()) {
458
- const name = repository.nameWithOwner;
459
- if (includeOwnRepos || !name.startsWith(`${canonicalUsername}/`)) {
460
- repos.add(name);
461
- }
462
- }
463
- }
464
- }
465
-
466
- // each saturated range pushes both of its halves
467
- const saturatedCount = nextPending.length / 2;
468
- if (saturatedCount > 0) {
469
- logger.log(`found ${saturatedCount} saturated ranges, splitting and retrying...`);
470
- }
471
- pending = nextPending;
472
- }
473
-
474
- return repos.size;
475
- };
476
-
477
315
  /**
478
316
  * Fetch stats for a given username.
479
317
  *
@@ -487,7 +325,8 @@ const fetchStats = async (
487
325
  include_merged_pull_requests = false,
488
326
  include_discussions = false,
489
327
  include_discussions_answers = false,
490
- commits_year,
328
+ from,
329
+ to,
491
330
  repo = [],
492
331
  owner = [],
493
332
  include_prs_authored = false,
@@ -506,7 +345,10 @@ const fetchStats = async (
506
345
  include_merged_pull_requests?: boolean;
507
346
  include_discussions?: boolean;
508
347
  include_discussions_answers?: boolean;
509
- commits_year?: number | undefined;
348
+ /** Start of the range the commit count covers; GitHub's own last year when neither end is given. */
349
+ from?: Date | undefined;
350
+ /** End of that range. */
351
+ to?: Date | undefined;
510
352
  repo?: Array<string>;
511
353
  owner?: Array<string>;
512
354
  include_prs_authored?: boolean;
@@ -532,6 +374,7 @@ const fetchStats = async (
532
374
  mergedPRsPercentage: 0,
533
375
  totalReviews: 0,
534
376
  totalCommits: 0,
377
+ commitsRange: undefined,
535
378
  totalIssues: 0,
536
379
  totalStars: 0,
537
380
  totalDiscussionsStarted: 0,
@@ -548,41 +391,30 @@ const fetchStats = async (
548
391
  };
549
392
  const affiliations = parseOwnerAffiliations(ownerAffiliations);
550
393
 
394
+ // neither end given leaves GitHub's trailing year in place, which `userInfo` already carries
395
+ const commitsRange = from === undefined && to === undefined ? undefined : toRange(from, to);
396
+ const commitsYearRanges = commitsRange ? toYearRanges(commitsRange) : [];
397
+ // one calendar year is a range `userInfo` can ask for itself; anything else is summed separately
398
+ const inlineRange = commitsYearRanges.length === 1 ? commitsYearRanges[0] : undefined;
399
+
551
400
  const res = await statsFetcher(
552
401
  {
553
402
  username,
554
403
  includeMergedPullRequests: include_merged_pull_requests,
555
404
  includeDiscussions: include_discussions,
556
405
  includeDiscussionsAnswers: include_discussions_answers,
557
- startTime:
558
- commits_year === undefined
559
- ? undefined
560
- : toGitHubDateTime(getGitHubYearRange(commits_year).from),
406
+ commitsRange: inlineRange,
561
407
  ownerAffiliations: affiliations,
562
408
  includeUserRepositories: contribs_include_own_repos,
563
409
  },
564
410
  config,
565
411
  );
566
412
 
567
- // Catch GraphQL errors.
568
413
  if (res.data.errors) {
569
- logger.error(res.data.errors);
570
- const [firstError] = res.data.errors;
571
- if (firstError?.type === 'NOT_FOUND') {
572
- throw new CardError(firstError.message || 'Could not fetch user.', {
573
- code: 'not_found',
574
- secondaryMessage: USER_NOT_FOUND,
575
- });
576
- }
577
- if (firstError?.message) {
578
- throw new CardError(wrapTextMultiline(firstError.message, 525, 12)[0] ?? '', {
579
- code: 'upstream',
580
- secondaryMessage: res.statusText,
581
- });
582
- }
583
- throw new CardError(
414
+ throw graphqlError(
415
+ res.data.errors,
416
+ res.statusText,
584
417
  'Something went wrong while trying to retrieve the stats data using the GraphQL API.',
585
- { code: 'upstream' },
586
418
  );
587
419
  }
588
420
 
@@ -609,7 +441,17 @@ const fetchStats = async (
609
441
  config,
610
442
  );
611
443
  } else {
612
- stats.totalCommits = user.commits.totalCommitContributions;
444
+ stats.commitsRange = commitsRange;
445
+ // an inverted range sums no years, which is no commits rather than GitHub's default year
446
+ stats.totalCommits =
447
+ commitsRange && !inlineRange
448
+ ? await sumOverRanges(
449
+ username,
450
+ commitsYearRanges,
451
+ (block) => block.totalCommitContributions,
452
+ config,
453
+ )
454
+ : user.commits.totalCommitContributions;
613
455
  }
614
456
  const repoUserStats = await fetchRepoUserStats(
615
457
  {
@@ -642,21 +484,28 @@ const fetchStats = async (
642
484
  }
643
485
  stats.contributedTo = user.repositoriesContributedTo.totalCount;
644
486
 
487
+ const contributionRanges = toContributionRanges(
488
+ user.contributionsCollection.contributionYears,
489
+ getWidestRange(),
490
+ );
491
+
645
492
  if (include_contributions) {
646
- stats.totalContributions = await fetchTotalContributions(
493
+ stats.totalContributions = await sumOverRanges(
647
494
  username,
648
- user.contributionsCollection.contributionYears,
495
+ contributionRanges,
496
+ (block) => block.contributionCalendar.totalContributions,
649
497
  config,
650
498
  );
651
499
  }
652
500
 
653
501
  if (include_all_time_contribs) {
654
- stats.allTimeContributedTo = await fetchAllTimeReposContributedTo(
502
+ const reposContributedTo = await fetchReposContributedTo(
655
503
  user.login,
656
- user.contributionsCollection.contributionYears,
504
+ contributionRanges,
657
505
  contribs_include_own_repos,
658
506
  config,
659
507
  );
508
+ stats.allTimeContributedTo = reposContributedTo.size;
660
509
  }
661
510
 
662
511
  // Retrieve stars while filtering out repositories to be hidden.
@@ -1,9 +1,7 @@
1
1
  import type { CardConfig } from '../common/config.ts';
2
- import { CardError, USER_NOT_FOUND } from '../common/error.ts';
2
+ import { CardError } from '../common/error.ts';
3
3
  import { createGraphQLFetcher } from '../common/http.ts';
4
- import { logger } from '../common/log.ts';
5
4
  import { parseOwnerAffiliations } from '../common/ops.ts';
6
- import { wrapTextMultiline } from '../common/render.ts';
7
5
  import { retryer } from '../common/retryer.ts';
8
6
  import { TopLanguagesDocument } from '../graphql/generated/top-languages.ts';
9
7
  import type {
@@ -11,6 +9,7 @@ import type {
11
9
  TopLanguagesRepositoryFragment,
12
10
  } from '../graphql/generated/top-languages.ts';
13
11
 
12
+ import { graphqlError } from './graphql-error.ts';
14
13
  import type { Lang, TopLangData } from './types.ts';
15
14
 
16
15
  const fetcher = createGraphQLFetcher(TopLanguagesDocument, 'token');
@@ -51,23 +50,10 @@ const fetchTopLanguages = async (
51
50
  );
52
51
 
53
52
  if (res.data.errors) {
54
- logger.error(res.data.errors);
55
- const [firstError] = res.data.errors;
56
- if (firstError?.type === 'NOT_FOUND') {
57
- throw new CardError(firstError.message || 'Could not fetch user.', {
58
- code: 'not_found',
59
- secondaryMessage: USER_NOT_FOUND,
60
- });
61
- }
62
- if (firstError?.message) {
63
- throw new CardError(wrapTextMultiline(firstError.message, 525, 12)[0] ?? '', {
64
- code: 'upstream',
65
- secondaryMessage: res.statusText,
66
- });
67
- }
68
- throw new CardError(
53
+ throw graphqlError(
54
+ res.data.errors,
55
+ res.statusText,
69
56
  'Something went wrong while trying to retrieve the language data using the GraphQL API.',
70
- { code: 'upstream' },
71
57
  );
72
58
  }
73
59
 
@@ -1,3 +1,4 @@
1
+ import type { GitHubDateRange } from '../common/date.ts';
1
2
  import type { RepoInfoFragment } from '../graphql/generated/repo.ts';
2
3
 
3
4
  export interface GistData {
@@ -39,6 +40,8 @@ export interface StatsData {
39
40
  mergedPRsPercentage: number;
40
41
  totalReviews: number;
41
42
  totalCommits: number;
43
+ /** The range `totalCommits` covers; `undefined` when it is GitHub's own last year, or all time. */
44
+ commitsRange: GitHubDateRange | undefined;
42
45
  totalIssues: number;
43
46
  totalStars: number;
44
47
  totalDiscussionsStarted: number;
@@ -54,6 +57,29 @@ export interface StatsData {
54
57
  rank: { level: string; percentile: number };
55
58
  }
56
59
 
60
+ /** One repository a user contributed to, as the card ranks it. */
61
+ export interface ContributedRepo {
62
+ nameWithOwner: string;
63
+ /**
64
+ * Commit-days plus issues and pull requests opened, summed over every year.
65
+ * Not a commit count: GitHub groups commit contributions by day, not by commit.
66
+ */
67
+ contributions: number;
68
+ /** Every year the user contributed to it, ascending. */
69
+ years: Array<number>;
70
+ }
71
+
72
+ export interface ContributedToData {
73
+ /** GitHub's own casing of the login, which the query param need not match. */
74
+ login: string;
75
+ /** The ranked slice, most contributions first. */
76
+ repos: Array<ContributedRepo>;
77
+ /** Every repository the walk found, which `repos` is a slice of. */
78
+ totalRepos: number;
79
+ /** The account's contribution years, ascending — the range the year marks cover. */
80
+ years: Array<number>;
81
+ }
82
+
57
83
  export interface Lang {
58
84
  name: string;
59
85
  // GitHub's GraphQL `Language.color` is nullable — the card falls back to a default.
@@ -0,0 +1,18 @@
1
+ import type { GitHubDateRange } from '../common/date.ts';
2
+ import { toGitHubDateTime } from '../common/date.ts';
3
+
4
+ /** Naming `to` is not optional: GitHub defaults an omitted one to a year after `from`. */
5
+ const contributionsCollectionOf = ({ from, to }: GitHubDateRange): string =>
6
+ `contributionsCollection(from: "${toGitHubDateTime(from)}", to: "${toGitHubDateTime(to)}")`;
7
+
8
+ /**
9
+ * One `contributionsCollection` field per range, aliased by position.
10
+ *
11
+ * @returns The fields, for a query that selects `fragment`.
12
+ */
13
+ const aliasedRanges = (ranges: Array<GitHubDateRange>, fragment: string): string =>
14
+ ranges
15
+ .map((range, index) => `range_${index}: ${contributionsCollectionOf(range)} { ...${fragment} }`)
16
+ .join('\n');
17
+
18
+ export { aliasedRanges };