@stats-forge/github-stats-forge-core 0.0.1

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 (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/build/api/api-result.d.ts +44 -0
  4. package/build/api/api-result.d.ts.map +1 -0
  5. package/build/api/api-result.js +33 -0
  6. package/build/api/gist.d.ts +29 -0
  7. package/build/api/gist.d.ts.map +1 -0
  8. package/build/api/gist.js +52 -0
  9. package/build/api/index.d.ts +13 -0
  10. package/build/api/index.d.ts.map +1 -0
  11. package/build/api/index.js +8 -0
  12. package/build/api/params.d.ts +93 -0
  13. package/build/api/params.d.ts.map +1 -0
  14. package/build/api/params.js +145 -0
  15. package/build/api/pin.d.ts +47 -0
  16. package/build/api/pin.d.ts.map +1 -0
  17. package/build/api/pin.js +87 -0
  18. package/build/api/stats.d.ts +42 -0
  19. package/build/api/stats.d.ts.map +1 -0
  20. package/build/api/stats.js +132 -0
  21. package/build/api/top-langs.d.ts +37 -0
  22. package/build/api/top-langs.d.ts.map +1 -0
  23. package/build/api/top-langs.js +101 -0
  24. package/build/api/wakatime.d.ts +34 -0
  25. package/build/api/wakatime.d.ts.map +1 -0
  26. package/build/api/wakatime.js +91 -0
  27. package/build/calculateRank.d.ts +29 -0
  28. package/build/calculateRank.d.ts.map +1 -0
  29. package/build/calculateRank.js +58 -0
  30. package/build/cards/gist.d.ts +16 -0
  31. package/build/cards/gist.d.ts.map +1 -0
  32. package/build/cards/gist.js +119 -0
  33. package/build/cards/index.d.ts +11 -0
  34. package/build/cards/index.d.ts.map +1 -0
  35. package/build/cards/index.js +7 -0
  36. package/build/cards/options.d.ts +19 -0
  37. package/build/cards/options.d.ts.map +1 -0
  38. package/build/cards/options.js +1 -0
  39. package/build/cards/repo.d.ts +25 -0
  40. package/build/cards/repo.d.ts.map +1 -0
  41. package/build/cards/repo.js +276 -0
  42. package/build/cards/stats.d.ts +37 -0
  43. package/build/cards/stats.d.ts.map +1 -0
  44. package/build/cards/stats.js +457 -0
  45. package/build/cards/top-languages.d.ts +153 -0
  46. package/build/cards/top-languages.d.ts.map +1 -0
  47. package/build/cards/top-languages.js +805 -0
  48. package/build/cards/wakatime.d.ts +31 -0
  49. package/build/cards/wakatime.d.ts.map +1 -0
  50. package/build/cards/wakatime.js +376 -0
  51. package/build/common/Card.d.ts +121 -0
  52. package/build/common/Card.d.ts.map +1 -0
  53. package/build/common/Card.js +305 -0
  54. package/build/common/I18n.d.ts +27 -0
  55. package/build/common/I18n.d.ts.map +1 -0
  56. package/build/common/I18n.js +35 -0
  57. package/build/common/color.d.ts +112 -0
  58. package/build/common/color.d.ts.map +1 -0
  59. package/build/common/color.js +188 -0
  60. package/build/common/config.d.ts +53 -0
  61. package/build/common/config.d.ts.map +1 -0
  62. package/build/common/config.js +87 -0
  63. package/build/common/constants.d.ts +7 -0
  64. package/build/common/constants.d.ts.map +1 -0
  65. package/build/common/constants.js +9 -0
  66. package/build/common/date.d.ts +29 -0
  67. package/build/common/date.d.ts.map +1 -0
  68. package/build/common/date.js +23 -0
  69. package/build/common/error.d.ts +87 -0
  70. package/build/common/error.d.ts.map +1 -0
  71. package/build/common/error.js +108 -0
  72. package/build/common/fmt.d.ts +28 -0
  73. package/build/common/fmt.d.ts.map +1 -0
  74. package/build/common/fmt.js +68 -0
  75. package/build/common/html.d.ts +8 -0
  76. package/build/common/html.d.ts.map +1 -0
  77. package/build/common/html.js +14 -0
  78. package/build/common/http.d.ts +53 -0
  79. package/build/common/http.d.ts.map +1 -0
  80. package/build/common/http.js +47 -0
  81. package/build/common/icons.d.ts +28 -0
  82. package/build/common/icons.d.ts.map +1 -0
  83. package/build/common/icons.js +77 -0
  84. package/build/common/languageColors.d.ts +11 -0
  85. package/build/common/languageColors.d.ts.map +1 -0
  86. package/build/common/languageColors.js +15 -0
  87. package/build/common/languageColors.json +678 -0
  88. package/build/common/log.d.ts +9 -0
  89. package/build/common/log.d.ts.map +1 -0
  90. package/build/common/log.js +8 -0
  91. package/build/common/ops.d.ts +59 -0
  92. package/build/common/ops.d.ts.map +1 -0
  93. package/build/common/ops.js +115 -0
  94. package/build/common/render.d.ts +196 -0
  95. package/build/common/render.d.ts.map +1 -0
  96. package/build/common/render.js +474 -0
  97. package/build/common/retryer.d.ts +36 -0
  98. package/build/common/retryer.d.ts.map +1 -0
  99. package/build/common/retryer.js +67 -0
  100. package/build/fetchers/gist.d.ts +15 -0
  101. package/build/fetchers/gist.d.ts.map +1 -0
  102. package/build/fetchers/gist.js +68 -0
  103. package/build/fetchers/index.d.ts +13 -0
  104. package/build/fetchers/index.d.ts.map +1 -0
  105. package/build/fetchers/index.js +10 -0
  106. package/build/fetchers/repo.d.ts +27 -0
  107. package/build/fetchers/repo.d.ts.map +1 -0
  108. package/build/fetchers/repo.js +100 -0
  109. package/build/fetchers/stats.d.ts +74 -0
  110. package/build/fetchers/stats.d.ts.map +1 -0
  111. package/build/fetchers/stats.js +498 -0
  112. package/build/fetchers/top-languages.d.ts +23 -0
  113. package/build/fetchers/top-languages.d.ts.map +1 -0
  114. package/build/fetchers/top-languages.js +89 -0
  115. package/build/fetchers/types.d.ts +130 -0
  116. package/build/fetchers/types.d.ts.map +1 -0
  117. package/build/fetchers/types.js +1 -0
  118. package/build/fetchers/wakatime.d.ts +19 -0
  119. package/build/fetchers/wakatime.d.ts.map +1 -0
  120. package/build/fetchers/wakatime.js +36 -0
  121. package/build/graphql/contributionsDocument.d.ts +18 -0
  122. package/build/graphql/contributionsDocument.d.ts.map +1 -0
  123. package/build/graphql/contributionsDocument.js +30 -0
  124. package/build/graphql/generated/common.d.ts +42 -0
  125. package/build/graphql/generated/common.d.ts.map +1 -0
  126. package/build/graphql/generated/common.js +2 -0
  127. package/build/graphql/generated/gist.d.ts +44 -0
  128. package/build/graphql/generated/gist.d.ts.map +1 -0
  129. package/build/graphql/generated/gist.js +27 -0
  130. package/build/graphql/generated/repo.d.ts +67 -0
  131. package/build/graphql/generated/repo.d.ts.map +1 -0
  132. package/build/graphql/generated/repo.js +30 -0
  133. package/build/graphql/generated/stats.d.ts +150 -0
  134. package/build/graphql/generated/stats.d.ts.map +1 -0
  135. package/build/graphql/generated/stats.js +95 -0
  136. package/build/graphql/generated/top-languages.d.ts +54 -0
  137. package/build/graphql/generated/top-languages.d.ts.map +1 -0
  138. package/build/graphql/generated/top-languages.js +27 -0
  139. package/build/graphql/graphqlDocument.d.ts +22 -0
  140. package/build/graphql/graphqlDocument.d.ts.map +1 -0
  141. package/build/graphql/graphqlDocument.js +8 -0
  142. package/build/graphql/reposContributedToDocument.d.ts +27 -0
  143. package/build/graphql/reposContributedToDocument.d.ts.map +1 -0
  144. package/build/graphql/reposContributedToDocument.js +59 -0
  145. package/build/index.d.ts +15 -0
  146. package/build/index.d.ts.map +1 -0
  147. package/build/index.js +10 -0
  148. package/build/themes/index.d.ts +505 -0
  149. package/build/themes/index.d.ts.map +1 -0
  150. package/build/themes/index.js +505 -0
  151. package/build/translations.d.ts +1153 -0
  152. package/build/translations.d.ts.map +1 -0
  153. package/build/translations.js +1156 -0
  154. package/package.json +114 -0
  155. package/src/_emoji-name-map.d.ts +10 -0
  156. package/src/_github-username-regex.d.ts +4 -0
  157. package/src/api/api-result.ts +69 -0
  158. package/src/api/gist.ts +66 -0
  159. package/src/api/index.ts +16 -0
  160. package/src/api/params.ts +221 -0
  161. package/src/api/pin.ts +126 -0
  162. package/src/api/stats.ts +195 -0
  163. package/src/api/top-langs.ts +150 -0
  164. package/src/api/wakatime.ts +130 -0
  165. package/src/calculateRank.ts +91 -0
  166. package/src/cards/gist.ts +169 -0
  167. package/src/cards/index.ts +21 -0
  168. package/src/cards/options.ts +18 -0
  169. package/src/cards/repo.ts +376 -0
  170. package/src/cards/stats.ts +615 -0
  171. package/src/cards/top-languages.ts +1077 -0
  172. package/src/cards/wakatime.ts +501 -0
  173. package/src/common/Card.ts +364 -0
  174. package/src/common/I18n.ts +51 -0
  175. package/src/common/color.ts +296 -0
  176. package/src/common/config.ts +118 -0
  177. package/src/common/constants.ts +12 -0
  178. package/src/common/date.ts +34 -0
  179. package/src/common/error.ts +163 -0
  180. package/src/common/fmt.ts +86 -0
  181. package/src/common/html.ts +17 -0
  182. package/src/common/http.ts +98 -0
  183. package/src/common/icons.ts +80 -0
  184. package/src/common/languageColors.json +678 -0
  185. package/src/common/languageColors.ts +19 -0
  186. package/src/common/log.ts +12 -0
  187. package/src/common/ops.ts +145 -0
  188. package/src/common/render.ts +641 -0
  189. package/src/common/retryer.ts +111 -0
  190. package/src/fetchers/gist.ts +82 -0
  191. package/src/fetchers/index.ts +25 -0
  192. package/src/fetchers/repo.ts +140 -0
  193. package/src/fetchers/stats.ts +742 -0
  194. package/src/fetchers/top-languages.ts +134 -0
  195. package/src/fetchers/types.ts +136 -0
  196. package/src/fetchers/wakatime.ts +56 -0
  197. package/src/graphql/contributionsDocument.ts +44 -0
  198. package/src/graphql/generated/common.ts +26 -0
  199. package/src/graphql/generated/gist.ts +56 -0
  200. package/src/graphql/generated/repo.ts +79 -0
  201. package/src/graphql/generated/stats.ts +180 -0
  202. package/src/graphql/generated/top-languages.ts +66 -0
  203. package/src/graphql/graphqlDocument.ts +25 -0
  204. package/src/graphql/queries/gist.graphql +25 -0
  205. package/src/graphql/queries/repo.graphql +28 -0
  206. package/src/graphql/queries/stats.graphql +113 -0
  207. package/src/graphql/queries/top-languages.graphql +27 -0
  208. package/src/graphql/reposContributedToDocument.ts +83 -0
  209. package/src/index.ts +18 -0
  210. package/src/themes/index.ts +519 -0
  211. package/src/translations.ts +1169 -0
@@ -0,0 +1,498 @@
1
+ import githubUsernameRegex from 'github-username-regex';
2
+ import { calculateRank } from '../calculateRank.js';
3
+ import { getGitHubYearRange, toGitHubDateTime } from '../common/date.js';
4
+ import { CardError, USER_NOT_FOUND } from '../common/error.js';
5
+ import { wrapTextMultiline } from '../common/fmt.js';
6
+ import { createGraphQLFetcher, httpRequest } from '../common/http.js';
7
+ import { logger } from '../common/log.js';
8
+ import { buildSearchFilter, chunkArray, parseOwnerAffiliations } from '../common/ops.js';
9
+ import { retryer } from '../common/retryer.js';
10
+ import { buildContributionsDocument } from '../graphql/contributionsDocument.js';
11
+ import { UserInfoDocument, UserReposDocument } from '../graphql/generated/stats.js';
12
+ import { MAX_REPOSITORIES_LIMIT, buildReposContributedToDocument, } from '../graphql/reposContributedToDocument.js';
13
+ const fetcher = createGraphQLFetcher(UserInfoDocument, 'bearer');
14
+ /** Fetcher for the pages after the first, which only need `repositories`. */
15
+ const reposFetcher = createGraphQLFetcher(UserReposDocument, 'bearer');
16
+ /**
17
+ * Fetch stats information for a given username.
18
+ *
19
+ * @param variables Fetcher variables.
20
+ * @param variables.username GitHub username.
21
+ * @param variables.includeMergedPullRequests Include merged pull requests.
22
+ * @param variables.includeDiscussions Include discussions.
23
+ * @param variables.includeDiscussionsAnswers Include discussions answers.
24
+ * @param variables.startTime Time to start the count of total commits.
25
+ * @param variables.ownerAffiliations The owner affiliations to filter by. Default: OWNER.
26
+ * @param variables.includeUserRepositories Whether to include the user's own repositories in the repos contributed to.
27
+ * @param config Deployment config supplying the PAT pool.
28
+ * @returns The stats response, with every fetched page's repos merged in.
29
+ *
30
+ * @description Supports multi-page fetching when the `FETCH_MULTI_PAGE_STARS`
31
+ * env variable is `true` or a fetch limit.
32
+ */
33
+ const statsFetcher = async ({ username, includeMergedPullRequests, includeDiscussions, includeDiscussionsAnswers, startTime, ownerAffiliations, includeUserRepositories, }, config) => {
34
+ // only the first request carries the stats themselves
35
+ let stats = await retryer(fetcher, {
36
+ login: username,
37
+ after: null,
38
+ includeMergedPullRequests,
39
+ includeDiscussions,
40
+ includeDiscussionsAnswers,
41
+ startTime,
42
+ ownerAffiliations,
43
+ includeUserRepositories,
44
+ }, config);
45
+ if (stats.data.errors) {
46
+ return stats;
47
+ }
48
+ const pageLimit = config.fetchMultiPageStars;
49
+ const extraRepoNodes = [];
50
+ let pageRepositories = stats.data.data.user?.repositories;
51
+ let previousCursor = null;
52
+ let fetchedPages = 1;
53
+ while (fetchedPages < pageLimit &&
54
+ // an unstarred repo on the page means the starred ones are exhausted
55
+ !pageRepositories?.nodes?.some((node) => node?.stargazerCount === 0) &&
56
+ pageRepositories?.pageInfo.hasNextPage) {
57
+ const after = pageRepositories.pageInfo.endCursor;
58
+ // a null or non-advancing cursor would refetch the same page forever
59
+ if (after === null || after === previousCursor) {
60
+ break;
61
+ }
62
+ previousCursor = after;
63
+ const page = await retryer(reposFetcher, { login: username, after, ownerAffiliations }, config);
64
+ if (page.data.errors) {
65
+ return {
66
+ data: { ...stats.data, errors: page.data.errors },
67
+ statusText: page.statusText,
68
+ };
69
+ }
70
+ pageRepositories = page.data.data.user?.repositories;
71
+ extraRepoNodes.push(...(pageRepositories?.nodes ?? []));
72
+ fetchedPages++;
73
+ }
74
+ if (extraRepoNodes.length > 0) {
75
+ // deep copy to avoid mutating the response cached by the frontend
76
+ stats = structuredClone({
77
+ data: stats.data,
78
+ statusText: stats.statusText,
79
+ });
80
+ stats.data.data.user?.repositories.nodes?.push(...extraRepoNodes);
81
+ }
82
+ return stats;
83
+ };
84
+ /**
85
+ * Fetch total items count using the REST search API.
86
+ *
87
+ * @param variables Fetcher variables.
88
+ * @param token GitHub token.
89
+ * @param context What the retryer supplies each attempt.
90
+ * @param context.fetch Transport to send the request with.
91
+ * @returns The search response, carrying `total_count`.
92
+ *
93
+ * @see https://developer.github.com/v3/search/#search-commits
94
+ */
95
+ const fetchTotalItems = (variables, token, { fetch }) => {
96
+ const type = String(variables['type']);
97
+ const filter = String(variables['filter']);
98
+ const repo = variables['repo'];
99
+ const owner = variables['owner'];
100
+ return httpRequest(fetch, `https://api.github.com/search/${type}?per_page=1&q=` +
101
+ buildSearchFilter(repo, owner).replaceAll(' ', '+') +
102
+ filter, {
103
+ method: 'GET',
104
+ headers: {
105
+ 'Content-Type': 'application/json',
106
+ Accept: 'application/vnd.github.cloak-preview',
107
+ Authorization: `token ${token}`,
108
+ },
109
+ });
110
+ };
111
+ /**
112
+ * Fetch a total count for a given username via the REST search API.
113
+ *
114
+ * @param props Fetcher props.
115
+ * @param props.username GitHub username.
116
+ * @param props.repo Repositories the search is scoped to.
117
+ * @param props.owner Owners the search is scoped to.
118
+ * @param props.type Search endpoint to hit, `issues` or `commits`.
119
+ * @param props.filter Query fragment appended to the scope filter.
120
+ * @param config Deployment config supplying the PAT pool.
121
+ * @returns Total count.
122
+ *
123
+ * The GraphQL API can't return this.
124
+ * @see https://github.com/anuraghazra/github-readme-stats/issues/92#issuecomment-661026467
125
+ * @see https://github.com/anuraghazra/github-readme-stats/pull/211
126
+ */
127
+ const totalItemsFetcher = async ({ username, repo, owner, type, filter, }, config) => {
128
+ if (!githubUsernameRegex.test(username)) {
129
+ logger.log('Invalid username provided.');
130
+ throw new CardError('Invalid username provided.', {
131
+ code: 'invalid_param',
132
+ param: 'username',
133
+ });
134
+ }
135
+ let res;
136
+ try {
137
+ res = await retryer(fetchTotalItems, { login: username, repo, owner, type, filter }, config);
138
+ }
139
+ catch (err) {
140
+ logger.log(err);
141
+ throw err;
142
+ }
143
+ const totalCount = res.data.total_count;
144
+ if (totalCount === undefined || isNaN(totalCount)) {
145
+ logger.error('GitHub error: ' + JSON.stringify(res.data));
146
+ throw new CardError('Could not fetch data from GitHub REST API.', {
147
+ code: 'upstream',
148
+ });
149
+ }
150
+ return totalCount;
151
+ };
152
+ /**
153
+ * Fetch the per-repository counts the REST search API answers, one request each.
154
+ *
155
+ * @param props Fetcher props.
156
+ * @param props.username GitHub username.
157
+ * @param props.repo Repositories the search is scoped to.
158
+ * @param props.owner Owners the search is scoped to.
159
+ * @param props.include_prs_authored Include count of PRs authored.
160
+ * @param props.include_prs_commented Include count of PRs commented.
161
+ * @param props.include_prs_reviewed Include count of PRs reviewed.
162
+ * @param props.include_issues_authored Include count of issues authored.
163
+ * @param props.include_issues_commented Include count of issues commented.
164
+ * @param config Deployment config supplying the PAT pool.
165
+ * @returns Only the counts that were asked for.
166
+ */
167
+ const fetchRepoUserStats = async ({ username, repo = [], owner = [], include_prs_authored = false, include_prs_commented = false, include_prs_reviewed = false, include_issues_authored = false, include_issues_commented = false, }, config) => {
168
+ const stats = {};
169
+ if (include_prs_authored) {
170
+ stats.totalPRsAuthored = await totalItemsFetcher({
171
+ username,
172
+ repo,
173
+ owner,
174
+ type: 'issues',
175
+ filter: `author:${username}+type:pr`,
176
+ }, config);
177
+ }
178
+ if (include_prs_commented) {
179
+ stats.totalPRsCommented = await totalItemsFetcher({
180
+ username,
181
+ repo,
182
+ owner,
183
+ type: 'issues',
184
+ filter: `commenter:${username}+-author:${username}+type:pr`,
185
+ }, config);
186
+ }
187
+ if (include_prs_reviewed) {
188
+ stats.totalPRsReviewed = await totalItemsFetcher({
189
+ username,
190
+ repo,
191
+ owner,
192
+ type: 'issues',
193
+ filter: `reviewed-by:${username}+-author:${username}+type:pr`,
194
+ }, config);
195
+ }
196
+ if (include_issues_authored) {
197
+ stats.totalIssuesAuthored = await totalItemsFetcher({
198
+ username,
199
+ repo,
200
+ owner,
201
+ type: 'issues',
202
+ filter: `author:${username}+type:issue`,
203
+ }, config);
204
+ }
205
+ if (include_issues_commented) {
206
+ stats.totalIssuesCommented = await totalItemsFetcher({
207
+ username,
208
+ repo,
209
+ owner,
210
+ type: 'issues',
211
+ filter: `commenter:${username}+-author:${username}+type:issue`,
212
+ }, config);
213
+ }
214
+ return stats;
215
+ };
216
+ /**
217
+ * Turn a GraphQL `errors` payload into the error to throw.
218
+ *
219
+ * @param errors Errors from the response envelope.
220
+ * @param statusText HTTP status text, used as the error type when GitHub gave a message.
221
+ * @param fallback Message when GitHub gave none.
222
+ */
223
+ const graphqlError = (errors, statusText, fallback) => {
224
+ logger.error(errors);
225
+ const message = errors[0]?.message;
226
+ return message
227
+ ? new CardError(wrapTextMultiline(message, 525, 12)[0] ?? '', {
228
+ code: 'upstream',
229
+ secondaryMessage: statusText,
230
+ })
231
+ : new CardError(fallback, { code: 'upstream' });
232
+ };
233
+ /**
234
+ * Fetch all-time contributions by building a single GraphQL query
235
+ * for all the given years.
236
+ *
237
+ * Whether private contributions are included depends on the user's profile settings:
238
+ * 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
239
+ */
240
+ const fetchTotalContributions = async (username, years, config) => {
241
+ if (years.length === 0) {
242
+ return 0;
243
+ }
244
+ const contributionsFetcher = createGraphQLFetcher(buildContributionsDocument(years), 'bearer');
245
+ const contribRes = await retryer(contributionsFetcher, { login: username }, config);
246
+ if (contribRes.data.errors) {
247
+ throw graphqlError(contribRes.data.errors, contribRes.statusText, 'Something went wrong while trying to retrieve the contributions data using the GraphQL API.');
248
+ }
249
+ const user = contribRes.data.data.user;
250
+ if (!user) {
251
+ return 0;
252
+ }
253
+ let total = 0;
254
+ for (const year of years) {
255
+ const yearBlock = user[`year_${year}`];
256
+ if (yearBlock?.contributionCalendar.totalContributions) {
257
+ total += yearBlock.contributionCalendar.totalContributions;
258
+ }
259
+ }
260
+ return total;
261
+ };
262
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
263
+ /**
264
+ * Ranges per request.
265
+ * Each costs roughly `4 * MAX_REPOSITORIES_LIMIT` nodes,
266
+ * so an unchunked round of a heavily split account would breach GitHub's 500k node ceiling
267
+ * and lose the ranges that had already resolved along with it.
268
+ */
269
+ const MAX_RANGES_PER_REQUEST = 100;
270
+ const REPOS_CONTRIBUTED_TO_ERROR = 'Something went wrong while trying to retrieve the repository contributions data using the GraphQL API.';
271
+ /**
272
+ * Count the repositories a user contributed to across every contribution year.
273
+ *
274
+ * `repositoriesContributedTo` spans at most one year,
275
+ * so every year is fetched as an aliased `contributionsCollection(from, to)` in one request and the repos de-duplicated.
276
+ * A range returning `MAX_REPOSITORIES_LIMIT` results may have more,
277
+ * so it is halved and requeried in the next round.
278
+ *
279
+ * Whether private contributions are included depends on the used PAT.
280
+ *
281
+ * @param canonicalUsername Username as GitHub reports it; `nameWithOwner` uses its
282
+ * casing, which the raw query-string username need not match.
283
+ * @param years Contribution years to walk.
284
+ * @param includeOwnRepos Whether to count the user's own repositories.
285
+ * @param config Deployment config supplying the PAT pool.
286
+ * @returns Count of repositories.
287
+ */
288
+ const fetchAllTimeReposContributedTo = async (canonicalUsername, years, includeOwnRepos, config) => {
289
+ const repos = new Set();
290
+ let pending = years.map(getGitHubYearRange);
291
+ while (pending.length > 0) {
292
+ const nextPending = [];
293
+ for (const chunk of chunkArray(pending, MAX_RANGES_PER_REQUEST)) {
294
+ const fetcher = createGraphQLFetcher(buildReposContributedToDocument(chunk, includeOwnRepos), 'bearer');
295
+ const res = await retryer(fetcher, { login: canonicalUsername, maxRepositories: MAX_REPOSITORIES_LIMIT }, config);
296
+ if (res.data.errors) {
297
+ throw graphqlError(res.data.errors, res.statusText, REPOS_CONTRIBUTED_TO_ERROR);
298
+ }
299
+ const user = res.data.data.user;
300
+ if (!user) {
301
+ throw new CardError(REPOS_CONTRIBUTED_TO_ERROR, { code: 'upstream' });
302
+ }
303
+ for (const [index, range] of chunk.entries()) {
304
+ const rangeResponse = user[`range_${index}`];
305
+ if (!rangeResponse) {
306
+ throw new CardError(REPOS_CONTRIBUTED_TO_ERROR, {
307
+ code: 'upstream',
308
+ });
309
+ }
310
+ const lists = [
311
+ rangeResponse.commitContributionsByRepository,
312
+ rangeResponse.issueContributionsByRepository,
313
+ rangeResponse.pullRequestContributionsByRepository,
314
+ (rangeResponse.repositoryContributions?.nodes ?? []).filter((node) => node !== null),
315
+ ];
316
+ const isSaturated = lists.some((list) => list.length >= MAX_REPOSITORIES_LIMIT);
317
+ const rangeDays = Math.round((range.to.getTime() - range.from.getTime()) / MS_PER_DAY);
318
+ // a range of 1 day or less can't be split any further
319
+ if (isSaturated && rangeDays >= 2) {
320
+ // every `from` sits on UTC midnight, so the split lands on a day boundary too
321
+ const mid = new Date(range.from.getTime() + Math.floor(rangeDays / 2) * MS_PER_DAY);
322
+ // GitHub only reads the date portion,
323
+ // so the first half ends 1 second before `mid` to keep the halves from sharing a day
324
+ nextPending.push({ from: range.from, to: new Date(mid.getTime() - 1000) }, { from: mid, to: range.to });
325
+ continue;
326
+ }
327
+ if (isSaturated) {
328
+ logger.log(`Range ${range.from.toISOString()} - ${range.to.toISOString()} is saturated but cannot be split further.`);
329
+ }
330
+ for (const { repository } of lists.flat()) {
331
+ const name = repository.nameWithOwner;
332
+ if (includeOwnRepos || !name.startsWith(`${canonicalUsername}/`)) {
333
+ repos.add(name);
334
+ }
335
+ }
336
+ }
337
+ }
338
+ // each saturated range pushes both of its halves
339
+ const saturatedCount = nextPending.length / 2;
340
+ if (saturatedCount > 0) {
341
+ logger.log(`found ${saturatedCount} saturated ranges, splitting and retrying...`);
342
+ }
343
+ pending = nextPending;
344
+ }
345
+ return repos.size;
346
+ };
347
+ /**
348
+ * Fetch stats for a given username.
349
+ *
350
+ * @param props Fetcher props.
351
+ * @param props.username GitHub username.
352
+ * @param props.include_all_commits Include all commits.
353
+ * @param props.exclude_repo Repositories to exclude.
354
+ * @param props.include_merged_pull_requests Include merged pull requests.
355
+ * @param props.include_discussions Include discussions.
356
+ * @param props.include_discussions_answers Include discussions answers.
357
+ * @param props.commits_year Year to count total commits.
358
+ * @param props.repo Repositories to scope the REST search to.
359
+ * @param props.owner Owners to scope the REST search to.
360
+ * @param props.include_prs_authored Include count of PRs authored.
361
+ * @param props.include_prs_commented Include count of PRs commented.
362
+ * @param props.include_prs_reviewed Include count of PRs reviewed.
363
+ * @param props.include_issues_authored Include count of issues authored.
364
+ * @param props.include_issues_commented Include count of issues commented.
365
+ * @param props.ownerAffiliations Owner affiliations. Default: OWNER.
366
+ * @param props.include_contributions Include all-time contributions.
367
+ * @param props.include_all_time_contribs Include all-time count of repos contributed to.
368
+ * @param props.contribs_include_own_repos Include user-owned repos in contributed-to counts.
369
+ * @param config Deployment config supplying the PAT pool.
370
+ * @returns Stats data.
371
+ */
372
+ const fetchStats = async ({ username, include_all_commits = false, exclude_repo = [], include_merged_pull_requests = false, include_discussions = false, include_discussions_answers = false, commits_year, repo = [], owner = [], include_prs_authored = false, include_prs_commented = false, include_prs_reviewed = false, include_issues_authored = false, include_issues_commented = false, ownerAffiliations = [], include_contributions = false, include_all_time_contribs = false, contribs_include_own_repos = false, }, config) => {
373
+ if (!username) {
374
+ throw CardError.missingParam(['username']);
375
+ }
376
+ const stats = {
377
+ name: '',
378
+ totalPRs: 0,
379
+ totalPRsMerged: 0,
380
+ mergedPRsPercentage: 0,
381
+ totalReviews: 0,
382
+ totalCommits: 0,
383
+ totalIssues: 0,
384
+ totalStars: 0,
385
+ totalDiscussionsStarted: 0,
386
+ totalDiscussionsAnswered: 0,
387
+ contributedTo: 0,
388
+ allTimeContributedTo: 0,
389
+ totalPRsAuthored: 0,
390
+ totalPRsCommented: 0,
391
+ totalPRsReviewed: 0,
392
+ totalIssuesAuthored: 0,
393
+ totalIssuesCommented: 0,
394
+ totalContributions: 0,
395
+ rank: { level: 'C', percentile: 100 },
396
+ };
397
+ const affiliations = parseOwnerAffiliations(ownerAffiliations);
398
+ const res = await statsFetcher({
399
+ username,
400
+ includeMergedPullRequests: include_merged_pull_requests,
401
+ includeDiscussions: include_discussions,
402
+ includeDiscussionsAnswers: include_discussions_answers,
403
+ startTime: commits_year === undefined
404
+ ? undefined
405
+ : toGitHubDateTime(getGitHubYearRange(commits_year).from),
406
+ ownerAffiliations: affiliations,
407
+ includeUserRepositories: contribs_include_own_repos,
408
+ }, config);
409
+ // Catch GraphQL errors.
410
+ if (res.data.errors) {
411
+ logger.error(res.data.errors);
412
+ const firstError = res.data.errors[0];
413
+ if (firstError?.type === 'NOT_FOUND') {
414
+ throw new CardError(firstError.message || 'Could not fetch user.', {
415
+ code: 'not_found',
416
+ secondaryMessage: USER_NOT_FOUND,
417
+ });
418
+ }
419
+ if (firstError?.message) {
420
+ throw new CardError(wrapTextMultiline(firstError.message, 525, 12)[0] ?? '', {
421
+ code: 'upstream',
422
+ secondaryMessage: res.statusText,
423
+ });
424
+ }
425
+ throw new CardError('Something went wrong while trying to retrieve the stats data using the GraphQL API.', { code: 'upstream' });
426
+ }
427
+ const user = res.data.data.user;
428
+ if (!user) {
429
+ throw new CardError('Could not fetch user.', {
430
+ code: 'not_found',
431
+ secondaryMessage: USER_NOT_FOUND,
432
+ });
433
+ }
434
+ stats.name = user.name || user.login;
435
+ // if include_all_commits, fetch all commits using the REST API.
436
+ if (include_all_commits) {
437
+ stats.totalCommits = await totalItemsFetcher({
438
+ username,
439
+ repo,
440
+ owner,
441
+ type: 'commits',
442
+ filter: `author:${username}`,
443
+ }, config);
444
+ }
445
+ else {
446
+ stats.totalCommits = user.commits.totalCommitContributions;
447
+ }
448
+ const repoUserStats = await fetchRepoUserStats({
449
+ username,
450
+ repo,
451
+ owner,
452
+ include_prs_authored,
453
+ include_prs_commented,
454
+ include_prs_reviewed,
455
+ include_issues_authored,
456
+ include_issues_commented,
457
+ }, config);
458
+ Object.assign(stats, repoUserStats);
459
+ stats.totalPRs = user.pullRequests.totalCount;
460
+ if (include_merged_pull_requests) {
461
+ const mergedCount = user.mergedPullRequests?.totalCount ?? 0;
462
+ stats.totalPRsMerged = mergedCount;
463
+ stats.mergedPRsPercentage = (mergedCount / user.pullRequests.totalCount) * 100 || 0;
464
+ }
465
+ stats.totalReviews = user.reviews.totalPullRequestReviewContributions;
466
+ stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount;
467
+ if (include_discussions) {
468
+ stats.totalDiscussionsStarted = user.repositoryDiscussions?.totalCount ?? 0;
469
+ }
470
+ if (include_discussions_answers) {
471
+ stats.totalDiscussionsAnswered = user.repositoryDiscussionComments?.totalCount ?? 0;
472
+ }
473
+ stats.contributedTo = user.repositoriesContributedTo.totalCount;
474
+ if (include_contributions) {
475
+ stats.totalContributions = await fetchTotalContributions(username, user.contributionsCollection.contributionYears, config);
476
+ }
477
+ if (include_all_time_contribs) {
478
+ stats.allTimeContributedTo = await fetchAllTimeReposContributedTo(user.login, user.contributionsCollection.contributionYears, contribs_include_own_repos, config);
479
+ }
480
+ // Retrieve stars while filtering out repositories to be hidden.
481
+ const allExcludedRepos = [...exclude_repo, ...config.excludeRepositories];
482
+ const repoToHide = new Set(allExcludedRepos);
483
+ stats.totalStars = (user.repositories.nodes ?? [])
484
+ .filter((data) => !!data && !repoToHide.has(data.name))
485
+ .reduce((prev, curr) => prev + (curr?.stargazerCount ?? 0), 0);
486
+ stats.rank = calculateRank({
487
+ all_commits: include_all_commits,
488
+ commits: stats.totalCommits,
489
+ prs: stats.totalPRs,
490
+ reviews: stats.totalReviews,
491
+ issues: stats.totalIssues,
492
+ repos: user.repositories.totalCount,
493
+ stars: stats.totalStars,
494
+ followers: user.followers.totalCount,
495
+ });
496
+ return stats;
497
+ };
498
+ export { fetchStats, fetchRepoUserStats };
@@ -0,0 +1,23 @@
1
+ import type { CardConfig } from '../common/config.js';
2
+ import type { TopLangData } from './types.js';
3
+ /**
4
+ * Fetch top languages for a given username.
5
+ *
6
+ * @param props Fetcher props.
7
+ * @param props.username GitHub username.
8
+ * @param props.exclude_repo List of repositories to exclude. Default: [].
9
+ * @param props.size_weight Weightage to be given to size.
10
+ * @param props.count_weight Weightage to be given to count.
11
+ * @param props.ownerAffiliations The owner affiliations to filter by. Default: OWNER.
12
+ * @param config Deployment config supplying the PAT pool.
13
+ * @returns Top languages data.
14
+ */
15
+ declare const fetchTopLanguages: ({ username, exclude_repo, size_weight, count_weight, ownerAffiliations, }: {
16
+ username: string | undefined;
17
+ exclude_repo?: Array<string>;
18
+ size_weight?: number | undefined;
19
+ count_weight?: number | undefined;
20
+ ownerAffiliations?: Array<string>;
21
+ }, config: CardConfig) => Promise<TopLangData>;
22
+ export { fetchTopLanguages };
23
+ //# sourceMappingURL=top-languages.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"top-languages.d.ts","sourceRoot":"","sources":["../../src/fetchers/top-languages.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAatD,OAAO,KAAK,EAAQ,WAAW,EAAE,MAAM,YAAY,CAAC;AAIpD;;;;;;;;;;;GAWG;AACH,QAAA,MAAM,iBAAiB,GACrB,2EAMG;IACD,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,YAAY,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAC7B,WAAW,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACjC,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,iBAAiB,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;CACnC,EACD,QAAQ,UAAU,KACjB,OAAO,CAAC,WAAW,CAuFrB,CAAC;AAEF,OAAO,EAAE,iBAAiB,EAAE,CAAC"}
@@ -0,0 +1,89 @@
1
+ import { CardError, USER_NOT_FOUND } from '../common/error.js';
2
+ import { wrapTextMultiline } from '../common/fmt.js';
3
+ import { createGraphQLFetcher } from '../common/http.js';
4
+ import { logger } from '../common/log.js';
5
+ import { parseOwnerAffiliations } from '../common/ops.js';
6
+ import { retryer } from '../common/retryer.js';
7
+ import { TopLanguagesDocument } from '../graphql/generated/top-languages.js';
8
+ const fetcher = createGraphQLFetcher(TopLanguagesDocument, 'token');
9
+ /**
10
+ * Fetch top languages for a given username.
11
+ *
12
+ * @param props Fetcher props.
13
+ * @param props.username GitHub username.
14
+ * @param props.exclude_repo List of repositories to exclude. Default: [].
15
+ * @param props.size_weight Weightage to be given to size.
16
+ * @param props.count_weight Weightage to be given to count.
17
+ * @param props.ownerAffiliations The owner affiliations to filter by. Default: OWNER.
18
+ * @param config Deployment config supplying the PAT pool.
19
+ * @returns Top languages data.
20
+ */
21
+ const fetchTopLanguages = async ({ username, exclude_repo = [], size_weight = 1, count_weight = 0, ownerAffiliations = [], }, config) => {
22
+ if (!username) {
23
+ throw CardError.missingParam(['username']);
24
+ }
25
+ const affiliations = parseOwnerAffiliations(ownerAffiliations);
26
+ const res = await retryer(fetcher, {
27
+ login: username,
28
+ ownerAffiliations: affiliations,
29
+ }, config);
30
+ if (res.data.errors) {
31
+ logger.error(res.data.errors);
32
+ const firstError = res.data.errors[0];
33
+ if (firstError?.type === 'NOT_FOUND') {
34
+ throw new CardError(firstError.message || 'Could not fetch user.', {
35
+ code: 'not_found',
36
+ secondaryMessage: USER_NOT_FOUND,
37
+ });
38
+ }
39
+ if (firstError?.message) {
40
+ throw new CardError(wrapTextMultiline(firstError.message, 525, 12)[0] ?? '', {
41
+ code: 'upstream',
42
+ secondaryMessage: res.statusText,
43
+ });
44
+ }
45
+ throw new CardError('Something went wrong while trying to retrieve the language data using the GraphQL API.', { code: 'upstream' });
46
+ }
47
+ const repoToHide = {};
48
+ const allExcludedRepos = [...exclude_repo, ...config.excludeRepositories];
49
+ // populate repoToHide map for quick lookup while filtering out
50
+ allExcludedRepos.forEach((repoName) => {
51
+ repoToHide[repoName] = true;
52
+ });
53
+ // filter out repositories to be hidden
54
+ const repoNodes = (res.data.data.user?.repositories.nodes ?? []).filter((node) => !!node && !repoToHide[node.name]);
55
+ // flatten edges across repos. Order matters: `concat(acc)` prepends, and the
56
+ // shared repoCount below depends on visitation order.
57
+ const languageEdges = repoNodes.reduce((acc, repo) => {
58
+ const edges = (repo.languages?.edges ?? []).filter((edge) => !!edge);
59
+ return edges.length > 0 ? edges.concat(acc) : acc;
60
+ }, []);
61
+ // accumulate size and repo count per language
62
+ const languageMap = {};
63
+ let repoCount = 0;
64
+ for (const edge of languageEdges) {
65
+ const existing = languageMap[edge.node.name];
66
+ // same language seen again: add to its size and bump count; else reset to 1
67
+ let langSize = edge.size;
68
+ if (existing && edge.node.name === existing.name) {
69
+ langSize = edge.size + existing.size;
70
+ repoCount += 1;
71
+ }
72
+ else {
73
+ repoCount = 1;
74
+ }
75
+ languageMap[edge.node.name] = {
76
+ name: edge.node.name,
77
+ color: edge.node.color,
78
+ size: langSize,
79
+ count: repoCount,
80
+ };
81
+ }
82
+ // comparison index calculation
83
+ for (const lang of Object.values(languageMap)) {
84
+ lang.size = Math.pow(lang.size, size_weight) * Math.pow(lang.count, count_weight);
85
+ }
86
+ // return languages sorted by (weighted) size, descending
87
+ return Object.fromEntries(Object.entries(languageMap).sort(([, a], [, b]) => b.size - a.size));
88
+ };
89
+ export { fetchTopLanguages };