import { execFileSync } from "node:child_process";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const DEFAULT_REPOSITORY = "tianma-if/edgeever";
export const CONTRIBUTORS_START_MARKER = "";
export const CONTRIBUTORS_END_MARKER = "";
export const README_CONTRIBUTOR_SECTIONS = [
{ file: "README.md", heading: "## Contributors" },
{ file: "README.zh-CN.md", heading: "## 贡献者" },
{ file: "README.zh-TW.md", heading: "## 貢獻者" },
{ file: "README.ja.md", heading: "## コントリビューター" },
{ file: "README.pl-PL.md", heading: "## Współtwórcy" },
];
const isBotLogin = (login) =>
!login ||
/\[bot\]$/i.test(login) ||
login.toLowerCase() === "github-actions" ||
login.toLowerCase() === "dependabot";
export const circularAvatarUrl = (userId) =>
`https://images.weserv.nl/?url=https://avatars.githubusercontent.com/u/${userId}?v=4&h=96&w=96&fit=cover&mask=circle&maxage=7d`;
export const extractUserId = (user) => {
if (typeof user?.id === "number" && Number.isFinite(user.id)) {
return user.id;
}
const match = String(user?.avatar_url ?? user?.avatarUrl ?? "").match(
/avatars\.githubusercontent\.com\/u\/(\d+)/,
);
return match ? Number(match[1]) : null;
};
export const parseExistingReadmeContributors = (markdown) => {
const startIndex = markdown.indexOf(CONTRIBUTORS_START_MARKER);
const endIndex = markdown.indexOf(CONTRIBUTORS_END_MARKER);
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
return [];
}
const block = markdown.slice(startIndex, endIndex);
const results = [];
const pattern =
/]*> {
const byKey = new Map();
let insertionOrder = 0;
const upsert = (rawUser, contributions = 0) => {
const login = String(rawUser?.login ?? "").trim();
if (isBotLogin(login)) return;
const id = extractUserId(rawUser);
if (!id) return;
const key = login.toLowerCase();
const existing = byKey.get(key);
if (!existing) {
byKey.set(key, {
login,
id,
contributions: Math.max(1, Number(contributions) || 1),
order: insertionOrder++,
});
return;
}
existing.login = existing.login || login;
existing.id = existing.id || id;
existing.contributions = Math.max(
existing.contributions,
Number(contributions) || 1,
);
};
for (const item of directContributors) {
if (item?.type && item.type !== "User") continue;
upsert(item, item?.contributions ?? 1);
}
const sortedStats = [...statsContributors].sort(
(a, b) => (Number(b?.total) || 0) - (Number(a?.total) || 0),
);
for (const entry of sortedStats) {
if (!entry?.author) continue;
upsert(entry.author, entry.total ?? 1);
}
// GitHub's /_sidebar endpoint returns contributors ordered by rank (including Co-authored-by)
sidebarContributors.forEach((user, index) => {
const syntheticWeight = Math.max(1, 20 - index);
const key = String(user?.login ?? "").trim().toLowerCase();
if (byKey.has(key)) {
upsert(user, byKey.get(key).contributions);
} else {
upsert(user, syntheticWeight > 2 ? 2 : 1);
}
});
const commitCounts = new Map();
const commitUsers = new Map();
for (const commit of recentCommits) {
for (const party of [commit?.author, commit?.committer]) {
const login = String(party?.login ?? "").trim();
if (!login || login.toLowerCase() === "web-flow" || isBotLogin(login)) {
continue;
}
const key = login.toLowerCase();
commitCounts.set(key, (commitCounts.get(key) ?? 0) + 1);
if (!commitUsers.has(key)) {
commitUsers.set(key, party);
}
}
}
for (const [key, party] of commitUsers.entries()) {
upsert(party, commitCounts.get(key) ?? 1);
}
for (const user of existingContributors) {
upsert(user, 1);
}
for (const user of extraUsers) {
upsert(user, 1);
}
const ownerKey = ownerLogin.toLowerCase();
return [...byKey.values()].sort((a, b) => {
const aIsOwner = a.login.toLowerCase() === ownerKey;
const bIsOwner = b.login.toLowerCase() === ownerKey;
if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1;
if (b.contributions !== a.contributions) {
return b.contributions - a.contributions;
}
return a.order - b.order;
});
};
export const renderContributorsBlock = (contributors) => {
const links = contributors.map(
({ login, id }) =>
`
`,
);
return [
CONTRIBUTORS_START_MARKER,
'
', ...links, "
", CONTRIBUTORS_END_MARKER, ].join("\n"); }; export const upsertReadmeContributorsSection = ( markdown, { heading, contributors }, ) => { const block = renderContributorsBlock(contributors); const startIndex = markdown.indexOf(CONTRIBUTORS_START_MARKER); const endIndex = markdown.indexOf(CONTRIBUTORS_END_MARKER); if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) { const before = markdown.slice(0, startIndex); const after = markdown.slice(endIndex + CONTRIBUTORS_END_MARKER.length); return `${before}${block}${after}`; } const trimmed = markdown.replace(/\s+$/, ""); return `${trimmed}\n\n${heading}\n\n${block}\n`; }; const runGhJson = (args) => { const output = execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }).trim(); return output ? JSON.parse(output) : null; }; const runSidebarJson = (repository) => { const output = execFileSync( "curl", [ "-fsSL", "-H", "Accept: application/json", "-H", "X-Requested-With: XMLHttpRequest", `https://github.com/${repository}/_sidebar`, ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }, ).trim(); return output ? JSON.parse(output) : null; }; export const fetchRepositoryContributors = ({ repository = DEFAULT_REPOSITORY, extraLogins = [], existingContributors = [], ghJson = runGhJson, sidebarJson = runSidebarJson, } = {}) => { const ownerLogin = repository.split("/")[0] || "tianma-if"; const directRaw = ghJson([ "api", `repos/${repository}/contributors?per_page=100`, ]); const directContributors = Array.isArray(directRaw) ? directRaw : []; let statsContributors = []; try { const statsRaw = ghJson(["api", `repos/${repository}/stats/contributors`]); if (Array.isArray(statsRaw)) { statsContributors = statsRaw; } } catch { // stats/contributors may return 202 while GitHub recomputes cache; fall back to other sources } let sidebarContributors = []; try { const sidebarRaw = sidebarJson(repository); if (Array.isArray(sidebarRaw?.contributors?.contributors)) { sidebarContributors = sidebarRaw.contributors.contributors; } } catch { // ignore transient web sidebar errors } let recentCommits = []; try { const commitsRaw = ghJson([ "api", `repos/${repository}/commits?per_page=100`, ]); if (Array.isArray(commitsRaw)) { recentCommits = commitsRaw; } } catch { // ignore transient commit list errors } const knownKeys = new Set( [ ...directContributors.map((u) => u?.login), ...statsContributors.map((s) => s?.author?.login), ...sidebarContributors.map((u) => u?.login), ...recentCommits.map((c) => c?.author?.login), ...existingContributors.map((u) => u?.login), ] .filter(Boolean) .map((login) => String(login).toLowerCase()), ); const extraUsers = []; for (const rawLogin of extraLogins) { const login = String(rawLogin ?? "").trim().replace(/^@/, ""); if (!login || isBotLogin(login) || knownKeys.has(login.toLowerCase())) { continue; } try { const user = ghJson(["api", `users/${login}`]); if (user?.login && user?.id) { extraUsers.push(user); knownKeys.add(login.toLowerCase()); } } catch { // ignore unresolvable user } } return mergeContributorSources({ ownerLogin, sidebarContributors, directContributors, statsContributors, recentCommits, existingContributors, extraUsers, }); }; export const syncReadmeContributors = ({ repositoryRoot = resolve(import.meta.dir, ".."), repository = DEFAULT_REPOSITORY, extraLogins = [], contributors, ghJson, sidebarJson, } = {}) => { const primaryReadmePath = resolve(repositoryRoot, "README.md"); const existingContributors = existsSync(primaryReadmePath) ? parseExistingReadmeContributors(readFileSync(primaryReadmePath, "utf8")) : []; const resolvedContributors = contributors ?? fetchRepositoryContributors({ repository, extraLogins, existingContributors, ghJson, sidebarJson, }); if (!Array.isArray(resolvedContributors) || resolvedContributors.length === 0) { return { updatedFiles: [], contributors: [] }; } const updatedFiles = []; for (const { file, heading } of README_CONTRIBUTOR_SECTIONS) { const fullPath = resolve(repositoryRoot, file); if (!existsSync(fullPath)) continue; const current = readFileSync(fullPath, "utf8"); const next = upsertReadmeContributorsSection(current, { heading, contributors: resolvedContributors, }); if (next !== current) { writeFileSync(fullPath, next); updatedFiles.push(file); } } return { updatedFiles, contributors: resolvedContributors, }; }; if ( process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url) ) { const extraLogins = process.argv.slice(2).filter(Boolean); const { updatedFiles, contributors } = syncReadmeContributors({ extraLogins, }); console.log( `[contributors] synced ${contributors.length} contributors (${updatedFiles.length} README files updated)`, ); }