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