409 lines
12 KiB
JavaScript
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);
|
|
});
|