Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/instructions/astro.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
3 changes: 3 additions & 0 deletions .github/workflows/update-integration-data.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
2 changes: 0 additions & 2 deletions src/frontend/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -87,7 +86,6 @@ export default defineConfig({
},
redirects: redirects,
integrations: [
contributorCacheIntegration(),
UnoCSS(),
Icons({
extractSafelist: true,
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/scripts/check-data-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
152 changes: 152 additions & 0 deletions src/frontend/scripts/update-contributors.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>; redirect: 'error';
signal: AbortSignal
},
) => Promise<{
ok: boolean;
status: number;
headers: {
get(name: string): string | null
};
json(): Promise<unknown>;
}>;

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<Contributor[]> {
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<void> {
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<string, Contributor[]> = {};
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;
});
}
4 changes: 3 additions & 1 deletion src/frontend/scripts/update-integration-data.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/',
Expand Down Expand Up @@ -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")
Expand Down
62 changes: 28 additions & 34 deletions src/frontend/src/components/ContributorList.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
import { getContributors } from '@utils/contributors';
import contributorData from '@data/github-contributors.json';

export interface Props {
githubRepo: string;
Expand All @@ -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<string, { id: number; login: string }[]>)[
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' ? (
<p class="contributors-unavailable">
{Astro.locals.t('contributors.unavailable')}{' '}
<a href={`https://github.com/${githubRepo.trim()}/graphs/contributors`}>
{Astro.locals.t('contributors.viewOnGitHub')}
</a>
</p>
) : (
<div style={focusColor ? { '--avatar-focus-color': focusColor } : {}}>
<ul class="avatar-list">
{contributors.map(({ id, login }) => (
<li>
<a href={`https://github.com/${login}`}>
<img
alt={login}
title={login}
width="48"
height="48"
src={`https://avatars.githubusercontent.com/u/${id}?s=64`}
loading="lazy"
/>
</a>
</li>
))}
</ul>
</div>
)
}
<div style={focusColor ? { '--avatar-focus-color': focusColor } : {}}>
<ul class="avatar-list">
{contributors.map(({ id, login }) => (
<li>
<a href={`https://github.com/${login}`}>
<img
alt={login}
title={login}
width="48"
height="48"
src={`https://avatars.githubusercontent.com/u/${id}?s=64`}
loading="lazy"
/>
</a>
</li>
))}
</ul>
</div>

<style>
.avatar-list {
Expand Down
4 changes: 0 additions & 4 deletions src/frontend/src/content/i18n/en.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
{
"contributors": {
"unavailable": "The contributor list is temporarily unavailable.",
"viewOnGitHub": "View contributors on GitHub."
},
"catalogSearch": {
"clearSearch": "Clear search",
"clearFilters": "Clear filters",
Expand Down
Loading
Loading