Files
2026-07-13 13:32:05 +08:00

409 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* Build-time generator: walks `content/docs/**` and produces
* `lib/generated/contributors.json`, keyed by each file's repo-relative
* path. Values are sorted by commit count desc.
*
* {
* "content/docs/getting-started.mdx": [
* { "login": "penguine-ip", "name": "Jeffrey Ip",
* "avatarUrl": "…", "url": "…", "commits": 12 },
* …
* ]
* }
*
* How the GitHub linking works: `git log` gives us (email, name) but no
* GitHub handle. We resolve email→login via `GET /repos/{owner}/{repo}/
* commits/{sha}` — GitHub does the email-to-user lookup server-side and
* returns `author.login` / `author.avatar_url` / `author.html_url`. One
* API call per unique email (not per commit), cached in
* `lib/generated/.contributors-cache.json` so subsequent runs are ~free.
*
* Bots (`…[bot]` login suffix) are excluded. Commits whose email doesn't
* resolve to a GitHub user are dropped — no point showing a ghost.
*
* Failure modes handled gracefully (all exit 0 so builds don't break):
* - Not in a git repo, a shallow git checkout, or no commits touch
* content/docs: keeps the previous JSON file if one exists, otherwise
* writes `{}`. This matters for hosted builds that may not expose the
* full git history needed by `git log --follow`.
* - GitHub API 403 / rate-limited: keeps existing cache entries,
* skips the uncached emails, warns.
*
* Env:
* GITHUB_TOKEN Optional; bumps GitHub API rate limit from 60/hr to
* 5000/hr. In CI, wire the built-in `GITHUB_TOKEN`.
*
* Run: `npm run contributors` (also runs pre-build).
*/
import { execSync } from "node:child_process";
import {
readdirSync,
readFileSync,
writeFileSync,
mkdirSync,
existsSync,
statSync,
} from "node:fs";
import { join, relative } from "node:path";
// Sections that display a contributors list. Changelog and blog are
// intentionally omitted — blog posts have their own author byline, and
// changelog entries are attributed by release.
const CONTENT_DIRS = [
"content/docs",
"content/guides",
"content/tutorials",
"content/integrations",
];
const OUTPUT = "lib/generated/contributors.json";
const CACHE = "lib/generated/.contributors-cache.json";
const REPO_CONTRIBUTORS = "lib/generated/repo-contributors.json";
// Pages that replace older docs should keep the original page attribution.
const PAGE_CONTRIBUTOR_ALIASES = {
"content/docs/introduction.mdx": "content/docs/getting-started.mdx",
};
// Some commit emails are not linked to a public GitHub identity, so the
// commit API returns `author: null`. For those cases we maintain a tiny
// email->login fallback and hydrate the avatar/profile URL from the
// repo-wide contributors manifest.
const AUTHOR_LOGIN_ALIASES = {
"jeffreyip@confident-ai.com": "penguine-ip",
};
// Read repo coords from lib/shared.ts so there's one source of truth.
// Parsing literals avoids having to compile the TS file at script time.
function readGitConfig() {
const src = readFileSync("lib/shared.ts", "utf8");
const user = src.match(/user:\s*['"]([^'"]+)['"]/)?.[1];
const repo = src.match(/repo:\s*['"]([^'"]+)['"]/)?.[1];
if (!user || !repo)
throw new Error("could not parse gitConfig from lib/shared.ts");
return { user, repo };
}
function tryExec(cmd) {
try {
return execSync(cmd, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return null;
}
}
function inGitRepo() {
return tryExec("git rev-parse --is-inside-work-tree") === "true";
}
function inShallowRepo() {
return tryExec("git rev-parse --is-shallow-repository") === "true";
}
function walkMdx(dir, acc = []) {
if (!existsSync(dir)) return acc;
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const s = statSync(full);
if (s.isDirectory()) walkMdx(full, acc);
else if (entry.endsWith(".mdx") || entry.endsWith(".md")) acc.push(full);
}
return acc;
}
// `git log --follow` so renames don't reset attribution. %x09 = tab, so
// we don't have to worry about author names containing our delimiter.
function gitCommitsForFile(file) {
const out = tryExec(
`git log --follow --format="%H%x09%ae%x09%an" -- "${file}"`
);
if (!out) return [];
return out
.split("\n")
.filter(Boolean)
.map((line) => {
const [sha, email, name] = line.split("\t");
return { sha, email: email.toLowerCase(), name };
});
}
function loadCache() {
if (!existsSync(CACHE)) return {};
try {
return JSON.parse(readFileSync(CACHE, "utf8"));
} catch {
return {};
}
}
function loadRepoContributors() {
if (!existsSync(REPO_CONTRIBUTORS)) return {};
try {
const list = JSON.parse(readFileSync(REPO_CONTRIBUTORS, "utf8"));
return Object.fromEntries(
Array.isArray(list)
? list
.filter((entry) => entry?.login && entry?.avatarUrl && entry?.url)
.map((entry) => [entry.login, entry])
: []
);
} catch {
return {};
}
}
function loadExistingManifest() {
if (!existsSync(OUTPUT)) return {};
try {
const manifest = JSON.parse(readFileSync(OUTPUT, "utf8"));
return manifest && typeof manifest === "object" && !Array.isArray(manifest)
? manifest
: {};
} catch {
return {};
}
}
function saveJson(path, obj) {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
}
function keepExistingOrWriteEmpty(path) {
if (existsSync(path)) {
console.warn(`[contributors] keeping existing ${path}.`);
return;
}
saveJson(path, {});
}
function findCommitMetaForEmail(perFile, email) {
for (const byEmail of perFile.values()) {
const entry = byEmail.get(email);
if (entry) return entry;
}
return null;
}
async function resolveAuthor(sha, { user, repo, token }) {
const headers = {
"User-Agent": "deepeval-docs-contributors",
Accept: "application/vnd.github+json",
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(
`https://api.github.com/repos/${user}/${repo}/commits/${sha}`,
{ headers }
);
if (res.status === 403 || res.status === 429) throw new Error(`rate_limited`);
if (!res.ok) return null; // 404 (commit not on this remote yet) → treat as unresolvable
const body = await res.json();
const a = body?.author;
if (!a?.login) return null; // commit exists but email isn't linked to a GH user
return {
login: a.login,
// Prefer the commit-author display name; fall back to the GH user's
// `name` field. The API's `user.name` endpoint would give us the
// canonical one but costs another request — not worth it.
name: body?.commit?.author?.name || a.login,
avatarUrl: a.avatar_url,
url: a.html_url,
};
}
function isBot(author) {
return (
author?.login?.endsWith("[bot]") || /\[bot\]$/.test(author?.name ?? "")
);
}
async function main() {
if (!inGitRepo()) {
console.warn(
"[contributors] not inside a git repo; cannot regenerate contributors manifest."
);
keepExistingOrWriteEmpty(OUTPUT);
return;
}
if (inShallowRepo()) {
console.warn(
"[contributors] shallow git checkout; keeping existing contributors manifest."
);
keepExistingOrWriteEmpty(OUTPUT);
return;
}
const { user, repo } = readGitConfig();
const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN;
const cache = loadCache();
const repoContributors = loadRepoContributors();
const files = CONTENT_DIRS.flatMap((d) => walkMdx(d));
if (files.length === 0) {
console.warn(
`[contributors] no MDX files found under ${CONTENT_DIRS.join(", ")}.`
);
keepExistingOrWriteEmpty(OUTPUT);
return;
}
const fileSet = new Set(files.map((file) => relative(".", file)));
// First pass: gather per-file commit metadata (all local, no network).
const perFile = new Map(); // relPath → Map<email, { name, commits, sha }>
for (const file of files) {
const rel = relative(".", file);
const commits = gitCommitsForFile(file);
if (commits.length === 0) continue;
const byEmail = new Map();
for (const c of commits) {
const prev = byEmail.get(c.email) ?? {
name: c.name,
commits: 0,
sha: c.sha,
};
prev.commits += 1;
byEmail.set(c.email, prev);
}
perFile.set(rel, byEmail);
}
// Second pass: resolve every unseen email to a GitHub user.
const uniqueEmails = new Set();
for (const byEmail of perFile.values())
for (const e of byEmail.keys()) uniqueEmails.add(e);
let resolved = 0,
aliased = 0,
skipped = 0,
bot = 0,
rateLimited = false;
for (const email of uniqueEmails) {
if (
cache[email]?.name &&
cache[email]?.login &&
cache[email]?.avatarUrl &&
cache[email]?.url
) {
continue;
}
const aliasLogin = AUTHOR_LOGIN_ALIASES[email];
if (aliasLogin && repoContributors[aliasLogin]) {
const meta = findCommitMetaForEmail(perFile, email);
cache[email] = {
login: aliasLogin,
name: meta?.name || aliasLogin,
avatarUrl: repoContributors[aliasLogin].avatarUrl,
url: repoContributors[aliasLogin].url,
};
aliased += 1;
continue;
}
if (email in cache) continue;
// Use any commit SHA associated with this email (they all resolve
// to the same GH user for a given email).
let sha;
for (const byEmail of perFile.values()) {
const entry = byEmail.get(email);
if (entry) {
sha = entry.sha;
break;
}
}
if (!sha) continue;
try {
const author = await resolveAuthor(sha, { user, repo, token });
if (author && isBot(author)) {
cache[email] = null;
bot += 1;
continue;
}
cache[email] = author;
if (author) resolved += 1;
else skipped += 1;
} catch (e) {
if (e.message === "rate_limited") {
rateLimited = true;
console.warn(
"[contributors] GitHub API rate-limited; stopping resolution. Set GITHUB_TOKEN to raise the ceiling."
);
break;
}
console.warn(`[contributors] failed resolving ${email}: ${e.message}`);
skipped += 1;
}
}
saveJson(CACHE, cache);
// Third pass: materialize the manifest using the (now populated) cache.
const manifest = {};
for (const [rel, byEmail] of perFile) {
const list = [];
const seenLogins = new Set();
for (const [email, meta] of byEmail) {
const author = cache[email];
if (!author) continue;
// Same GH user may have pushed from multiple emails — collapse.
if (seenLogins.has(author.login)) {
const existing = list.find((x) => x.login === author.login);
if (existing) existing.commits += meta.commits;
continue;
}
seenLogins.add(author.login);
list.push({
...author,
name: author.name || meta.name || author.login,
commits: meta.commits,
});
}
// Sort real committers by commit count, then alphabetical.
list.sort(
(a, b) => b.commits - a.commits || a.login.localeCompare(b.login)
);
if (list.length > 0) manifest[rel] = list;
}
// Hosted builds can have incomplete git history without being marked as
// shallow. Never let a partial regeneration replace a richer checked-in
// manifest entry for a page that still exists.
const existingManifest = loadExistingManifest();
let preserved = 0;
for (const [rel, existingList] of Object.entries(existingManifest)) {
if (!fileSet.has(rel) || !Array.isArray(existingList)) continue;
const generatedList = manifest[rel];
if (
!Array.isArray(generatedList) ||
existingList.length > generatedList.length
) {
manifest[rel] = existingList;
preserved += 1;
}
}
for (const [target, source] of Object.entries(PAGE_CONTRIBUTOR_ALIASES)) {
if (!fileSet.has(target) || !Array.isArray(manifest[source])) continue;
manifest[target] = manifest[source].map((entry) => ({ ...entry }));
}
saveJson(OUTPUT, manifest);
console.log(
`[contributors] ${Object.keys(manifest).length} pages, ` +
`resolved ${resolved} new author(s), aliased ${aliased}, skipped ${skipped}, bots filtered ${bot}, ` +
`preserved ${preserved} existing page(s)` +
(rateLimited ? " (rate-limited; re-run with GITHUB_TOKEN)" : "") +
"."
);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});