| #!/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); |
| }); |