blob: f37f66d35d78902a468469bc60111798e8140b1b [file]
#!/usr/bin/env node
// Licensed to the Apache Software Foundation (ASF) under one or more
// contributor license agreements. See the NOTICE file distributed with this
// work for additional information regarding copyright ownership. The ASF
// licenses this file to you under the Apache License, Version 2.0; you may not
// use this file except in compliance with the License. You may obtain a copy
// of the License at http://www.apache.org/licenses/LICENSE-2.0
//
// Resolve the apache/magpie committer/PMC roster from GitHub and download each
// member's avatar locally, then regenerate src/data/community.json. The landing
// page renders those avatars from /community-avatars/ — no GitHub widgets, no
// hotlinking.
//
// Source of truth: the `apache/<team>` GitHub team membership, which mirrors the
// PMC/committers. Run on demand via the `sync-community` prek hook:
// prek run sync-community --hook-stage manual --all-files
// or directly:
// node scripts/sync-community.mjs
//
// Needs a GitHub token with read:org — taken from GH_TOKEN, GITHUB_TOKEN, or
// `gh auth token`. On ANY failure it leaves the committed community.json and
// avatars untouched and exits 0, so it never blocks a commit or a build.
import { writeFile, mkdir, readdir, unlink } from "node:fs/promises";
import { execSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const ORG = process.env.MAGPIE_GH_ORG || "apache";
// The committer team mirrors the PMC/committer roster (apache/magpie-committers).
const TEAM = process.env.MAGPIE_GH_TEAM || "magpie-committers";
const AVATAR_SIZE = 160;
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const DATA = join(ROOT, "src", "data", "community.json");
const AVATAR_DIR = join(ROOT, "public", "community-avatars");
function resolveToken() {
if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
try {
return execSync("gh auth token", { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return null;
}
}
// Route through an HTTP(S) proxy when one is configured (sandboxed / CI envs).
async function installProxy() {
const url = process.env.HTTPS_PROXY || process.env.https_proxy;
if (!url) return;
const { ProxyAgent, setGlobalDispatcher } = await import("undici");
setGlobalDispatcher(new ProxyAgent(url));
}
async function gh(path, token) {
const res = await fetch(`https://api.github.com${path}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "magpie-site-sync-community",
"X-GitHub-Api-Version": "2022-11-28",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (!res.ok) {
throw new Error(`GitHub ${path} -> ${res.status} ${res.statusText}`);
}
return res.json();
}
async function main() {
await installProxy();
const token = resolveToken();
if (!token) {
throw new Error(
"no GitHub token (set GH_TOKEN / GITHUB_TOKEN or run `gh auth login`)",
);
}
console.log(`-> Listing ${ORG}/${TEAM} team members`);
const team = await gh(`/orgs/${ORG}/teams/${TEAM}/members?per_page=100`, token);
if (!Array.isArray(team) || team.length === 0) {
throw new Error(`${ORG}/${TEAM} team membership is empty`);
}
await mkdir(AVATAR_DIR, { recursive: true });
const members = [];
const keep = new Set();
for (const m of team) {
const user = await gh(`/users/${m.login}`, token);
const name = user.name || user.login;
const file = `${m.login}.png`;
const sep = user.avatar_url.includes("?") ? "&" : "?";
const img = await fetch(`${user.avatar_url}${sep}s=${AVATAR_SIZE}`);
let avatar = null;
if (img.ok) {
await writeFile(join(AVATAR_DIR, file), Buffer.from(await img.arrayBuffer()));
avatar = `/community-avatars/${file}`;
keep.add(file);
}
members.push({ name, github: m.login, url: user.html_url, avatar });
console.log(` - ${name} (@${m.login})`);
}
members.sort((a, b) => a.name.localeCompare(b.name));
// Drop avatars for people no longer on the team.
for (const f of await readdir(AVATAR_DIR)) {
if (f.endsWith(".png") && !keep.has(f)) await unlink(join(AVATAR_DIR, f));
}
const out = {
_comment:
"GENERATED by scripts/sync-community.mjs (prek hook 'sync-community'). Do not edit by hand.",
source: `github:${ORG}/${TEAM} team`,
count: members.length,
members,
};
await writeFile(DATA, `${JSON.stringify(out, null, 2)}\n`);
console.log(`OK wrote ${members.length} members -> ${DATA}`);
}
main().catch((err) => {
console.warn(`WARN sync-community skipped: ${err.message}`);
console.warn(" keeping the committed src/data/community.json and avatars.");
process.exit(0);
});