From e7fd3129ef73c4f3457bef3913f10bd772773c5d Mon Sep 17 00:00:00 2001 From: webmekanic Date: Wed, 30 Sep 2026 23:02:56 +0100 Subject: [PATCH 1/3] Add update:contributors script and pre-generated contributor data --- src/frontend/package.json | 3 +- src/frontend/scripts/check-data-files.mjs | 2 +- src/frontend/scripts/update-contributors.ts | 152 ++ .../src/data/github-contributors.json | 2136 +++++++++++++++++ src/frontend/src/utils/contributors.ts | 238 -- .../tests/unit/contributors.vitest.test.ts | 428 ---- 6 files changed, 2291 insertions(+), 668 deletions(-) create mode 100644 src/frontend/scripts/update-contributors.ts create mode 100644 src/frontend/src/data/github-contributors.json delete mode 100644 src/frontend/src/utils/contributors.ts delete mode 100644 src/frontend/tests/unit/contributors.vitest.test.ts diff --git a/src/frontend/package.json b/src/frontend/package.json index c5068f5a3..197fd7ea6 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -52,12 +52,13 @@ "test:e2e:serve": "pnpm git-env && pnpm check-data && astro dev --host 127.0.0.1 --port 4321", "lint": "pnpm git-env && pnpm exec astro sync && pnpm verify-skill-digests && eslint . --max-warnings 0", "format": "prettier -w --cache --plugin prettier-plugin-astro .", - "update:all": "pnpm update:integrations && pnpm update:github-stats && pnpm update:samples && pnpm update:blogs", + "update:all": "pnpm update:integrations && pnpm update:github-stats && pnpm update:contributors && pnpm update:samples && pnpm update:blogs", "update:blogs": "tsx ./scripts/update-blogs.ts", "update:schemas": "tsx ./scripts/update-schemas.ts", "update:integrations": "tsx ./scripts/update-integrations.ts", "update:ts-api": "tsx ./scripts/update-ts-api.ts", "update:github-stats": "tsx ./scripts/update-github-stats.ts", + "update:contributors": "tsx ./scripts/update-contributors.ts", "update:release-contributors": "tsx ./scripts/update-release-contributors.ts", "update:samples": "tsx ./scripts/update-samples.ts", "normalize:api-data": "tsx ./scripts/normalize-generated-api-data.ts", diff --git a/src/frontend/scripts/check-data-files.mjs b/src/frontend/scripts/check-data-files.mjs index 73cc3af3f..13535131d 100644 --- a/src/frontend/scripts/check-data-files.mjs +++ b/src/frontend/scripts/check-data-files.mjs @@ -7,7 +7,7 @@ import { existsSync } from 'fs'; import { join } from 'path'; import { execSync } from 'child_process'; -const REQUIRED_FILES = ['./src/data/aspire-integrations.json', './src/data/github-stats.json', './src/data/aspire-blog-posts.json']; +const REQUIRED_FILES = ['./src/data/aspire-integrations.json', './src/data/github-stats.json', './src/data/github-contributors.json', './src/data/aspire-blog-posts.json']; function checkDataFiles() { const missingFiles = REQUIRED_FILES.filter((file) => { diff --git a/src/frontend/scripts/update-contributors.ts b/src/frontend/scripts/update-contributors.ts new file mode 100644 index 000000000..c7e641191 --- /dev/null +++ b/src/frontend/scripts/update-contributors.ts @@ -0,0 +1,152 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { aspireProject } from '../src/data/aspire-project'; +import { fetchWithProxy } from './fetch-with-proxy'; + +export const REPOS = [ + aspireProject.name, + 'microsoft/aspire-samples', + 'CommunityToolkit/Aspire', + 'microsoft/aspire.dev', + 'microsoft/dcp', +] as const; + +const OUTPUT_PATH = './src/data/github-contributors.json'; +const PAGE_SIZE = 100; +const MAX_PAGES = 100; +const REQUEST_TIMEOUT_MS = 30_000; + +export interface Contributor { + id: number; + login: string; +} + +export type FetchLike = ( + url: string, + init: { + headers: Record; redirect: 'error'; + signal: AbortSignal + }, +) => Promise<{ + ok: boolean; + status: number; + headers: { + get(name: string): string | null + }; + json(): Promise; +}>; + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function canonicalRepository(repository: string): string { + const canonical = repository.trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9-]*\/[a-z0-9._-]+$/.test(canonical)) { + throw new TypeError(`Expected a GitHub owner/repository name, got "${repository}".`); + } + return canonical; +} + +export function parseContributors(value: unknown): Contributor[] { + if (!Array.isArray(value) || value.length > PAGE_SIZE) { + throw new Error('Unexpected contributors response shape.'); + } + return value.map((item: unknown) => { + if ( + typeof item !== 'object' || item === null || + !('id' in item) || typeof item.id !== 'number' || + !Number.isSafeInteger(item.id) || item.id <= 0 || + !('login' in item) || typeof item.login !== 'string' || + !/^[a-z0-9][a-z0-9-]*(?:\[bot\])?$/i.test(item.login) + ) { + throw new Error('Unexpected contributor entry in response.'); + } + return { id: item.id, login: item.login }; + }); +} + +export function hasNextPage(link: string | null, endpoint: string, page: number): boolean { + const next = link?.split(',').find((part) => /;\s*rel="next"/.test(part)); + if (!next) return false; + + const match = /^\s*<([^>]+)>/.exec(next); + if (!match) throw new Error('Malformed Link header.'); + const url = new URL(match[1], endpoint); + const expected = new URL(endpoint); + if ( + url.origin !== expected.origin || + ( + url.pathname !== expected.pathname && + !/^\/repositories\/[1-9]\d*\/contributors$/.test(url.pathname) + ) || + url.username || url.password || url.hash || + url.searchParams.get('page') !== String(page + 1) || + url.searchParams.get('per_page') !== String(PAGE_SIZE) || + [...url.searchParams.keys()].some((key) => key !== 'page' && key !== 'per_page') + ) { + throw new Error('Unexpected next-page URL in Link header.'); + } + return true; +} + +export async function fetchRepositoryContributors( + repository: string, + fetch: FetchLike, + token?: string, +): Promise { + const endpoint = `https://api.github.com/repos/${canonicalRepository(repository)}/contributors`; + const contributors: Contributor[] = []; + for (let page = 1; page <= MAX_PAGES; page++) { + const response = await fetch(`${endpoint}?per_page=${PAGE_SIZE}&page=${page}`, { + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { + 'User-Agent': 'aspire-contributors-script', + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch contributors for ${repository} (page ${page}): HTTP ${response.status}`); + } + if (response.status === 204) return contributors; + + contributors.push(...parseContributors(await response.json())); + if (!hasNextPage(response.headers.get('link'), endpoint, page)) { + return contributors; + } + } + throw new Error(`Contributors for ${repository} exceeded ${MAX_PAGES} pages.`); +} + +async function main(): Promise { + const token = (process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN)?.trim() || undefined; + if (!token) { + console.warn('āš ļø No GITHUB_TOKEN/GH_TOKEN set; using anonymous GitHub API requests.'); + } + + const result: Record = {}; + for (const repo of REPOS) { + const contributors = await fetchRepositoryContributors(repo, fetchWithProxy, token); + result[canonicalRepository(repo)] = contributors; + console.log(`āœ… ${repo}: ${contributors.length} contributors`); + } + + fs.writeFileSync(OUTPUT_PATH, `${JSON.stringify(result, null, 2)}\n`); + console.log(`\nšŸ“ Saved contributors for ${REPOS.length} repos to ${OUTPUT_PATH}`); +} + +const isMainModule = process.argv[1] + ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false; + +if (isMainModule) { + void main().catch((error: unknown) => { + console.error('āŒ Failed to update contributors', getErrorMessage(error)); + process.exitCode = 1; + }); +} diff --git a/src/frontend/src/data/github-contributors.json b/src/frontend/src/data/github-contributors.json new file mode 100644 index 000000000..4e10dd29e --- /dev/null +++ b/src/frontend/src/data/github-contributors.json @@ -0,0 +1,2136 @@ +{ + "microsoft/aspire": [ + { + "id": 303201, + "login": "JamesNK" + }, + { + "id": 198982749, + "login": "Copilot" + }, + { + "id": 513398, + "login": "mitchdenny" + }, + { + "id": 8291187, + "login": "eerhardt" + }, + { + "id": 95136, + "login": "davidfowl" + }, + { + "id": 20359921, + "login": "adamint" + }, + { + "id": 13854455, + "login": "joperezr" + }, + { + "id": 1165805, + "login": "sebastienros" + }, + { + "id": 41898282, + "login": "github-actions[bot]" + }, + { + "id": 1472, + "login": "radical" + }, + { + "id": 42748379, + "login": "dotnet-maestro[bot]" + }, + { + "id": 50252651, + "login": "danegsta" + }, + { + "id": 49699333, + "login": "dependabot[bot]" + }, + { + "id": 249088, + "login": "DamianEdwards" + }, + { + "id": 268009190, + "login": "aspire-repo-bot[bot]" + }, + { + "id": 7679720, + "login": "IEvangelist" + }, + { + "id": 15271049, + "login": "karolz-ms" + }, + { + "id": 9011267, + "login": "dotnet-bot" + }, + { + "id": 9613109, + "login": "tlmii" + }, + { + "id": 350947, + "login": "drewnoakes" + }, + { + "id": 203839, + "login": "ReubenBond" + }, + { + "id": 289860, + "login": "afscrome" + }, + { + "id": 70141416, + "login": "Alirexaa" + }, + { + "id": 4403806, + "login": "RussKie" + }, + { + "id": 67609881, + "login": "ellahathaway" + }, + { + "id": 1528107, + "login": "smitpatel" + }, + { + "id": 12660687, + "login": "maddymontaquila" + }, + { + "id": 7574801, + "login": "BrennanConroy" + }, + { + "id": 556238, + "login": "davidebbo" + }, + { + "id": 4821, + "login": "timheuer" + }, + { + "id": 52016832, + "login": "Zombach" + }, + { + "id": 1761079, + "login": "vnbaaij" + }, + { + "id": 14283640, + "login": "wtgodbe" + }, + { + "id": 1439341, + "login": "martincostello" + }, + { + "id": 939291, + "login": "jfversluis" + }, + { + "id": 434140, + "login": "aaronpowell" + }, + { + "id": 20427417, + "login": "benjaminpetit" + }, + { + "id": 8725170, + "login": "mmitche" + }, + { + "id": 31598696, + "login": "DeagleGross" + }, + { + "id": 51421, + "login": "MattKotsenas" + }, + { + "id": 233947509, + "login": "ShilpiRach" + }, + { + "id": 12819039, + "login": "tommasodotNET" + }, + { + "id": 7681382, + "login": "spboyer" + }, + { + "id": 31348972, + "login": "Youssef1313" + }, + { + "id": 104792814, + "login": "bart-vmware" + }, + { + "id": 668244, + "login": "vicancy" + }, + { + "id": 1653751, + "login": "normj" + }, + { + "id": 7604648, + "login": "WeihanLi" + }, + { + "id": 141655842, + "login": "zhiyuanliang-ms" + }, + { + "id": 10554446, + "login": "ArcturusZhang" + }, + { + "id": 4063578, + "login": "kiapanahi" + }, + { + "id": 2433750, + "login": "phenning" + }, + { + "id": 1857993, + "login": "captainsafia" + }, + { + "id": 17270481, + "login": "MatsM16" + }, + { + "id": 7863439, + "login": "bjorkstromm" + }, + { + "id": 1518610, + "login": "prom3theu5" + }, + { + "id": 1583241, + "login": "sliekens" + }, + { + "id": 1844001, + "login": "oising" + }, + { + "id": 813007, + "login": "nellshamrell" + }, + { + "id": 9786571, + "login": "Meir017" + }, + { + "id": 45107667, + "login": "matthebrown" + }, + { + "id": 105797295, + "login": "hewe-saxo" + }, + { + "id": 87335885, + "login": "kundadebdatta" + }, + { + "id": 386903, + "login": "mtmk" + }, + { + "id": 1872271, + "login": "VincentH-Net" + }, + { + "id": 6402946, + "login": "philliphoff" + }, + { + "id": 1376924, + "login": "akoeplinger" + }, + { + "id": 19396090, + "login": "Kahbazi" + }, + { + "id": 188129, + "login": "bgrainger" + }, + { + "id": 470455, + "login": "paulomorgado" + }, + { + "id": 44353219, + "login": "cqnguy23" + }, + { + "id": 681739, + "login": "g7ed6e" + }, + { + "id": 22527892, + "login": "microsoftopensource" + }, + { + "id": 573979, + "login": "baronfel" + }, + { + "id": 52082556, + "login": "RafaelJCamara" + }, + { + "id": 54915162, + "login": "samsp-msft" + }, + { + "id": 215511671, + "login": "SankeerthNara" + }, + { + "id": 122666, + "login": "SimonCropp" + }, + { + "id": 67293991, + "login": "adegeo" + }, + { + "id": 220716413, + "login": "alirezafzali" + }, + { + "id": 312835, + "login": "Pilchie" + }, + { + "id": 67107950, + "login": "stbau04" + }, + { + "id": 10456961, + "login": "MackinnonBuck" + }, + { + "id": 17213165, + "login": "marshalhayes" + }, + { + "id": 173814476, + "login": "shauryalowkeygotaura" + }, + { + "id": 2642209, + "login": "stephentoub" + }, + { + "id": 583206, + "login": "twsouthwick" + }, + { + "id": 5714438, + "login": "Varorbc" + }, + { + "id": 24213737, + "login": "vhvb1989" + }, + { + "id": 11340282, + "login": "Evangelink" + }, + { + "id": 93973732, + "login": "Kumima" + }, + { + "id": 54595583, + "login": "JoshLove-msft" + }, + { + "id": 6995051, + "login": "javiercn" + }, + { + "id": 47030488, + "login": "Muckenbatscher" + }, + { + "id": 5751682, + "login": "brendandburns" + }, + { + "id": 26527405, + "login": "aradalvand" + }, + { + "id": 2493377, + "login": "askpt" + }, + { + "id": 5325718, + "login": "andrevlins" + }, + { + "id": 24496671, + "login": "vivekjm" + }, + { + "id": 270232, + "login": "vlesierse" + }, + { + "id": 26800140, + "login": "vsantele" + }, + { + "id": 1101362, + "login": "SteveSandersonMS" + }, + { + "id": 32671551, + "login": "rzikm" + }, + { + "id": 47990216, + "login": "missymessa" + }, + { + "id": 104274, + "login": "mfcollins3" + }, + { + "id": 155835512, + "login": "MermaidIsla" + }, + { + "id": 6011991, + "login": "adamsitnik" + }, + { + "id": 2238529, + "login": "WhitWaldo" + }, + { + "id": 45233435, + "login": "air-hand" + }, + { + "id": 10067009, + "login": "edmondshtogu" + }, + { + "id": 258741235, + "login": "nanookclaw" + }, + { + "id": 11291885, + "login": "paule96" + }, + { + "id": 129366361, + "login": "rickylabs" + }, + { + "id": 10198202, + "login": "illay1994" + }, + { + "id": 142834, + "login": "yreynhout" + }, + { + "id": 271514, + "login": "liammclennan" + }, + { + "id": 77124997, + "login": "abdallahsellem" + }, + { + "id": 219224, + "login": "adityamandaleeka" + }, + { + "id": 6539701, + "login": "AndriySvyryd" + }, + { + "id": 36966225, + "login": "bwateratmsft" + }, + { + "id": 19917677, + "login": "cmeyertons" + }, + { + "id": 54735, + "login": "cmdkeen" + }, + { + "id": 5628773, + "login": "chrisdcmoore" + }, + { + "id": 30415120, + "login": "danespinosa" + }, + { + "id": 10567243, + "login": "Dona278" + }, + { + "id": 20465797, + "login": "ericmutta" + }, + { + "id": 6771251, + "login": "Daluur" + }, + { + "id": 85122868, + "login": "guanzhousongmicrosoft" + }, + { + "id": 15638113, + "login": "james-gould" + }, + { + "id": 1031940, + "login": "jeffhandley" + }, + { + "id": 2374270, + "login": "julioct" + }, + { + "id": 2529120, + "login": "krubenok" + }, + { + "id": 10020471, + "login": "maraf" + }, + { + "id": 804724, + "login": "neoGeneva" + }, + { + "id": 5578, + "login": "peterwald" + }, + { + "id": 11230135, + "login": "pdekkers" + }, + { + "id": 65667413, + "login": "msftph" + }, + { + "id": 7116354, + "login": "pvanbuijtene" + }, + { + "id": 4218809, + "login": "NinoFloris" + }, + { + "id": 87569709, + "login": "nightt5879" + }, + { + "id": 120699894, + "login": "GryBsh" + }, + { + "id": 12735464, + "login": "ndhansen" + }, + { + "id": 56079798, + "login": "foxminchan" + }, + { + "id": 28607748, + "login": "FullStackChef" + }, + { + "id": 3872435, + "login": "epsitec" + }, + { + "id": 25244392, + "login": "pierrebelin" + }, + { + "id": 5731724, + "login": "rdeveen" + }, + { + "id": 9105216, + "login": "Arasz" + }, + { + "id": 3474842, + "login": "romansp" + }, + { + "id": 2608468, + "login": "richlander" + }, + { + "id": 14853553, + "login": "Formatted" + }, + { + "id": 166608075, + "login": "Jah-yee" + }, + { + "id": 35737434, + "login": "r0ss88" + }, + { + "id": 3536775, + "login": "RudyCo" + }, + { + "id": 7412651, + "login": "ViktorHofer" + }, + { + "id": 16405269, + "login": "levimatheri" + }, + { + "id": 803458, + "login": "mangeg" + }, + { + "id": 27914201, + "login": "marcfreiheit" + }, + { + "id": 6841973, + "login": "MarcinJuraszek" + }, + { + "id": 89650029, + "login": "Marcus-Kanon" + }, + { + "id": 6239029, + "login": "markphillips100" + }, + { + "id": 453360, + "login": "MO2k4" + }, + { + "id": 53596000, + "login": "heintz06" + }, + { + "id": 600044, + "login": "wicksipedia" + }, + { + "id": 5815695, + "login": "mattchenderson" + }, + { + "id": 255971, + "login": "MatthewSteeples" + }, + { + "id": 8856912, + "login": "mbwilding" + }, + { + "id": 175220241, + "login": "mehara-rothila" + }, + { + "id": 20541227, + "login": "mcumming" + }, + { + "id": 8290530, + "login": "MichaelSimons" + }, + { + "id": 9459391, + "login": "mikeharder" + }, + { + "id": 4560672, + "login": "mu88" + }, + { + "id": 1643324, + "login": "mitchcapper" + }, + { + "id": 22234383, + "login": "GMouaad" + }, + { + "id": 33566379, + "login": "Mrxx99" + }, + { + "id": 16853743, + "login": "OptimusPi" + }, + { + "id": 46972, + "login": "Costo" + }, + { + "id": 120122292, + "login": "Vladipz" + }, + { + "id": 11080463, + "login": "vladimir-shirmanov" + }, + { + "id": 6625879, + "login": "Waleed-KH" + }, + { + "id": 1550763, + "login": "wmeints" + }, + { + "id": 16876986, + "login": "LittleLittleCloud" + }, + { + "id": 18468966, + "login": "amrali21" + }, + { + "id": 2432095, + "login": "andi0b" + }, + { + "id": 61829128, + "login": "andrei-ungureanu-uipath" + }, + { + "id": 68384137, + "login": "danikishin" + }, + { + "id": 12471105, + "login": "devsko" + }, + { + "id": 14294244, + "login": "duskembayev" + }, + { + "id": 245611701, + "login": "eso-cyber" + }, + { + "id": 48262102, + "login": "kola-tm" + }, + { + "id": 345446, + "login": "mturac" + }, + { + "id": 55726097, + "login": "microsoft-github-operations[bot]" + }, + { + "id": 1688780, + "login": "ojamte" + }, + { + "id": 4287940, + "login": "AdrianCanadasSC" + }, + { + "id": 30927510, + "login": "sharpSteff" + }, + { + "id": 1179329, + "login": "tg-msft" + }, + { + "id": 844331, + "login": "AkosLukacs" + }, + { + "id": 1430011, + "login": "rynowak" + }, + { + "id": 1283154, + "login": "sayedihashimi" + }, + { + "id": 10702007, + "login": "scottaddie" + }, + { + "id": 816505, + "login": "coolcsh" + }, + { + "id": 19911059, + "login": "McDonaldSean" + }, + { + "id": 104617579, + "login": "Shaurya2k06" + }, + { + "id": 163410222, + "login": "ShilpiRachna1" + }, + { + "id": 55030452, + "login": "shivamgoel008" + }, + { + "id": 17148381, + "login": "ShreyasJejurkar" + }, + { + "id": 873751, + "login": "stefannikolei" + }, + { + "id": 16616736, + "login": "sunandabalu" + }, + { + "id": 4603206, + "login": "tskimmett" + }, + { + "id": 20819818, + "login": "thimok" + }, + { + "id": 11218403, + "login": "Thovenaar" + }, + { + "id": 2820647, + "login": "ikkentim" + }, + { + "id": 7315207, + "login": "timritzer" + }, + { + "id": 30480171, + "login": "thomhurst" + }, + { + "id": 46543583, + "login": "T-Gro" + }, + { + "id": 41759, + "login": "tmat" + }, + { + "id": 175955013, + "login": "tranhoangtu-it" + }, + { + "id": 88094233, + "login": "vha-schleupen" + }, + { + "id": 70903378, + "login": "Victor-johnsson" + }, + { + "id": 1130210, + "login": "buvinghausen" + }, + { + "id": 59507, + "login": "bdukes" + }, + { + "id": 3310141, + "login": "CaitieM20" + }, + { + "id": 46290258, + "login": "ChaseKnowlden" + }, + { + "id": 77782533, + "login": "ChinoUkaegbu" + }, + { + "id": 1821173, + "login": "Tratcher" + }, + { + "id": 22922536, + "login": "cdbrown2018" + }, + { + "id": 6497386, + "login": "jesuszarate" + }, + { + "id": 92059005, + "login": "colinwilliams91" + }, + { + "id": 20536256, + "login": "cyrusdargahi" + }, + { + "id": 151573964, + "login": "danfiedler-msft" + }, + { + "id": 8218022, + "login": "djonser" + }, + { + "id": 23037278, + "login": "danspark" + }, + { + "id": 1874516, + "login": "danroth27" + }, + { + "id": 1424395, + "login": "dkattan" + }, + { + "id": 183583132, + "login": "rsd-darshan" + }, + { + "id": 6053067, + "login": "Odonno" + }, + { + "id": 384747, + "login": "flcdrg" + }, + { + "id": 34795406, + "login": "CyberDNS" + }, + { + "id": 381720, + "login": "DavidZidar" + }, + { + "id": 91007260, + "login": "ideepakchauhan7" + }, + { + "id": 3603910, + "login": "aaron-hardin" + }, + { + "id": 326939, + "login": "Aaronontheweb" + }, + { + "id": 124523882, + "login": "wahab-cide" + }, + { + "id": 6626120, + "login": "los93sol" + }, + { + "id": 51498470, + "login": "adelinowona" + }, + { + "id": 7503689, + "login": "ailtonguitar" + }, + { + "id": 50913857, + "login": "alexander-kucherov" + }, + { + "id": 9199345, + "login": "HofmeisterAn" + }, + { + "id": 8689453, + "login": "annatisch" + }, + { + "id": 48644548, + "login": "amilochau" + }, + { + "id": 3242828, + "login": "arpitjain099" + }, + { + "id": 100016552, + "login": "Asafima" + }, + { + "id": 230432, + "login": "attilah" + }, + { + "id": 23058572, + "login": "bbartels" + }, + { + "id": 1140050, + "login": "gansb" + }, + { + "id": 940619, + "login": "Falco20019" + }, + { + "id": 160950603, + "login": "benwitmanmsft" + }, + { + "id": 31216880, + "login": "berkansasmaz" + }, + { + "id": 1553785, + "login": "BillHiebert" + }, + { + "id": 7356786, + "login": "Cethric" + }, + { + "id": 5017479, + "login": "willibrandon" + }, + { + "id": 54418, + "login": "brettcannon" + }, + { + "id": 29430204, + "login": "gabisonia" + }, + { + "id": 44908454, + "login": "Banovvv" + }, + { + "id": 31937616, + "login": "JakeRadMSFT" + }, + { + "id": 5735905, + "login": "nohwnd" + }, + { + "id": 32421608, + "login": "licon4812" + }, + { + "id": 1843336, + "login": "jgbright" + }, + { + "id": 160512, + "login": "jeffl8n" + }, + { + "id": 55972873, + "login": "jomaxso" + }, + { + "id": 52005, + "login": "flojon" + }, + { + "id": 919634, + "login": "jnyrup" + }, + { + "id": 3209610, + "login": "jguadagno" + }, + { + "id": 3335181, + "login": "JoshuaKGoldberg" + }, + { + "id": 8400967, + "login": "holystix04" + }, + { + "id": 6850324, + "login": "Steinblock" + }, + { + "id": 361677, + "login": "vcsjones" + }, + { + "id": 5364423, + "login": "kieronlanning" + }, + { + "id": 679326, + "login": "KirillOsenkov" + }, + { + "id": 19240945, + "login": "KuraiAndras" + }, + { + "id": 6827807, + "login": "larsfjerm" + }, + { + "id": 673769, + "login": "flensrocker" + }, + { + "id": 272612, + "login": "laurentkempe" + }, + { + "id": 1746272, + "login": "GrabYourPitchforks" + }, + { + "id": 5635171, + "login": "denisivan0v" + }, + { + "id": 8350694, + "login": "dvoituron" + }, + { + "id": 3432571, + "login": "dbreshears" + }, + { + "id": 79130336, + "login": "divyeshio" + }, + { + "id": 496737, + "login": "aelij" + }, + { + "id": 44171454, + "login": "emilienbev" + }, + { + "id": 7478125, + "login": "EmmittJ" + }, + { + "id": 382805, + "login": "ericsuh" + }, + { + "id": 8918108, + "login": "ericstj" + }, + { + "id": 969938, + "login": "onionhammer" + }, + { + "id": 3521936, + "login": "Bertolossi" + }, + { + "id": 2507935, + "login": "fabiocav" + }, + { + "id": 42162085, + "login": "jeremy-vm" + }, + { + "id": 5655810, + "login": "frankbuckley" + }, + { + "id": 1246444, + "login": "GeertvanHorrik" + }, + { + "id": 234688, + "login": "glennc" + }, + { + "id": 351693, + "login": "Depechie" + }, + { + "id": 55554236, + "login": "gkulin" + }, + { + "id": 11094468, + "login": "inlineHamed" + }, + { + "id": 3463496, + "login": "hansmbakker" + }, + { + "id": 65155920, + "login": "0xharkirat" + }, + { + "id": 22633385, + "login": "eltociear" + } + ], + "microsoft/aspire-samples": [ + { + "id": 49699333, + "login": "dependabot[bot]" + }, + { + "id": 249088, + "login": "DamianEdwards" + }, + { + "id": 7679720, + "login": "IEvangelist" + }, + { + "id": 303201, + "login": "JamesNK" + }, + { + "id": 13854455, + "login": "joperezr" + }, + { + "id": 198982749, + "login": "Copilot" + }, + { + "id": 95136, + "login": "davidfowl" + }, + { + "id": 8291187, + "login": "eerhardt" + }, + { + "id": 22527892, + "login": "microsoftopensource" + }, + { + "id": 1165805, + "login": "sebastienros" + }, + { + "id": 41929050, + "login": "bradygaster" + }, + { + "id": 1031940, + "login": "jeffhandley" + }, + { + "id": 513398, + "login": "mitchdenny" + }, + { + "id": 14283640, + "login": "wtgodbe" + }, + { + "id": 6539701, + "login": "AndriySvyryd" + }, + { + "id": 1472, + "login": "radical" + }, + { + "id": 6835152, + "login": "antonfirsov" + }, + { + "id": 9611108, + "login": "asilverman" + }, + { + "id": 8246794, + "login": "balachir" + }, + { + "id": 151573964, + "login": "danfiedler-msft" + }, + { + "id": 351693, + "login": "Depechie" + }, + { + "id": 3237266, + "login": "hishamco" + }, + { + "id": 68539, + "login": "jongalloway" + }, + { + "id": 15874598, + "login": "Layla-P" + }, + { + "id": 53272907, + "login": "prisecano" + }, + { + "id": 8725170, + "login": "mmitche" + }, + { + "id": 4887488, + "login": "michaelto20" + }, + { + "id": 203839, + "login": "ReubenBond" + }, + { + "id": 3605364, + "login": "Rick-Anderson" + }, + { + "id": 1857993, + "login": "captainsafia" + }, + { + "id": 36219292, + "login": "sammychinedu2ky" + }, + { + "id": 1283154, + "login": "sayedihashimi" + }, + { + "id": 5178445, + "login": "sgbj" + }, + { + "id": 122666, + "login": "SimonCropp" + }, + { + "id": 1583241, + "login": "sliekens" + }, + { + "id": 28659384, + "login": "timdeschryver" + }, + { + "id": 8255269, + "login": "vishipayyallore" + }, + { + "id": 93200798, + "login": "alexwolfmsft" + }, + { + "id": 55726097, + "login": "microsoft-github-operations[bot]" + }, + { + "id": 1768576, + "login": "moljac" + } + ], + "communitytoolkit/aspire": [ + { + "id": 434140, + "login": "aaronpowell" + }, + { + "id": 49699333, + "login": "dependabot[bot]" + }, + { + "id": 7679720, + "login": "IEvangelist" + }, + { + "id": 289860, + "login": "afscrome" + }, + { + "id": 198982749, + "login": "Copilot" + }, + { + "id": 12819039, + "login": "tommasodotNET" + }, + { + "id": 70141416, + "login": "Alirexaa" + }, + { + "id": 6053067, + "login": "Odonno" + }, + { + "id": 2404846, + "login": "fboucher" + }, + { + "id": 5503800, + "login": "brian-guerrero" + }, + { + "id": 17213165, + "login": "marshalhayes" + }, + { + "id": 20828017, + "login": "gabynevada" + }, + { + "id": 4169187, + "login": "ErikEJ" + }, + { + "id": 41898282, + "login": "github-actions[bot]" + }, + { + "id": 5289365, + "login": "anoordover" + }, + { + "id": 2164354, + "login": "dealloc" + }, + { + "id": 1538528, + "login": "justinyoo" + }, + { + "id": 3094648, + "login": "QuantumNightmare" + }, + { + "id": 30480171, + "login": "thomhurst" + }, + { + "id": 28607748, + "login": "FullStackChef" + }, + { + "id": 2493377, + "login": "askpt" + }, + { + "id": 2529120, + "login": "krubenok" + }, + { + "id": 10209472, + "login": "fabio-marini" + }, + { + "id": 875092, + "login": "karl-sjogren" + }, + { + "id": 342800, + "login": "tamirdresher" + }, + { + "id": 1225489, + "login": "jmezach" + }, + { + "id": 19223232, + "login": "axies20" + }, + { + "id": 1749983, + "login": "JerryNixon" + }, + { + "id": 519707, + "login": "Scooletz" + }, + { + "id": 1844001, + "login": "oising" + }, + { + "id": 5149277, + "login": "DavidGarton8" + }, + { + "id": 10456961, + "login": "MackinnonBuck" + }, + { + "id": 1699587, + "login": "martinjt" + }, + { + "id": 4821, + "login": "timheuer" + }, + { + "id": 1165805, + "login": "sebastienros" + }, + { + "id": 3496652, + "login": "Omnideth" + }, + { + "id": 12660687, + "login": "maddymontaquila" + }, + { + "id": 25389585, + "login": "poissoncorp" + }, + { + "id": 807685, + "login": "TheBlueSky" + }, + { + "id": 6255074, + "login": "Harold-Morgan" + }, + { + "id": 566365, + "login": "Chicoo" + }, + { + "id": 1583241, + "login": "sliekens" + }, + { + "id": 46426884, + "login": "shiranshalom" + }, + { + "id": 10067009, + "login": "edmondshtogu" + }, + { + "id": 470455, + "login": "paulomorgado" + }, + { + "id": 56079798, + "login": "foxminchan" + }, + { + "id": 15186176, + "login": "TechWatching" + }, + { + "id": 1518610, + "login": "prom3theu5" + }, + { + "id": 8291187, + "login": "eerhardt" + }, + { + "id": 29800, + "login": "fredimachado" + }, + { + "id": 303201, + "login": "JamesNK" + }, + { + "id": 7587183, + "login": "kristremblay" + }, + { + "id": 17270481, + "login": "MatsM16" + }, + { + "id": 11130940, + "login": "lqdev" + }, + { + "id": 16354178, + "login": "BickelLukas" + }, + { + "id": 1439341, + "login": "martincostello" + }, + { + "id": 104274, + "login": "mfcollins3" + }, + { + "id": 33566379, + "login": "Mrxx99" + }, + { + "id": 292704, + "login": "r4hulp" + }, + { + "id": 13435555, + "login": "ekomsctr" + }, + { + "id": 23123160, + "login": "RubenPX" + }, + { + "id": 10097564, + "login": "sschutten" + }, + { + "id": 65206, + "login": "esskar" + }, + { + "id": 2642209, + "login": "stephentoub" + }, + { + "id": 1341446, + "login": "slang25" + }, + { + "id": 3947063, + "login": "TimHess" + }, + { + "id": 68390856, + "login": "konnta0" + }, + { + "id": 23089287, + "login": "lukedukeus" + }, + { + "id": 163427032, + "login": "lvde0" + }, + { + "id": 94305767, + "login": "MichielBrys" + }, + { + "id": 25082814, + "login": "andrey-noskov" + }, + { + "id": 5581662, + "login": "cdschneider" + }, + { + "id": 10738038, + "login": "Catalin-Andronie" + }, + { + "id": 10096185, + "login": "programmation" + }, + { + "id": 95136, + "login": "davidfowl" + }, + { + "id": 371009, + "login": "dluc" + }, + { + "id": 2035340, + "login": "almostchristian" + }, + { + "id": 4650644, + "login": "esond" + }, + { + "id": 939291, + "login": "jfversluis" + }, + { + "id": 132921372, + "login": "gitbutler-client" + }, + { + "id": 17333, + "login": "henrikroschmann" + }, + { + "id": 114292165, + "login": "IgorShaposhnikov" + }, + { + "id": 23101537, + "login": "josemalm32" + }, + { + "id": 126857618, + "login": "nnitkasw" + }, + { + "id": 6827807, + "login": "larsfjerm" + }, + { + "id": 269221678, + "login": "lassem-eq" + }, + { + "id": 1885400, + "login": "lvmajor" + }, + { + "id": 105670456, + "login": "Stertz" + } + ], + "microsoft/aspire.dev": [ + { + "id": 7679720, + "login": "IEvangelist" + }, + { + "id": 268009190, + "login": "aspire-repo-bot[bot]" + }, + { + "id": 198982749, + "login": "Copilot" + }, + { + "id": 49699333, + "login": "dependabot[bot]" + }, + { + "id": 95136, + "login": "davidfowl" + }, + { + "id": 41898282, + "login": "github-actions[bot]" + }, + { + "id": 61622933, + "login": "takashiuesaka" + }, + { + "id": 8291187, + "login": "eerhardt" + }, + { + "id": 303201, + "login": "JamesNK" + }, + { + "id": 1165805, + "login": "sebastienros" + }, + { + "id": 5115571, + "login": "BethMassi" + }, + { + "id": 12660687, + "login": "maddymontaquila" + }, + { + "id": 50252651, + "login": "danegsta" + }, + { + "id": 513398, + "login": "mitchdenny" + }, + { + "id": 20359921, + "login": "adamint" + }, + { + "id": 41286777, + "login": "alistairmatthews" + }, + { + "id": 13854455, + "login": "joperezr" + }, + { + "id": 1988321, + "login": "jasontaylordev" + }, + { + "id": 6053067, + "login": "Odonno" + }, + { + "id": 249088, + "login": "DamianEdwards" + }, + { + "id": 939291, + "login": "jfversluis" + }, + { + "id": 1857993, + "login": "captainsafia" + }, + { + "id": 10067009, + "login": "edmondshtogu" + }, + { + "id": 70903378, + "login": "Victor-johnsson" + }, + { + "id": 28659384, + "login": "timdeschryver" + }, + { + "id": 3496652, + "login": "Omnideth" + }, + { + "id": 19944129, + "login": "matt-goldman" + }, + { + "id": 55443722, + "login": "haugis-git" + }, + { + "id": 1246444, + "login": "GeertvanHorrik" + }, + { + "id": 81490414, + "login": "Webmekanic" + }, + { + "id": 45535570, + "login": "Cameron-McBroom" + }, + { + "id": 703248, + "login": "agriffard" + }, + { + "id": 434140, + "login": "aaronpowell" + }, + { + "id": 30480171, + "login": "thomhurst" + }, + { + "id": 12659251, + "login": "teo-tsirpanis" + }, + { + "id": 1341446, + "login": "slang25" + }, + { + "id": 221825, + "login": "rocklau" + }, + { + "id": 1341180, + "login": "patrickklaeren" + }, + { + "id": 56079798, + "login": "foxminchan" + }, + { + "id": 7757162, + "login": "mohsin-mehmood" + }, + { + "id": 9786571, + "login": "Meir017" + }, + { + "id": 17270481, + "login": "MatsM16" + }, + { + "id": 10020471, + "login": "maraf" + }, + { + "id": 4030759, + "login": "butskristof" + }, + { + "id": 289860, + "login": "afscrome" + }, + { + "id": 2048645, + "login": "Tri125" + }, + { + "id": 26800140, + "login": "vsantele" + }, + { + "id": 6625879, + "login": "Waleed-KH" + }, + { + "id": 7604648, + "login": "WeihanLi" + }, + { + "id": 59841145, + "login": "zprobinson" + }, + { + "id": 59741700, + "login": "ZieMcd" + }, + { + "id": 182325027, + "login": "huangkevin-apr" + }, + { + "id": 225886905, + "login": "lim-dy" + }, + { + "id": 11349081, + "login": "philipp985" + }, + { + "id": 153189317, + "login": "suugbut" + }, + { + "id": 55046528, + "login": "BOBx5" + }, + { + "id": 29532881, + "login": "alexravenna" + }, + { + "id": 237136924, + "login": "alex-clickhouse" + }, + { + "id": 2493377, + "login": "askpt" + }, + { + "id": 4245518, + "login": "angelobelchior" + }, + { + "id": 13779871, + "login": "meijeran" + }, + { + "id": 264902880, + "login": "lvb2104" + }, + { + "id": 28277932, + "login": "BoyanYK" + }, + { + "id": 129743, + "login": "brunoborges" + }, + { + "id": 567988, + "login": "dracan" + }, + { + "id": 151573964, + "login": "danfiedler-msft" + }, + { + "id": 75028710, + "login": "dkroderos" + }, + { + "id": 67609881, + "login": "ellahathaway" + }, + { + "id": 4169187, + "login": "ErikEJ" + }, + { + "id": 4189385, + "login": "fadamsen" + }, + { + "id": 90967400, + "login": "idogit123" + }, + { + "id": 33404765, + "login": "Zylvian" + }, + { + "id": 518436, + "login": "vyrotek" + }, + { + "id": 6995051, + "login": "javiercn" + }, + { + "id": 60582071, + "login": "Jeffreyyvdb" + }, + { + "id": 15271049, + "login": "karolz-ms" + }, + { + "id": 111166669, + "login": "kattschan" + }, + { + "id": 152013, + "login": "kijanawoodard" + } + ], + "microsoft/dcp": [ + { + "id": 15271049, + "login": "karolz-ms" + }, + { + "id": 50252651, + "login": "danegsta" + }, + { + "id": 36966225, + "login": "bwateratmsft" + }, + { + "id": 3432571, + "login": "dbreshears" + }, + { + "id": 198982749, + "login": "Copilot" + }, + { + "id": 49699333, + "login": "dependabot[bot]" + }, + { + "id": 4403806, + "login": "RussKie" + }, + { + "id": 4821, + "login": "timheuer" + }, + { + "id": 138153442, + "login": "Arjunmehta312" + }, + { + "id": 151573964, + "login": "danfiedler-msft" + }, + { + "id": 95136, + "login": "davidfowl" + }, + { + "id": 193942704, + "login": "Jal-Bafana" + }, + { + "id": 9602953, + "login": "ellismg" + }, + { + "id": 47990216, + "login": "missymessa" + }, + { + "id": 513398, + "login": "mitchdenny" + }, + { + "id": 20953018, + "login": "haliaeetusvocifer" + }, + { + "id": 166608075, + "login": "Jah-yee" + }, + { + "id": 19911059, + "login": "McDonaldSean" + }, + { + "id": 77245923, + "login": "microsoft-github-policy-service[bot]" + } + ] +} diff --git a/src/frontend/src/utils/contributors.ts b/src/frontend/src/utils/contributors.ts deleted file mode 100644 index 62f402303..000000000 --- a/src/frontend/src/utils/contributors.ts +++ /dev/null @@ -1,238 +0,0 @@ -import process from 'node:process'; -import type { AstroIntegration } from 'astro'; -import proxyFetch from 'node-fetch'; -import { HttpsProxyAgent } from 'https-proxy-agent'; - -export interface Contributor { - readonly id: number; - readonly login: string; -} - -type UnavailableReason = 'http' | 'network' | 'timeout' | 'invalid-response'; - -export type ContributorsResult = - | { readonly status: 'available'; readonly contributors: readonly Contributor[] } - | { readonly status: 'unavailable'; readonly reason: UnavailableReason; readonly httpStatus?: number }; - -const requestTimeoutMs = 10_000; -const maxPages = 100; -const pageSize = 100; - -class ContributorRequestError extends Error { - constructor( - readonly reason: UnavailableReason, - readonly retryable = false, - readonly httpStatus?: number, - ) { - super(reason); - } -} - -function canonicalRepository(repository: string): string { - const canonical = repository.trim().toLowerCase(); - if (!/^[a-z0-9][a-z0-9-]*\/[a-z0-9._-]+$/.test(canonical)) { - throw new TypeError('Expected a GitHub owner/repository name.'); - } - return canonical; -} - -function parseContributors(value: unknown): readonly Contributor[] { - if (!Array.isArray(value) || value.length > pageSize) { - throw new ContributorRequestError('invalid-response'); - } - return value.map((item: unknown) => { - if ( - typeof item !== 'object' || item === null || - !('id' in item) || typeof item.id !== 'number' || - !Number.isSafeInteger(item.id) || item.id <= 0 || - !('login' in item) || typeof item.login !== 'string' || - !/^[a-z0-9][a-z0-9-]*(?:\[bot\])?$/i.test(item.login) - ) { - throw new ContributorRequestError('invalid-response'); - } - return Object.freeze({ id: item.id, login: item.login }); - }); -} - -function hasNextPage(link: string | null, endpoint: string, page: number): boolean { - const next = link?.split(',').find((part) => /;\s*rel="next"/.test(part)); - if (!next) return false; - - // Do not follow arbitrary URLs from response headers, especially with a token. - const match = /^\s*<([^>]+)>/.exec(next); - if (!match) throw new ContributorRequestError('invalid-response'); - let url: URL; - try { - url = new URL(match[1], endpoint); - } catch { - throw new ContributorRequestError('invalid-response'); - } - const expected = new URL(endpoint); - if ( - url.origin !== expected.origin || - ( - url.pathname !== expected.pathname && - !/^\/repositories\/[1-9]\d*\/contributors$/.test(url.pathname) - ) || - url.username || url.password || url.hash || - url.searchParams.get('page') !== String(page + 1) || - url.searchParams.get('per_page') !== String(pageSize) || - [...url.searchParams.keys()].some((key) => key !== 'page' && key !== 'per_page') - ) { - throw new ContributorRequestError('invalid-response'); - } - return true; -} - -/** An isolated run: only complete lists or explicit unavailable outcomes are retained. */ -export function createContributorCache() { - const entries = new Map>(); - const diagnostics = { repositories: 0, requests: 0, cacheHits: 0, unavailable: 0 }; - - async function loadPages( - repository: string, - signal: AbortSignal, - agent?: HttpsProxyAgent, - ): Promise { - const endpoint = `https://api.github.com/repos/${repository}/contributors`; - const contributors: Contributor[] = []; - const token = process.env.GITHUB_TOKEN; - for (let page = 1; page <= maxPages; page++) { - signal.throwIfAborted(); - diagnostics.requests++; - const url = `${endpoint}?per_page=${pageSize}&page=${page}`; - const options = { - signal, - redirect: 'error' as const, - headers: { - Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }; - const response = agent - ? await proxyFetch(url, { ...options, agent }) - : await fetch(url, options); - if (!response.ok) { - throw new ContributorRequestError( - 'http', - response.status === 408 || response.status >= 500, - response.status, - ); - } - if (response.status === 204) return Object.freeze(contributors); - - let data: unknown; - try { - data = await response.json(); - } catch { - throw new ContributorRequestError('invalid-response'); - } - signal.throwIfAborted(); - contributors.push(...parseContributors(data)); - if (!hasNextPage(response.headers.get('link'), endpoint, page)) { - return Object.freeze(contributors); - } - } - throw new ContributorRequestError('invalid-response'); - } - - async function attempt(repository: string): Promise { - // Match scripts/fetch-with-proxy.ts without changing the process-wide fetch. - const proxyUrl = process.env.HTTPS_PROXY ?? process.env.https_proxy ?? - process.env.HTTP_PROXY ?? process.env.http_proxy; - const agent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; - const controller = new AbortController(); - let timer: ReturnType | undefined; - // Bound the whole paginated attempt, including body reads, not just the headers. - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { - reject(new ContributorRequestError('timeout', true)); - controller.abort(); - }, requestTimeoutMs); - }); - try { - return await Promise.race([loadPages(repository, controller.signal, agent), timeout]); - } finally { - clearTimeout(timer); - controller.abort(); - agent?.destroy(); - } - } - - async function load(repository: string): Promise { - for (let attemptNumber = 0; ; attemptNumber++) { - try { - return Object.freeze({ status: 'available', contributors: await attempt(repository) }); - } catch (error) { - const failure = error instanceof ContributorRequestError - ? error - : new ContributorRequestError('network', true); - if (attemptNumber === 0 && failure.retryable) continue; - - diagnostics.unavailable++; - // Never print response bodies, credentials, or arbitrary error messages. - console.warn( - `[contributors] ${repository}: unavailable (${failure.reason}${failure.httpStatus ? ` ${failure.httpStatus}` : ''}); using the GitHub contributors link.`, - ); - return Object.freeze({ - status: 'unavailable', - reason: failure.reason, - ...(failure.httpStatus ? { httpStatus: failure.httpStatus } : {}), - }); - } - } - } - - return { - get(repository: string): Promise { - const key = canonicalRepository(repository); - const cached = entries.get(key); - if (cached) { - diagnostics.cacheHits++; - return cached; - } - diagnostics.repositories++; - // Publish the promise before starting any I/O, including concurrent prerenders. - const pending = Promise.resolve().then(() => load(key)); - entries.set(key, pending); - return pending; - }, - diagnostics: () => ({ ...diagnostics }), - }; -} - -// Astro's config and bundled prerender graph can evaluate different module copies. -// A process symbol bridges those copies; integration hooks own the run's lifetime. -const cacheKey = Symbol.for('aspire.dev.contributors.build-cache'); -const server = globalThis as typeof globalThis & { - [cacheKey]?: ReturnType; -}; - -export function getContributors(repository: string): Promise { - if (typeof window !== 'undefined') { - throw new Error('Contributor data is server-only.'); - } - // Development deliberately does not retain results across requests or HMR edits. - return (server[cacheKey] ?? createContributorCache()).get(repository); -} - -export function contributorCacheIntegration(): AstroIntegration { - return { - name: 'aspire-contributor-cache', - hooks: { - 'astro:config:setup': () => { - delete server[cacheKey]; - }, - 'astro:build:start': () => { - server[cacheKey] = createContributorCache(); - }, - 'astro:build:done': () => { - if (process.env.BUILD_TIMING === '1' && server[cacheKey]) { - console.info(`[contributors] ${JSON.stringify(server[cacheKey].diagnostics())}`); - } - delete server[cacheKey]; - }, - }, - }; -} diff --git a/src/frontend/tests/unit/contributors.vitest.test.ts b/src/frontend/tests/unit/contributors.vitest.test.ts deleted file mode 100644 index f888bf5cf..000000000 --- a/src/frontend/tests/unit/contributors.vitest.test.ts +++ /dev/null @@ -1,428 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import proxyFetch from 'node-fetch'; -import { HttpsProxyAgent } from 'https-proxy-agent'; -import { - contributorCacheIntegration, - createContributorCache, - getContributors, -} from '../../src/utils/contributors'; - -vi.mock('node-fetch', () => ({ default: vi.fn() })); - -const ada = { id: 1, login: 'ada' }; -const grace = { id: 2, login: 'grace' }; -const endpoint = 'https://api.github.com/repos/microsoft/aspire/contributors'; - -function page(contributors: unknown, next?: string): Response { - return new Response(JSON.stringify(contributors), { - headers: next ? { link: `<${next}>; rel="next"` } : {}, - }); -} - -function lifecycle(name: keyof ReturnType['hooks']) { - const hook = contributorCacheIntegration().hooks[name]; - if (!hook) throw new Error(`Missing ${name} hook`); - return Reflect.apply(hook, undefined, [{}]); -} - -beforeEach(() => { - vi.stubEnv('GITHUB_TOKEN', ''); - vi.stubEnv('BUILD_TIMING', ''); - for (const name of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) { - vi.stubEnv(name, ''); - } - vi.mocked(proxyFetch).mockReset(); - vi.spyOn(console, 'warn').mockImplementation(() => {}); - lifecycle('astro:config:setup'); -}); - -afterEach(() => { - lifecycle('astro:config:setup'); - vi.useRealTimers(); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - vi.restoreAllMocks(); -}); - -describe('complete contributor lists', () => { - it('publishes one promise immediately and reuses it after completion for canonical repositories', async () => { - const fetchMock = vi.fn().mockResolvedValue(page([ada])); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - const first = cache.get(' Microsoft/Aspire '); - const concurrent = cache.get('microsoft/aspire'); - - expect(concurrent).toBe(first); - expect(fetchMock).not.toHaveBeenCalled(); - expect(await first).toEqual({ status: 'available', contributors: [ada] }); - expect(cache.get('MICROSOFT/ASPIRE')).toBe(first); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(cache.diagnostics()).toEqual({ repositories: 1, requests: 1, cacheHits: 2, unavailable: 0 }); - }); - - it('keeps repositories isolated', async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce(page([ada])) - .mockResolvedValueOnce(page([grace])); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - const results = await Promise.all([cache.get('microsoft/aspire'), cache.get('microsoft/dcp')]); - expect(results.map((result) => result.status === 'available' && result.contributors)) - .toEqual([[ada], [grace]]); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('preserves API order across pages and shares the entire pagination operation', async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce(page([grace], `${endpoint}?per_page=100&page=2`)) - .mockResolvedValueOnce(page([ada])); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - const [first, second] = await Promise.all([ - cache.get('microsoft/aspire'), - cache.get('Microsoft/Aspire'), - ]); - expect(first).toBe(second); - expect(first).toEqual({ status: 'available', contributors: [grace, ada] }); - expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ - `${endpoint}?per_page=100&page=1`, - `${endpoint}?per_page=100&page=2`, - ]); - expect(await cache.get('microsoft/aspire')).toBe(first); - expect(fetchMock).toHaveBeenCalledTimes(2); - if (first.status === 'available') { - expect(Object.isFrozen(first.contributors)).toBe(true); - expect(Object.isFrozen(first.contributors[0])).toBe(true); - expect(first.contributors.filter(({ login }) => login !== 'grace')).toEqual([ada]); - expect(first.contributors).toEqual([grace, ada]); - } - }); - - it('accepts GitHub numeric repository Link aliases while requesting the authored repository', async () => { - const firstPage = new Response(JSON.stringify([grace]), { - headers: { - link: '; rel="next", ; rel="last"', - }, - }); - const fetchMock = vi.fn() - .mockResolvedValueOnce(firstPage) - .mockResolvedValueOnce(page([ada])); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - const result = await cache.get('microsoft/aspire'); - expect(result).toEqual({ status: 'available', contributors: [grace, ada] }); - expect(await cache.get('microsoft/aspire')).toBe(result); - expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ - `${endpoint}?per_page=100&page=1`, - `${endpoint}?per_page=100&page=2`, - ]); - expect(console.warn).not.toHaveBeenCalled(); - }); - - it.each([new Response('[]'), new Response(null, { status: 204 })])( - 'distinguishes a legitimate empty list from an unavailable result', - async (response) => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); - const cache = createContributorCache(); - expect(await cache.get('microsoft/aspire')).toEqual({ status: 'available', contributors: [] }); - expect(console.warn).not.toHaveBeenCalled(); - }, - ); - - it('uses only the optional server token and never places credentials in URLs or diagnostics', async () => { - vi.stubEnv('GITHUB_TOKEN', 'server-secret'); - vi.stubEnv('PUBLIC_GITHUB_TOKEN', 'public-secret'); - const fetchMock = vi.fn().mockResolvedValue(page([ada])); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - await cache.get('microsoft/aspire'); - const [url, options] = fetchMock.mock.calls[0]; - expect(url).toBe(`${endpoint}?per_page=100&page=1`); - expect(options?.headers).toMatchObject({ Authorization: 'Bearer server-secret' }); - expect(JSON.stringify(cache.diagnostics())).not.toContain('secret'); - expect(options?.redirect).toBe('error'); - expect(console.warn).not.toHaveBeenCalled(); - }); - - it('supports public requests without consuming PUBLIC_GITHUB_TOKEN', async () => { - vi.stubEnv('PUBLIC_GITHUB_TOKEN', 'public-secret'); - const fetchMock = vi.fn().mockResolvedValue(page([ada])); - vi.stubGlobal('fetch', fetchMock); - await createContributorCache().get('microsoft/aspire'); - expect(fetchMock.mock.calls[0][1]?.headers).not.toHaveProperty('Authorization'); - }); - - it('preserves HTTPS_PROXY routing without replacing global fetch or exposing proxy credentials', async () => { - vi.stubEnv('HTTPS_PROXY', 'http://proxy-user:proxy-secret@127.0.0.1:3128'); - vi.stubEnv('GITHUB_TOKEN', 'server-secret'); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - vi.mocked(proxyFetch).mockResolvedValue({ - ok: false, - status: 403, - } as Awaited>); - const destroy = vi.spyOn(HttpsProxyAgent.prototype, 'destroy'); - const cache = createContributorCache(); - expect(await cache.get('microsoft/aspire')) - .toEqual({ status: 'unavailable', reason: 'http', httpStatus: 403 }); - await cache.get('microsoft/aspire'); - expect(proxyFetch).toHaveBeenCalledTimes(1); - expect(fetchMock).not.toHaveBeenCalled(); - expect(globalThis.fetch).toBe(fetchMock); - const [url, options] = vi.mocked(proxyFetch).mock.calls[0]; - expect(url).toBe(`${endpoint}?per_page=100&page=1`); - expect(options?.headers).toMatchObject({ Authorization: 'Bearer server-secret' }); - expect(options?.agent).toBeInstanceOf(HttpsProxyAgent); - expect(destroy).toHaveBeenCalledTimes(1); - const warning = vi.mocked(console.warn).mock.calls.flat().join(' '); - expect(warning).not.toContain('secret'); - expect(warning).not.toContain('proxy-user'); - }); - - it.each(['https_proxy', 'HTTP_PROXY', 'http_proxy'])('supports existing proxy fallback %s', async (name) => { - for (const key of ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy']) { - vi.stubEnv(key, undefined); - } - vi.stubEnv(name, 'http://127.0.0.1:3128'); - vi.stubGlobal('fetch', vi.fn()); - vi.mocked(proxyFetch).mockResolvedValue({ - ok: true, - status: 204, - } as Awaited>); - expect(await createContributorCache().get('microsoft/aspire')) - .toEqual({ status: 'available', contributors: [] }); - expect(proxyFetch).toHaveBeenCalledTimes(1); - expect(globalThis.fetch).not.toHaveBeenCalled(); - }); -}); - -describe('bounded unavailable outcomes', () => { - it.each([401, 403, 404, 429])('does not retry HTTP %i, and warns once per repository', async (status) => { - const fetchMock = vi.fn().mockResolvedValue(new Response('private error', { status })); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - const first = await cache.get('microsoft/aspire'); - expect(first).toEqual({ status: 'unavailable', reason: 'http', httpStatus: status }); - expect(await cache.get('Microsoft/Aspire')).toBe(first); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(console.warn).toHaveBeenCalledTimes(1); - expect(vi.mocked(console.warn).mock.calls.flat().join(' ')).not.toContain('private error'); - }); - - it('retries a transient error once, then retains a complete success', async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(null, { status: 503 })) - .mockResolvedValueOnce(page([ada])); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - expect(await cache.get('microsoft/aspire')).toEqual({ status: 'available', contributors: [ada] }); - await cache.get('microsoft/aspire'); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(console.warn).not.toHaveBeenCalled(); - }); - - it('exhausts the server-error retry budget and retains an unavailable outcome', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 503 })); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - const unavailable = await cache.get('microsoft/aspire'); - expect(unavailable).toEqual({ status: 'unavailable', reason: 'http', httpStatus: 503 }); - expect(await cache.get('microsoft/aspire')).toBe(unavailable); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(console.warn).toHaveBeenCalledTimes(1); - }); - - it('exhausts the network retry budget without logging the thrown error or token', async () => { - vi.stubEnv('GITHUB_TOKEN', 'server-secret'); - const fetchMock = vi.fn().mockRejectedValue(new Error('server-secret with private data')); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - expect(await cache.get('microsoft/aspire')).toEqual({ status: 'unavailable', reason: 'network' }); - await cache.get('microsoft/aspire'); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(console.warn).toHaveBeenCalledExactlyOnceWith( - '[contributors] microsoft/aspire: unavailable (network); using the GitHub contributors link.', - ); - }); - - it('never returns partial pages when a later page fails', async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce(page([ada], `${endpoint}?per_page=100&page=2`)) - .mockResolvedValueOnce(new Response(null, { status: 403 })); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - const result = await cache.get('microsoft/aspire'); - expect(result).toEqual({ status: 'unavailable', reason: 'http', httpStatus: 403 }); - expect(result).not.toHaveProperty('contributors'); - expect(await cache.get('microsoft/aspire')).toBe(result); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('restarts pagination atomically on a transient later-page failure', async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce(page([ada], `${endpoint}?per_page=100&page=2`)) - .mockResolvedValueOnce(new Response(null, { status: 502 })) - .mockResolvedValueOnce(page([grace])); - vi.stubGlobal('fetch', fetchMock); - expect(await createContributorCache().get('microsoft/aspire')) - .toEqual({ status: 'available', contributors: [grace] }); - expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ - `${endpoint}?per_page=100&page=1`, - `${endpoint}?per_page=100&page=2`, - `${endpoint}?per_page=100&page=1`, - ]); - }); - - it.each([ - {}, [{ id: 1 }], [{ id: -1, login: 'ada' }], [{ id: 1, login: '../unsafe' }], - [{ id: 1, login: 'ada' }, null], - ])('rejects malformed contributor data without caching a partial success: %j', async (data) => { - const fetchMock = vi.fn().mockResolvedValue(page(data)); - vi.stubGlobal('fetch', fetchMock); - expect(await createContributorCache().get('microsoft/aspire')) - .toEqual({ status: 'unavailable', reason: 'invalid-response' }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('rejects malformed JSON', async () => { - const fetchMock = vi.fn().mockResolvedValue(new Response('{not json')); - vi.stubGlobal('fetch', fetchMock); - expect(await createContributorCache().get('microsoft/aspire')) - .toEqual({ status: 'unavailable', reason: 'invalid-response' }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it.each([ - 'https://example.com/?per_page=100&page=2', - 'https://example.com/repositories/696529789/contributors?per_page=100&page=2', - 'https://api.github.com/repositories/not-numeric/contributors?per_page=100&page=2', - 'https://api.github.com/repos/another/repository/contributors?per_page=100&page=2', - `${endpoint}?per_page=100&page=1`, - `${endpoint}?per_page=100&page=2&token=secret`, - ])('rejects unsafe or looping pagination links: %s', async (next) => { - const fetchMock = vi.fn().mockResolvedValue(page([ada], next)); - vi.stubGlobal('fetch', fetchMock); - expect(await createContributorCache().get('microsoft/aspire')) - .toEqual({ status: 'unavailable', reason: 'invalid-response' }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('bounds hung fetches to two 10-second attempts and retains the unavailable result', async () => { - vi.useFakeTimers(); - const signals: AbortSignal[] = []; - const fetchMock = vi.fn().mockImplementation((_url, options) => { - signals.push(options!.signal!); - return new Promise(() => {}); - }); - vi.stubGlobal('fetch', fetchMock); - const cache = createContributorCache(); - const pending = cache.get('microsoft/aspire'); - await vi.advanceTimersByTimeAsync(20_000); - expect(await pending).toEqual({ status: 'unavailable', reason: 'timeout' }); - expect(await cache.get('microsoft/aspire')).toBe(await pending); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(signals.every((signal) => signal.aborted)).toBe(true); - expect(vi.getTimerCount()).toBe(0); - }); - - it('also bounds stalled body reads', async () => { - vi.useFakeTimers(); - const response = page([ada]); - vi.spyOn(response, 'json').mockImplementation(() => new Promise(() => {})); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)); - const pending = createContributorCache().get('microsoft/aspire'); - await vi.advanceTimersByTimeAsync(20_000); - expect(await pending).toEqual({ status: 'unavailable', reason: 'timeout' }); - }); - - it('does not continue pagination if a timed-out fetch eventually resolves', async () => { - vi.useFakeTimers(); - let finishFirst: (response: Response) => void = () => {}; - const fetchMock = vi.fn() - .mockImplementationOnce(() => new Promise((resolve) => { finishFirst = resolve; })) - .mockResolvedValueOnce(page([grace])); - vi.stubGlobal('fetch', fetchMock); - const pending = createContributorCache().get('microsoft/aspire'); - await vi.advanceTimersByTimeAsync(10_000); - expect(await pending).toEqual({ status: 'available', contributors: [grace] }); - finishFirst(page([ada], `${endpoint}?per_page=100&page=2`)); - await vi.advanceTimersByTimeAsync(0); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('treats the pagination safety limit as unavailable rather than a truncated success', async () => { - const fetchMock = vi.fn().mockImplementation((url) => { - const currentPage = Number(new URL(url instanceof Request ? url.url : url).searchParams.get('page')); - return Promise.resolve(page([ada], `${endpoint}?per_page=100&page=${currentPage + 1}`)); - }); - vi.stubGlobal('fetch', fetchMock); - expect(await createContributorCache().get('microsoft/aspire')) - .toEqual({ status: 'unavailable', reason: 'invalid-response' }); - expect(fetchMock).toHaveBeenCalledTimes(100); - }); - - it('rejects invalid repository input before I/O', () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - expect(() => createContributorCache().get('microsoft/aspire?token=secret')).toThrow(TypeError); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - -describe('Astro build lifetime', () => { - it('shares production data across separately evaluated config and prerender modules', async () => { - const fetchMock = vi.fn().mockResolvedValue(page([ada])); - vi.stubGlobal('fetch', fetchMock); - lifecycle('astro:build:start'); - const original = getContributors('microsoft/aspire'); - vi.resetModules(); - const prerenderCopy = await import('../../src/utils/contributors'); - expect(prerenderCopy.getContributors('microsoft/aspire')).toBe(original); - await original; - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('retries failed repositories in the next build, not on every render', async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(null, { status: 429 })) - .mockResolvedValueOnce(page([ada])); - vi.stubGlobal('fetch', fetchMock); - lifecycle('astro:build:start'); - expect((await getContributors('microsoft/aspire')).status).toBe('unavailable'); - expect((await getContributors('microsoft/aspire')).status).toBe('unavailable'); - lifecycle('astro:build:done'); - lifecycle('astro:build:start'); - expect(await getContributors('microsoft/aspire')).toEqual({ status: 'available', contributors: [ada] }); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('does not reuse successful results across builds, development requests, or failed-build restarts', async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce(page([ada])) - .mockResolvedValueOnce(page([grace])) - .mockResolvedValueOnce(page([ada])) - .mockResolvedValueOnce(page([grace])); - vi.stubGlobal('fetch', fetchMock); - lifecycle('astro:build:start'); - expect(await getContributors('microsoft/aspire')).toMatchObject({ contributors: [ada] }); - lifecycle('astro:build:start'); - expect(await getContributors('microsoft/aspire')).toMatchObject({ contributors: [grace] }); - lifecycle('astro:config:setup'); - expect(await getContributors('microsoft/aspire')).toMatchObject({ contributors: [ada] }); - expect(await getContributors('microsoft/aspire')).toMatchObject({ contributors: [grace] }); - expect(fetchMock).toHaveBeenCalledTimes(4); - }); - - it('emits only cache counters when build timing is enabled', async () => { - vi.stubEnv('BUILD_TIMING', '1'); - vi.spyOn(console, 'info').mockImplementation(() => {}); - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(page([ada]))); - lifecycle('astro:build:start'); - await getContributors('microsoft/aspire'); - await getContributors('microsoft/aspire'); - lifecycle('astro:build:done'); - expect(console.info).toHaveBeenCalledExactlyOnceWith( - '[contributors] {"repositories":1,"requests":1,"cacheHits":1,"unavailable":0}', - ); - }); -}); From 8820dca5f41969d62c1b1b785f53836c87a06ddd Mon Sep 17 00:00:00 2001 From: webmekanic Date: Wed, 30 Sep 2026 23:03:06 +0100 Subject: [PATCH 2/3] Render ContributorList from committed data; remove build-time GitHub API calls --- src/frontend/astro.config.mjs | 2 - .../src/components/ContributorList.astro | 62 +++--- src/frontend/src/content/i18n/en.json | 4 - .../unit/contributor-list.vitest.test.ts | 210 ++++++++++-------- 4 files changed, 150 insertions(+), 128 deletions(-) diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs index 87f40a0ae..e7b697807 100644 --- a/src/frontend/astro.config.mjs +++ b/src/frontend/astro.config.mjs @@ -22,7 +22,6 @@ import starlightLlmsTxt from 'starlight-llms-txt'; import starlightSidebarTopics from 'starlight-sidebar-topics'; import starlightPageActions from 'starlight-page-actions'; import buildTiming from './config/build-timing.mjs'; -import { contributorCacheIntegration } from './src/utils/contributors.ts'; import UnoCSS from 'unocss/astro'; import Icons from 'starlight-plugin-icons'; @@ -87,7 +86,6 @@ export default defineConfig({ }, redirects: redirects, integrations: [ - contributorCacheIntegration(), UnoCSS(), Icons({ extractSafelist: true, diff --git a/src/frontend/src/components/ContributorList.astro b/src/frontend/src/components/ContributorList.astro index cc2d709fe..d5c501c9c 100644 --- a/src/frontend/src/components/ContributorList.astro +++ b/src/frontend/src/components/ContributorList.astro @@ -1,5 +1,5 @@ --- -import { getContributors } from '@utils/contributors'; +import contributorData from '@data/github-contributors.json'; export interface Props { githubRepo: string; @@ -8,41 +8,35 @@ export interface Props { } const { githubRepo, ignore = [], focusColor } = Astro.props; -const result = await getContributors(githubRepo); -const contributors = result.status === 'available' - ? result.contributors.filter(({ login }) => !ignore.includes(login)) - : []; +const repoContributors = (contributorData as Record)[ + githubRepo.trim().toLowerCase() +]; +if (!repoContributors) { + throw new Error( + `No contributor data for "${githubRepo}". Add it to scripts/update-contributors.ts and run \`pnpm update:contributors\`.`, + ); +} +const contributors = repoContributors.filter(({ login }) => !ignore.includes(login)); --- -{ - result.status === 'unavailable' ? ( -

- {Astro.locals.t('contributors.unavailable')}{' '} - - {Astro.locals.t('contributors.viewOnGitHub')} - -

- ) : ( -
-
    - {contributors.map(({ id, login }) => ( -
  • - - {login} - -
  • - ))} -
-
- ) -} +
+
    + {contributors.map(({ id, login }) => ( +
  • + + {login} + +
  • + ))} +
+