diff --git a/.github/instructions/astro.instructions.md b/.github/instructions/astro.instructions.md index a6b0e3aa6..09dab40d2 100644 --- a/.github/instructions/astro.instructions.md +++ b/.github/instructions/astro.instructions.md @@ -366,6 +366,7 @@ Sidebar topics are defined in `config/sidebar/` as separate modules and aggregat | `samples.json` | Sample app definitions with tags and thumbnails | | `testimonials.json` | Developer testimonials | | `github-stats.json` | GitHub repository statistics | +| `github-contributors.json` | Contributors page avatars (`pnpm update:contributors`); the build never calls the GitHub API | | `pkgs/*.json` | Per-package API reference schemas | ## Cookie Consent @@ -434,4 +435,5 @@ If a banner is present, dismiss it before any screenshot or visual verification. | `pnpm format` | Prettier formatting | | `pnpm update:integrations` | Sync NuGet integration data | | `pnpm update:samples` | Sync sample data from GitHub | +| `pnpm update:contributors` | Refresh Contributors page data from GitHub | | `pnpm update:all` | Run all data updates | \ No newline at end of file diff --git a/.github/workflows/update-integration-data.yml b/.github/workflows/update-integration-data.yml index 2f3ef50a8..7b8a4fe0e 100644 --- a/.github/workflows/update-integration-data.yml +++ b/.github/workflows/update-integration-data.yml @@ -80,6 +80,8 @@ jobs: - name: Update integration data and official blog archive id: update shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} run: ./src/frontend/scripts/update-integration-data.ps1 - name: Configure git identity @@ -126,6 +128,7 @@ jobs: src/frontend/src/data/aspire-integrations.json \ src/frontend/src/data/integration-docs.json \ src/frontend/src/data/github-stats.json \ + src/frontend/src/data/github-contributors.json \ src/frontend/src/data/samples.json \ src/frontend/src/data/aspire-blog-posts.json \ src/frontend/src/assets/samples \ 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/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/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index e797f2e7a..27acdc661 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -10,7 +10,7 @@ reliable scripting that runs identically in CI and locally. Phases: - 1. `pnpm update:all` — integration metadata, GitHub stats, samples, official blog archive. + 1. `pnpm update:all` — integration metadata, GitHub stats, contributors, samples, official blog archive. Reconciles documentation mappings, then runs structured-data tests. 2. Version-change detection — compares the committed aspire-integrations.json against the freshly written one by package @@ -74,6 +74,7 @@ $AllowedPaths = @( 'src/frontend/src/data/aspire-integrations.json', 'src/frontend/src/data/integration-docs.json', 'src/frontend/src/data/github-stats.json', + 'src/frontend/src/data/github-contributors.json', 'src/frontend/src/data/samples.json', 'src/frontend/src/data/aspire-blog-posts.json', 'src/frontend/src/assets/samples/', @@ -570,6 +571,7 @@ $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine("- ``src/frontend/src/data/aspire-integrations.json`` — latest package information") [void]$sb.AppendLine("- ``src/frontend/src/data/integration-docs.json`` — documentation mappings, when packages are removed") [void]$sb.AppendLine("- ``src/frontend/src/data/github-stats.json`` — repository statistics") +[void]$sb.AppendLine("- ``src/frontend/src/data/github-contributors.json`` — Contributors page avatars, when changed") [void]$sb.AppendLine("- ``src/frontend/src/data/samples.json`` — sample metadata, when changed") [void]$sb.AppendLine("- ``src/frontend/src/data/aspire-blog-posts.json`` — complete official Aspire blog metadata, when changed") [void]$sb.AppendLine("- ``src/frontend/src/assets/samples/`` — sample thumbnails, when changed") 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} + +
  • + ))} +
+