@stats-forge/github-stats-forge-core 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/build/api/contributed-to.js +1 -1
  2. package/build/api/gist.js +1 -1
  3. package/build/api/index.d.ts +1 -0
  4. package/build/api/index.d.ts.map +1 -1
  5. package/build/api/index.js +1 -0
  6. package/build/api/organization.d.ts +29 -0
  7. package/build/api/organization.d.ts.map +1 -0
  8. package/build/api/organization.js +53 -0
  9. package/build/api/params.js +1 -1
  10. package/build/api/pin.js +1 -1
  11. package/build/api/stats.js +1 -1
  12. package/build/api/top-langs.js +1 -1
  13. package/build/api/wakatime.js +1 -1
  14. package/build/cards/{contributed-to.d.ts → contributed-to/index.d.ts} +3 -3
  15. package/build/cards/contributed-to/index.d.ts.map +1 -0
  16. package/build/cards/{contributed-to.js → contributed-to/index.js} +31 -30
  17. package/build/cards/contributed-to/locales.d.ts +38 -0
  18. package/build/cards/contributed-to/locales.d.ts.map +1 -0
  19. package/build/cards/contributed-to/locales.js +39 -0
  20. package/build/cards/{gist.d.ts → gist/index.d.ts} +3 -3
  21. package/build/cards/gist/index.d.ts.map +1 -0
  22. package/build/cards/{gist.js → gist/index.js} +20 -15
  23. package/build/cards/gist/locales.d.ts +14 -0
  24. package/build/cards/gist/locales.d.ts.map +1 -0
  25. package/build/cards/gist/locales.js +15 -0
  26. package/build/cards/organization/index.d.ts +36 -0
  27. package/build/cards/organization/index.d.ts.map +1 -0
  28. package/build/cards/organization/index.js +225 -0
  29. package/build/cards/organization/locales.d.ts +47 -0
  30. package/build/cards/organization/locales.d.ts.map +1 -0
  31. package/build/cards/organization/locales.js +47 -0
  32. package/build/cards/{repo.d.ts → repo/index.d.ts} +3 -3
  33. package/build/cards/repo/index.d.ts.map +1 -0
  34. package/build/cards/{repo.js → repo/index.js} +20 -23
  35. package/build/cards/repo/locales.d.ts +129 -0
  36. package/build/cards/repo/locales.d.ts.map +1 -0
  37. package/build/cards/repo/locales.js +129 -0
  38. package/build/cards/{stats.d.ts → stats/index.d.ts} +3 -3
  39. package/build/cards/stats/index.d.ts.map +1 -0
  40. package/build/cards/{stats.js → stats/index.js} +38 -42
  41. package/build/cards/stats/locales.d.ts +621 -0
  42. package/build/cards/stats/locales.d.ts.map +1 -0
  43. package/build/cards/stats/locales.js +623 -0
  44. package/build/cards/{top-languages.d.ts → top-languages/index.d.ts} +3 -3
  45. package/build/cards/top-languages/index.d.ts.map +1 -0
  46. package/build/cards/{top-languages.js → top-languages/index.js} +13 -16
  47. package/build/cards/top-languages/locales.d.ts +103 -0
  48. package/build/cards/top-languages/locales.d.ts.map +1 -0
  49. package/build/cards/top-languages/locales.js +103 -0
  50. package/build/cards/{wakatime.d.ts → wakatime/index.d.ts} +3 -3
  51. package/build/cards/wakatime/index.d.ts.map +1 -0
  52. package/build/cards/{wakatime.js → wakatime/index.js} +22 -23
  53. package/build/cards/wakatime/locales.d.ts +250 -0
  54. package/build/cards/wakatime/locales.d.ts.map +1 -0
  55. package/build/cards/wakatime/locales.js +250 -0
  56. package/build/common/brand.d.ts +1 -0
  57. package/build/common/brand.d.ts.map +1 -1
  58. package/build/common/brand.js +1 -0
  59. package/build/common/error.d.ts +3 -1
  60. package/build/common/error.d.ts.map +1 -1
  61. package/build/common/error.js +3 -1
  62. package/build/common/icons.d.ts +5 -0
  63. package/build/common/icons.d.ts.map +1 -1
  64. package/build/common/icons.js +19 -4
  65. package/build/common/locales.d.ts +59 -0
  66. package/build/common/locales.d.ts.map +1 -0
  67. package/build/common/locales.js +59 -0
  68. package/build/common/localize.d.ts +60 -0
  69. package/build/common/localize.d.ts.map +1 -0
  70. package/build/common/localize.js +163 -0
  71. package/build/common/render.d.ts.map +1 -1
  72. package/build/common/render.js +9 -3
  73. package/build/fetchers/index.d.ts +2 -1
  74. package/build/fetchers/index.d.ts.map +1 -1
  75. package/build/fetchers/index.js +1 -0
  76. package/build/fetchers/organization.d.ts +15 -0
  77. package/build/fetchers/organization.d.ts.map +1 -0
  78. package/build/fetchers/organization.js +115 -0
  79. package/build/fetchers/types.d.ts +38 -0
  80. package/build/fetchers/types.d.ts.map +1 -1
  81. package/build/graphql/generated/organization.d.ts +88 -0
  82. package/build/graphql/generated/organization.d.ts.map +1 -0
  83. package/build/graphql/generated/organization.js +59 -0
  84. package/package.json +1 -1
  85. package/src/api/contributed-to.ts +1 -1
  86. package/src/api/gist.ts +1 -1
  87. package/src/api/index.ts +1 -0
  88. package/src/api/organization.ts +90 -0
  89. package/src/api/params.ts +1 -1
  90. package/src/api/pin.ts +1 -1
  91. package/src/api/stats.ts +1 -1
  92. package/src/api/top-langs.ts +1 -1
  93. package/src/api/wakatime.ts +1 -1
  94. package/src/cards/{contributed-to.ts → contributed-to/index.ts} +42 -39
  95. package/src/cards/contributed-to/locales.ts +41 -0
  96. package/src/cards/{gist.ts → gist/index.ts} +23 -20
  97. package/src/cards/gist/locales.ts +17 -0
  98. package/src/cards/organization/index.ts +358 -0
  99. package/src/cards/organization/locales.ts +49 -0
  100. package/src/cards/{repo.ts → repo/index.ts} +23 -26
  101. package/src/cards/repo/locales.ts +131 -0
  102. package/src/cards/{stats.ts → stats/index.ts} +48 -56
  103. package/src/cards/stats/locales.ts +625 -0
  104. package/src/cards/{top-languages.ts → top-languages/index.ts} +17 -20
  105. package/src/cards/top-languages/locales.ts +105 -0
  106. package/src/cards/{wakatime.ts → wakatime/index.ts} +26 -27
  107. package/src/cards/wakatime/locales.ts +252 -0
  108. package/src/common/brand.ts +1 -0
  109. package/src/common/error.ts +4 -0
  110. package/src/common/icons.ts +20 -4
  111. package/src/common/locales.ts +61 -0
  112. package/src/common/localize.ts +251 -0
  113. package/src/common/render.ts +10 -3
  114. package/src/fetchers/index.ts +2 -0
  115. package/src/fetchers/organization.ts +153 -0
  116. package/src/fetchers/types.ts +36 -0
  117. package/src/graphql/generated/organization.ts +109 -0
  118. package/src/graphql/queries/organization.graphql +57 -0
  119. package/build/cards/contributed-to.d.ts.map +0 -1
  120. package/build/cards/gist.d.ts.map +0 -1
  121. package/build/cards/repo.d.ts.map +0 -1
  122. package/build/cards/stats.d.ts.map +0 -1
  123. package/build/cards/top-languages.d.ts.map +0 -1
  124. package/build/cards/wakatime.d.ts.map +0 -1
  125. package/build/common/I18n.d.ts +0 -21
  126. package/build/common/I18n.d.ts.map +0 -1
  127. package/build/common/I18n.js +0 -34
  128. package/build/translations.d.ts +0 -1212
  129. package/build/translations.d.ts.map +0 -1
  130. package/build/translations.js +0 -1213
  131. package/src/common/I18n.ts +0 -50
  132. package/src/translations.ts +0 -1239
@@ -0,0 +1,61 @@
1
+ import { defineLocales } from './localize.ts';
2
+
3
+ /**
4
+ * The wordings more than one card draws, in the locales they have been translated into.
5
+ *
6
+ * `lastYear` lived in the wakatime card's table until 2026-09-08, which is where the
7
+ * stats card reached for it — the translations here are that table's, moved verbatim.
8
+ */
9
+ const commonLocales = defineLocales({
10
+ lastYear: {
11
+ en: 'last year',
12
+ ar: 'العام الماضي',
13
+ az: 'Ötən il',
14
+ ca: "L'any passat",
15
+ cn: '去年',
16
+ 'zh-tw': '去年',
17
+ cs: 'Minulý rok',
18
+ de: 'Letztes Jahr',
19
+ sw: 'Mwaka uliopita',
20
+ ur: 'پچھلا سال',
21
+ bg: 'миналата год.',
22
+ bn: 'গত বছর',
23
+ es: 'El año pasado',
24
+ fa: 'سال گذشته',
25
+ fi: 'Viime vuosi',
26
+ fr: "L'année dernière",
27
+ hi: 'पिछले साल',
28
+ sa: 'गतवर्षे',
29
+ hu: 'Tavaly',
30
+ it: "L'anno scorso",
31
+ ja: '昨年',
32
+ kr: '작년',
33
+ nl: 'Vorig jaar',
34
+ 'pt-pt': 'Ano passado',
35
+ 'pt-br': 'Ano passado',
36
+ np: 'गत वर्ष',
37
+ el: 'Πέρυσι',
38
+ ro: 'Anul trecut',
39
+ ru: 'За прошлый год',
40
+ 'uk-ua': 'За минулий рік',
41
+ id: 'Tahun lalu',
42
+ ml: 'കഴിഞ്ഞ വർഷം',
43
+ my: 'မနှစ်က',
44
+ ta: `கடந்த ஆண்டு`,
45
+ sk: 'Minulý rok',
46
+ tr: 'Geçen yıl',
47
+ pl: 'W zeszłym roku',
48
+ uz: "O'tgan yil",
49
+ vi: 'Năm ngoái',
50
+ se: 'Förra året',
51
+ he: 'שנה שעברה',
52
+ fil: 'Nakaraang Taon',
53
+ th: 'ปีที่แล้ว',
54
+ sr: 'Прошла год.',
55
+ 'sr-latn': 'Prošla god.',
56
+ no: 'I fjor',
57
+ be: 'мінулы год',
58
+ },
59
+ });
60
+
61
+ export { commonLocales };
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Every locale a card can be asked for.
3
+ *
4
+ * A key is written in `en` first and translated afterwards, so a card's table need not
5
+ * carry every locale — a wording falls back to `en`. This list is what the api accepts.
6
+ *
7
+ * @see https://www.andiamo.co.uk/resources/iso-language-codes/ for language codes.
8
+ */
9
+ const AVAILABLE_LOCALES = [
10
+ 'en',
11
+ 'ar',
12
+ 'az',
13
+ 'bg',
14
+ 'bn',
15
+ 'ca',
16
+ 'cn',
17
+ 'zh-tw',
18
+ 'cs',
19
+ 'de',
20
+ 'sw',
21
+ 'ur',
22
+ 'es',
23
+ 'fa',
24
+ 'fi',
25
+ 'fr',
26
+ 'hi',
27
+ 'sa',
28
+ 'hu',
29
+ 'it',
30
+ 'ja',
31
+ 'kr',
32
+ 'nl',
33
+ 'pt-pt',
34
+ 'pt-br',
35
+ 'np',
36
+ 'el',
37
+ 'ro',
38
+ 'ru',
39
+ 'uk-ua',
40
+ 'id',
41
+ 'ml',
42
+ 'my',
43
+ 'ta',
44
+ 'sk',
45
+ 'tr',
46
+ 'pl',
47
+ 'uz',
48
+ 'vi',
49
+ 'se',
50
+ 'he',
51
+ 'fil',
52
+ 'th',
53
+ 'sr',
54
+ 'sr-latn',
55
+ 'no',
56
+ 'be',
57
+ ] as const;
58
+
59
+ const FALLBACK_LOCALE = 'en';
60
+
61
+ /**
62
+ * Checks whether the locale is available or not.
63
+ *
64
+ * @returns Boolean specifying whether the locale is available or not.
65
+ */
66
+ const isLocaleAvailable = (locale: string): boolean =>
67
+ (AVAILABLE_LOCALES as ReadonlyArray<string>).includes(locale.toLowerCase());
68
+
69
+ /**
70
+ * A count-dependent wording: one form per plural category the locale distinguishes.
71
+ *
72
+ * `other` is the form every locale has, so it is what an unwritten category falls back to.
73
+ */
74
+ type PluralForms = { other: string } & Partial<Record<Intl.LDMLPluralRule, string>>;
75
+
76
+ /** One locale's wording for a key, either outright or a form per plural category. */
77
+ type Phrase = string | PluralForms;
78
+
79
+ /**
80
+ * A card's translations: a key, then that key's wording per locale.
81
+ *
82
+ * `en` is required because it is what every other locale falls back to; the rest are
83
+ * checked against {@link AVAILABLE_LOCALES} by `tests/locales.test.ts`.
84
+ */
85
+ type LocaleTable = Record<string, { en: Phrase } & Record<string, Phrase>>;
86
+
87
+ /**
88
+ * Declares a card's translations, keeping every string's literal type so `localize` knows
89
+ * which `{placeholder}` values each key needs.
90
+ *
91
+ * @returns The table it was given.
92
+ */
93
+ const defineLocales = <const Table extends LocaleTable>(table: Table): Table => table;
94
+
95
+ /** The `{name}` placeholders in a phrase, as a union of their names. */
96
+ type PlaceholderNames<Written> = Written extends `${string}{${infer Name}}${infer Rest}`
97
+ ? Name | PlaceholderNames<Rest>
98
+ : never;
99
+
100
+ /** What a phrase has to be given: its placeholders, plus `count` when it has plural forms. */
101
+ type PhraseValues<Written> =
102
+ // a wording only known at runtime declares nothing, so it asks for nothing
103
+ string extends Written
104
+ ? never
105
+ : Written extends string
106
+ ? PlaceholderNames<Written>
107
+ : Written extends Record<string, string>
108
+ ? 'count' | PlaceholderNames<Written[keyof Written]>
109
+ : never;
110
+
111
+ /** `t`'s trailing argument: absent for a wording that interpolates nothing. */
112
+ type ValuesArg<Written> = [PhraseValues<Written>] extends [never]
113
+ ? []
114
+ : [
115
+ values: {
116
+ [Name in PhraseValues<Written>]: Name extends 'count' ? number : string | number;
117
+ },
118
+ ];
119
+
120
+ /** The values as `t` reads them back, once the generic tuple above has done its checking. */
121
+ type ReadValues = Record<string, string | number> | undefined;
122
+
123
+ /** `{name}` — a placeholder in a phrase. */
124
+ const PLACEHOLDER = /\{(?<name>\w+)\}/g;
125
+
126
+ /**
127
+ * Substitutes a phrase's `{name}` placeholders. Only the phrase is scanned, so a value
128
+ * that itself looks like a placeholder is left alone.
129
+ *
130
+ * @returns The wording as the card draws it.
131
+ */
132
+ const interpolate = (phrase: string, key: string, values: ReadValues): string =>
133
+ phrase.replace(PLACEHOLDER, (_placeholder, name: string) => {
134
+ const value = values?.[name];
135
+ if (value === undefined) {
136
+ throw new Error(`'${key}' needs a value for '{${name}}'`);
137
+ }
138
+ return String(value);
139
+ });
140
+
141
+ const pluralRules = new Map<string, Intl.PluralRules>();
142
+
143
+ /**
144
+ * The plural rules of the locale that supplied the phrase. Several of the locale names
145
+ * here are not language tags `Intl` knows (`cn`, `kr`, `np`), and resolve to the runtime's
146
+ * own locale — which only matters once such a locale writes plural forms of its own.
147
+ *
148
+ * @returns The rules, built once per locale.
149
+ */
150
+ const rulesFor = (locale: string): Intl.PluralRules => {
151
+ const cached = pluralRules.get(locale);
152
+ if (cached) {
153
+ return cached;
154
+ }
155
+
156
+ const rules = new Intl.PluralRules(locale);
157
+ pluralRules.set(locale, rules);
158
+ return rules;
159
+ };
160
+
161
+ /**
162
+ * Picks the form a count calls for, by the rules of the locale the phrase came from.
163
+ *
164
+ * @returns The chosen form, or `other` for a category that locale has not written.
165
+ */
166
+ const selectPlural = (
167
+ forms: PluralForms,
168
+ locale: string,
169
+ key: string,
170
+ values: ReadValues,
171
+ ): string => {
172
+ const count = values?.['count'];
173
+ if (typeof count !== 'number') {
174
+ throw new TypeError(`'${key}' has plural forms and needs a numeric 'count'`);
175
+ }
176
+ return forms[rulesFor(locale).select(count)] ?? forms.other;
177
+ };
178
+
179
+ /**
180
+ * Reads a wording in the locale asked for, falling back to English when that locale has
181
+ * no entry for it.
182
+ *
183
+ * @returns The wording, and the locale it actually came from.
184
+ */
185
+ const readPhrase = (
186
+ phrases: LocaleTable[string],
187
+ key: string,
188
+ locale: string,
189
+ ): { phrase: Phrase; locale: string } => {
190
+ const written = phrases[locale];
191
+ if (written !== undefined) {
192
+ return { phrase: written, locale };
193
+ }
194
+
195
+ // The english wording the type requires can still be missing at runtime: a card can pass
196
+ // a table it built, and `i18n.test.ts` covers exactly that.
197
+ const english = phrases[FALLBACK_LOCALE];
198
+ // oxlint-disable-next-line typescript/no-unnecessary-condition
199
+ if (english === undefined) {
200
+ throw new Error(`'${key}' translation not found for locale '${locale}'`);
201
+ }
202
+ return { phrase: english, locale: FALLBACK_LOCALE };
203
+ };
204
+
205
+ /**
206
+ * Draws a key's wording: the locale's own if it has one and English otherwise, in the
207
+ * plural form its count calls for, with its `{name}` placeholders substituted.
208
+ *
209
+ * @returns The wording as the card draws it.
210
+ */
211
+ const draw = (
212
+ phrases: LocaleTable[string],
213
+ key: string,
214
+ locale: string,
215
+ values: ReadValues,
216
+ ): string => {
217
+ const { phrase, locale: from } = readPhrase(phrases, key, locale);
218
+ const wording = typeof phrase === 'string' ? phrase : selectPlural(phrase, from, key, values);
219
+
220
+ return interpolate(wording, key, values);
221
+ };
222
+
223
+ /**
224
+ * A card's translations bound to one locale: each key a function of the values its own
225
+ * wording declares.
226
+ */
227
+ type Localized<Table extends LocaleTable> = {
228
+ [Key in keyof Table]: (...args: ValuesArg<Table[Key]['en']>) => string;
229
+ };
230
+
231
+ /**
232
+ * Binds a card's translations to one locale, so a card names a wording where it draws it
233
+ * — `t.title({ login })` — rather than naming a key. That is what lets an editor follow a
234
+ * wording to where it is written, and why a key carries no card name in front of it.
235
+ *
236
+ * @returns One function per key the table declares.
237
+ */
238
+ const localize = <Table extends LocaleTable>(table: Table, locale?: string): Localized<Table> => {
239
+ const asked = locale || FALLBACK_LOCALE;
240
+ const wordings: Record<string, (values?: ReadValues) => string> = {};
241
+
242
+ for (const [key, phrases] of Object.entries(table)) {
243
+ wordings[key] = (values): string => draw(phrases, key, asked, values);
244
+ }
245
+
246
+ // A mapped type over a table known only as a type parameter needs the assertion.
247
+ return wordings as Localized<Table>;
248
+ };
249
+
250
+ export { defineLocales, isLocaleAvailable, localize };
251
+ export type { LocaleTable, Localized, Phrase };
@@ -431,10 +431,15 @@ const renderError = ({
431
431
  theme,
432
432
  });
433
433
 
434
- const hint =
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 =
435
440
  UPSTREAM_API_ERRORS.has(secondaryMessage) || !show_repo_link
436
441
  ? ''
437
- : ' file an issue at https://tinyurl.com/github-stats';
442
+ : 'File an issue at https://tinyurl.com/stats-forge-bug';
438
443
 
439
444
  return renderMarkup(
440
445
  el(
@@ -464,13 +469,15 @@ const renderError = ({
464
469
  fill: String(bgColor),
465
470
  stroke: borderColor,
466
471
  }),
467
- el('text', { x: 25, y: 45, class: 'text' }, `Something went wrong!${hint}`),
472
+ el('text', { x: 25, y: 45, class: 'text' }, 'Something went wrong!'),
468
473
  el(
469
474
  'text',
470
475
  { 'data-testid': 'message', x: 25, y: 55, class: 'text small' },
471
476
  el('tspan', { x: 25, dy: 18 }, message),
472
477
  el('tspan', { x: 25, dy: 18, class: 'gray' }, secondaryMessage),
473
478
  ),
479
+ report !== '' &&
480
+ el('text', { 'data-testid': 'report', x: 25, y: 109, class: 'text small gray' }, report),
474
481
  ),
475
482
  );
476
483
  };
@@ -1,5 +1,6 @@
1
1
  export { fetchContributedTo } from './contributed-to.ts';
2
2
  export { fetchGist } from './gist.ts';
3
+ export { fetchOrganization } from './organization.ts';
3
4
  export { fetchRepo } from './repo.ts';
4
5
  export { fetchRepoUserStats, fetchStats } from './stats.ts';
5
6
  export { fetchTopLanguages } from './top-languages.ts';
@@ -10,6 +11,7 @@ export type {
10
11
  ContributedToData,
11
12
  GistData,
12
13
  Lang,
14
+ OrganizationData,
13
15
  RepoUserStats,
14
16
  RepositoryData,
15
17
  StatsData,
@@ -0,0 +1,153 @@
1
+ import type { CardConfig } from '../common/config.ts';
2
+ import { GITHUB_USERNAME_PATTERN } from '../common/constants.ts';
3
+ import { CardError, ORGANIZATION_NOT_FOUND } from '../common/error.ts';
4
+ import { createGraphQLFetcher } from '../common/http.ts';
5
+ import type { GraphQLResponse } from '../common/http.ts';
6
+ import { retryer } from '../common/retryer.ts';
7
+ import { GetOrganizationDocument } from '../graphql/generated/organization.ts';
8
+ import type {
9
+ GetOrganizationQuery,
10
+ OrgRepoInfoFragment,
11
+ } from '../graphql/generated/organization.ts';
12
+
13
+ import { graphqlError } from './graphql-error.ts';
14
+ import type { OrganizationData } from './types.ts';
15
+
16
+ const fetcher = createGraphQLFetcher(GetOrganizationDocument, 'token');
17
+
18
+ const urlExample = '/api/org?org=ORG_NAME';
19
+
20
+ const ORGANIZATION_ERROR =
21
+ 'Something went wrong while trying to retrieve the organization data using the GraphQL API.';
22
+
23
+ /**
24
+ * Pages of repositories the walk asks for, 100 each.
25
+ * Named in the doc comments below as the number it is, so the constant stays internal.
26
+ * The totals are sums over what it saw and the repositories arrive most-starred first,
27
+ * so the cap costs an organization past it the tail of its list rather than its headline figures.
28
+ */
29
+ const MAX_REPO_PAGES = 5;
30
+
31
+ /** The organization as the query returns it, once it has resolved. */
32
+ type Organization = NonNullable<GetOrganizationQuery['organization']>;
33
+
34
+ /**
35
+ * @returns The language most of those repositories name as their primary one, `null` when none do.
36
+ */
37
+ const topLanguage = (
38
+ repos: Array<OrgRepoInfoFragment>,
39
+ ): { name: string; color: string | null } | null => {
40
+ const counts = new Map<string, { color: string | null; count: number }>();
41
+
42
+ for (const { primaryLanguage } of repos) {
43
+ if (primaryLanguage) {
44
+ const seen = counts.get(primaryLanguage.name);
45
+ counts.set(primaryLanguage.name, {
46
+ color: primaryLanguage.color,
47
+ count: (seen?.count ?? 0) + 1,
48
+ });
49
+ }
50
+ }
51
+
52
+ let top: { name: string; color: string | null } | null = null;
53
+ let max = 0;
54
+ for (const [name, { color, count }] of counts) {
55
+ if (count > max) {
56
+ max = count;
57
+ top = { name, color };
58
+ }
59
+ }
60
+
61
+ return top;
62
+ };
63
+
64
+ /**
65
+ * Fetch an organization and the totals of its public repositories.
66
+ *
67
+ * Only public, non-fork repositories are counted, most-starred first,
68
+ * and at most five pages of 100 — `truncated` says when the walk stopped early.
69
+ *
70
+ * @returns The organization data.
71
+ */
72
+ const fetchOrganization = async (
73
+ { org }: { org: string | undefined },
74
+ config: CardConfig,
75
+ ): Promise<OrganizationData> => {
76
+ if (!org) {
77
+ throw CardError.missingParam(['org'], urlExample);
78
+ }
79
+ // `./fetchers` is a public export, so the login is checked here as well as at the api boundary.
80
+ if (!GITHUB_USERNAME_PATTERN.test(org)) {
81
+ throw new CardError('Invalid organization provided.', {
82
+ code: 'invalid_param',
83
+ param: 'org',
84
+ });
85
+ }
86
+
87
+ const repos: Array<OrgRepoInfoFragment> = [];
88
+ let organization: Organization | undefined;
89
+ let after: string | null = null;
90
+ let pages = 0;
91
+
92
+ do {
93
+ // Annotated because the walk feeds its own cursor back in, which TypeScript reads as circular.
94
+ const res: GraphQLResponse<GetOrganizationQuery> = await retryer(
95
+ fetcher,
96
+ { login: org, after },
97
+ config,
98
+ );
99
+ if (res.data.errors) {
100
+ // A login that is a user, or nothing at all, answers NOT_FOUND — which `graphqlError`
101
+ // words for a user query, so this endpoint's own wording is thrown instead.
102
+ if (res.data.errors[0]?.type === 'NOT_FOUND') {
103
+ throw new CardError('Organization not found', {
104
+ code: 'not_found',
105
+ secondaryMessage: ORGANIZATION_NOT_FOUND,
106
+ });
107
+ }
108
+ throw graphqlError(res.data.errors, res.statusText, ORGANIZATION_ERROR);
109
+ }
110
+
111
+ organization = res.data.data.organization ?? undefined;
112
+ if (!organization) {
113
+ throw new CardError('Organization not found', {
114
+ code: 'not_found',
115
+ secondaryMessage: ORGANIZATION_NOT_FOUND,
116
+ });
117
+ }
118
+
119
+ repos.push(...(organization.repositories.nodes ?? []).filter((node) => node !== null));
120
+ const { hasNextPage, endCursor } = organization.repositories.pageInfo;
121
+ after = hasNextPage ? endCursor : null;
122
+ pages += 1;
123
+ } while (after !== null && pages < MAX_REPO_PAGES);
124
+
125
+ const sum = (count: (repo: OrgRepoInfoFragment) => number): number =>
126
+ repos.reduce((total, repo) => total + count(repo), 0);
127
+
128
+ return {
129
+ login: organization.login,
130
+ name: organization.name || organization.login,
131
+ description: organization.description,
132
+ createdAt: organization.createdAt,
133
+ publicRepos: organization.repositories.totalCount,
134
+ totalStars: sum((repo) => repo.stargazerCount),
135
+ totalForks: sum((repo) => repo.forkCount),
136
+ totalWatchers: sum((repo) => repo.watchers.totalCount),
137
+ openIssues: sum((repo) => repo.issues.totalCount),
138
+ openPRs: sum((repo) => repo.pullRequests.totalCount),
139
+ totalReleases: sum((repo) => repo.releases.totalCount),
140
+ // An empty repository has no default branch, and a branch pointing at a tag has no history.
141
+ totalCommits: sum((repo) => {
142
+ const target = repo.defaultBranchRef?.target;
143
+ return target !== null && target !== undefined && 'history' in target
144
+ ? target.history.totalCount
145
+ : 0;
146
+ }),
147
+ publicMembers: organization.membersWithRole.totalCount,
148
+ topLanguage: topLanguage(repos),
149
+ truncated: after !== null,
150
+ };
151
+ };
152
+
153
+ export { fetchOrganization };
@@ -33,6 +33,42 @@ export interface RepoUserStats {
33
33
 
34
34
  export type RepositoryData = RepoInfo & RepoUserStats;
35
35
 
36
+ /**
37
+ * An organization, with its public repositories aggregated.
38
+ * Every total is summed over the repositories the walk saw — public, non-fork ones,
39
+ * most-starred first — so `truncated` says whether that was all of them.
40
+ */
41
+ export interface OrganizationData {
42
+ login: string;
43
+ /** The organization's display name, or its login when it has none. */
44
+ name: string;
45
+ description: string | null;
46
+ /** When the organization was created, as GitHub's own ISO 8601 instant. */
47
+ createdAt: string;
48
+ /** Public, non-fork repositories: the whole count, whether or not the walk saw them all. */
49
+ publicRepos: number;
50
+ totalStars: number;
51
+ totalForks: number;
52
+ totalWatchers: number;
53
+ openIssues: number;
54
+ openPRs: number;
55
+ totalReleases: number;
56
+ /**
57
+ * Commits on the default branch of every repository counted: every author, merges included,
58
+ * and whatever history a repository was imported with.
59
+ */
60
+ totalCommits: number;
61
+ /**
62
+ * Members GitHub shows publicly.
63
+ * Membership is private by default, so this counts fewer people than the organization has.
64
+ */
65
+ publicMembers: number;
66
+ /** The language most of those repositories name as their primary one, `null` when none do. */
67
+ topLanguage: { name: string; color: string | null } | null;
68
+ /** Whether the repository walk stopped at its page cap, leaving the totals short. */
69
+ truncated: boolean;
70
+ }
71
+
36
72
  export interface StatsData {
37
73
  name: string;
38
74
  totalPRs: number;
@@ -0,0 +1,109 @@
1
+ // Generated file — see .github/CONTRIBUTING.md
2
+
3
+ /** Internal type. DO NOT USE DIRECTLY. */
4
+ type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
5
+ import { graphqlDocument } from '../graphqlDocument.ts';
6
+ export type OrgRepoInfoFragment = {
7
+ stargazerCount: number;
8
+ forkCount: number;
9
+ primaryLanguage: { color: string | null; name: string } | null;
10
+ watchers: { totalCount: number };
11
+ issues: { totalCount: number };
12
+ pullRequests: { totalCount: number };
13
+ releases: { totalCount: number };
14
+ defaultBranchRef: {
15
+ target: { history: { totalCount: number } } | Record<PropertyKey, never> | null;
16
+ } | null;
17
+ };
18
+
19
+ export type GetOrganizationQueryVariables = Exact<{
20
+ login: string;
21
+ after?: string | null | undefined;
22
+ }>;
23
+
24
+ export type GetOrganizationQuery = {
25
+ organization: {
26
+ login: string;
27
+ name: string | null;
28
+ description: string | null;
29
+ createdAt: string;
30
+ membersWithRole: { totalCount: number };
31
+ repositories: {
32
+ totalCount: number;
33
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
34
+ nodes: Array<{
35
+ stargazerCount: number;
36
+ forkCount: number;
37
+ primaryLanguage: { color: string | null; name: string } | null;
38
+ watchers: { totalCount: number };
39
+ issues: { totalCount: number };
40
+ pullRequests: { totalCount: number };
41
+ releases: { totalCount: number };
42
+ defaultBranchRef: {
43
+ target: { history: { totalCount: number } } | Record<PropertyKey, never> | null;
44
+ } | null;
45
+ } | null> | null;
46
+ };
47
+ } | null;
48
+ };
49
+
50
+ export const GetOrganizationDocument = graphqlDocument<
51
+ GetOrganizationQuery,
52
+ GetOrganizationQueryVariables
53
+ >(`
54
+ query getOrganization($login: String!, $after: String) {
55
+ organization(login: $login) {
56
+ login
57
+ name
58
+ description
59
+ createdAt
60
+ membersWithRole {
61
+ totalCount
62
+ }
63
+ repositories(
64
+ first: 100
65
+ after: $after
66
+ privacy: PUBLIC
67
+ isFork: false
68
+ orderBy: {field: STARGAZERS, direction: DESC}
69
+ ) {
70
+ totalCount
71
+ pageInfo {
72
+ hasNextPage
73
+ endCursor
74
+ }
75
+ nodes {
76
+ ...OrgRepoInfo
77
+ }
78
+ }
79
+ }
80
+ }
81
+ fragment OrgRepoInfo on Repository {
82
+ stargazerCount
83
+ forkCount
84
+ primaryLanguage {
85
+ color
86
+ name
87
+ }
88
+ watchers {
89
+ totalCount
90
+ }
91
+ issues(states: OPEN) {
92
+ totalCount
93
+ }
94
+ pullRequests(states: OPEN) {
95
+ totalCount
96
+ }
97
+ releases {
98
+ totalCount
99
+ }
100
+ defaultBranchRef {
101
+ target {
102
+ ... on Commit {
103
+ history {
104
+ totalCount
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }`);