@stats-forge/github-stats-forge-core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/api/contributed-to.d.ts +23 -0
- package/build/api/contributed-to.d.ts.map +1 -0
- package/build/api/contributed-to.js +43 -0
- package/build/api/gist.d.ts +8 -15
- package/build/api/gist.d.ts.map +1 -1
- package/build/api/gist.js +14 -29
- package/build/api/handler.d.ts +16 -0
- package/build/api/handler.d.ts.map +1 -0
- package/build/api/handler.js +29 -0
- package/build/api/index.d.ts +1 -0
- package/build/api/index.d.ts.map +1 -1
- package/build/api/index.js +1 -0
- package/build/api/params.d.ts +11 -5
- package/build/api/params.d.ts.map +1 -1
- package/build/api/params.js +26 -6
- package/build/api/pin.d.ts +11 -15
- package/build/api/pin.d.ts.map +1 -1
- package/build/api/pin.js +34 -46
- package/build/api/stats.d.ts +15 -22
- package/build/api/stats.d.ts.map +1 -1
- package/build/api/stats.js +59 -72
- package/build/api/top-langs.d.ts +11 -22
- package/build/api/top-langs.d.ts.map +1 -1
- package/build/api/top-langs.js +34 -51
- package/build/api/wakatime.d.ts +10 -24
- package/build/api/wakatime.d.ts.map +1 -1
- package/build/api/wakatime.js +27 -47
- package/build/cards/contributed-to.d.ts +19 -0
- package/build/cards/contributed-to.d.ts.map +1 -0
- package/build/cards/contributed-to.js +242 -0
- package/build/cards/gist.d.ts +1 -0
- package/build/cards/gist.d.ts.map +1 -1
- package/build/cards/gist.js +13 -9
- package/build/cards/repo.d.ts +12 -1
- package/build/cards/repo.d.ts.map +1 -1
- package/build/cards/repo.js +40 -18
- package/build/cards/stats.d.ts +15 -3
- package/build/cards/stats.d.ts.map +1 -1
- package/build/cards/stats.js +91 -76
- package/build/cards/top-languages.d.ts +13 -2
- package/build/cards/top-languages.d.ts.map +1 -1
- package/build/cards/top-languages.js +26 -9
- package/build/cards/wakatime.d.ts +13 -2
- package/build/cards/wakatime.d.ts.map +1 -1
- package/build/cards/wakatime.js +22 -9
- package/build/common/Card.d.ts +9 -0
- package/build/common/Card.d.ts.map +1 -1
- package/build/common/Card.js +44 -9
- package/build/common/I18n.d.ts +1 -1
- package/build/common/I18n.d.ts.map +1 -1
- package/build/common/I18n.js +4 -2
- package/build/common/brand.d.ts +76 -0
- package/build/common/brand.d.ts.map +1 -0
- package/build/common/brand.js +73 -0
- package/build/common/constants.d.ts +3 -1
- package/build/common/constants.d.ts.map +1 -1
- package/build/common/constants.js +3 -1
- package/build/common/date.d.ts +50 -3
- package/build/common/date.d.ts.map +1 -1
- package/build/common/date.js +104 -2
- package/build/common/icons.d.ts +4 -2
- package/build/common/icons.d.ts.map +1 -1
- package/build/common/icons.js +10 -5
- package/build/common/ops.d.ts +8 -3
- package/build/common/ops.d.ts.map +1 -1
- package/build/common/ops.js +8 -14
- package/build/common/render.d.ts +7 -1
- package/build/common/render.d.ts.map +1 -1
- package/build/common/render.js +60 -58
- package/build/fetchers/contributed-to.d.ts +62 -0
- package/build/fetchers/contributed-to.d.ts.map +1 -0
- package/build/fetchers/contributed-to.js +184 -0
- package/build/fetchers/graphql-error.d.ts +13 -0
- package/build/fetchers/graphql-error.d.ts.map +1 -0
- package/build/fetchers/graphql-error.js +29 -0
- package/build/fetchers/index.d.ts +2 -1
- package/build/fetchers/index.d.ts.map +1 -1
- package/build/fetchers/index.js +1 -0
- package/build/fetchers/stats.d.ts +14 -8
- package/build/fetchers/stats.d.ts.map +1 -1
- package/build/fetchers/stats.js +72 -195
- package/build/fetchers/top-languages.d.ts.map +1 -1
- package/build/fetchers/top-languages.js +3 -18
- package/build/fetchers/types.d.ts +24 -0
- package/build/fetchers/types.d.ts.map +1 -1
- package/build/graphql/contributionsCollection.d.ts +9 -0
- package/build/graphql/contributionsCollection.d.ts.map +1 -0
- package/build/graphql/contributionsCollection.js +12 -0
- package/build/graphql/contributionsDocument.d.ts +5 -5
- package/build/graphql/contributionsDocument.d.ts.map +1 -1
- package/build/graphql/contributionsDocument.js +8 -15
- package/build/graphql/generated/contributed-to.d.ts +22 -0
- package/build/graphql/generated/contributed-to.d.ts.map +1 -0
- package/build/graphql/generated/contributed-to.js +11 -0
- package/build/graphql/generated/stats.d.ts +13 -1
- package/build/graphql/generated/stats.d.ts.map +1 -1
- package/build/graphql/generated/stats.js +2 -2
- package/build/graphql/graphqlDocument.d.ts +1 -1
- package/build/graphql/reposContributedToDocument.d.ts.map +1 -1
- package/build/graphql/reposContributedToDocument.js +11 -5
- package/build/translations.d.ts +64 -1
- package/build/translations.d.ts.map +1 -1
- package/build/translations.js +65 -1
- package/package.json +1 -1
- package/src/api/contributed-to.ts +84 -0
- package/src/api/gist.ts +20 -37
- package/src/api/handler.ts +42 -0
- package/src/api/index.ts +1 -0
- package/src/api/params.ts +48 -9
- package/src/api/pin.ts +33 -45
- package/src/api/stats.ts +78 -89
- package/src/api/top-langs.ts +34 -56
- package/src/api/wakatime.ts +33 -54
- package/src/cards/contributed-to.ts +373 -0
- package/src/cards/gist.ts +17 -8
- package/src/cards/repo.ts +44 -20
- package/src/cards/stats.ts +119 -90
- package/src/cards/top-languages.ts +27 -18
- package/src/cards/wakatime.ts +23 -13
- package/src/common/Card.ts +56 -11
- package/src/common/I18n.ts +4 -2
- package/src/common/brand.ts +109 -0
- package/src/common/constants.ts +4 -1
- package/src/common/date.ts +135 -3
- package/src/common/icons.ts +10 -5
- package/src/common/ops.ts +10 -19
- package/src/common/render.ts +65 -62
- package/src/fetchers/contributed-to.ts +271 -0
- package/src/fetchers/graphql-error.ts +36 -0
- package/src/fetchers/index.ts +3 -0
- package/src/fetchers/stats.ts +123 -274
- package/src/fetchers/top-languages.ts +5 -19
- package/src/fetchers/types.ts +26 -0
- package/src/graphql/contributionsCollection.ts +18 -0
- package/src/graphql/contributionsDocument.ts +11 -18
- package/src/graphql/generated/contributed-to.ts +25 -0
- package/src/graphql/generated/stats.ts +19 -6
- package/src/graphql/graphqlDocument.ts +1 -1
- package/src/graphql/queries/contributed-to.graphql +8 -0
- package/src/graphql/queries/stats.graphql +14 -2
- package/src/graphql/reposContributedToDocument.ts +11 -9
- package/src/translations.ts +77 -0
package/src/fetchers/stats.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
+
{ type, filter, repo, owner }: SearchVariables,
|
|
135
152
|
token: string,
|
|
136
153
|
{ fetch }: FetcherContext,
|
|
137
|
-
): Promise<FetcherResponse<{ total_count?: number }>> =>
|
|
138
|
-
|
|
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
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
username,
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
*
|
|
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
|
|
280
|
+
const sumOverRanges = async (
|
|
331
281
|
username: string,
|
|
332
|
-
|
|
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 (
|
|
287
|
+
if (ranges.length === 0) {
|
|
336
288
|
return 0;
|
|
337
289
|
}
|
|
338
290
|
|
|
339
|
-
const contributionsFetcher = createGraphQLFetcher(buildContributionsDocument(
|
|
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
|
|
358
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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.
|
|
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
|
|
493
|
+
stats.totalContributions = await sumOverRanges(
|
|
647
494
|
username,
|
|
648
|
-
|
|
495
|
+
contributionRanges,
|
|
496
|
+
(block) => block.contributionCalendar.totalContributions,
|
|
649
497
|
config,
|
|
650
498
|
);
|
|
651
499
|
}
|
|
652
500
|
|
|
653
501
|
if (include_all_time_contribs) {
|
|
654
|
-
|
|
502
|
+
const reposContributedTo = await fetchReposContributedTo(
|
|
655
503
|
user.login,
|
|
656
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
package/src/fetchers/types.ts
CHANGED
|
@@ -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 };
|