Files
Trilium/scripts/update-contributors.ts

224 lines
7.4 KiB
TypeScript
Raw Permalink Normal View History

2026-04-19 15:44:52 +03:00
import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
interface ContributorEntry {
name: string;
fullName?: string;
url: string;
role?: string;
}
interface ContributorFile {
contributors: ContributorEntry[];
}
interface ContributorInfo {
name: string;
2026-04-19 15:44:52 +03:00
fullName?: string;
email?: string;
commitCount: number;
url?: string;
}
2026-04-19 15:44:52 +03:00
interface ShowTableParams {
title: string;
comment?: string;
contributors: ContributorInfo[];
columns: (keyof ContributorInfo)[];
}
const TRANSLATION_PATHS = [
"apps/client/src/translations/",
"apps/server/src/assets/translations/"
];
/** Authors that are bots or automated tools, not real contributors. */
const EXCLUDED_AUTHORS = new Set([
"Languages add-on",
"Hosted Weblate",
"renovate[bot]"
]);
2026-04-19 15:44:52 +03:00
const NOREPLY_PATTERN = /^(?:\d+\+)?(.+)@users\.noreply\.github\.com$/;
/**
* Manual mapping for contributors whose git email doesn't reveal their
* GitHub username (i.e. no noreply email in .mailmap).
*/
const EMAIL_TO_GITHUB: Record<string, string> = {
"contact@eliandoran.me": "eliandoran",
"zadam.apps@gmail.com": "zadam",
"adorian@esevo.ro": "adoriandoran",
"jonfuller2012@gmail.com": "perfectra1n",
};
const CONTRIBUTORS_PATH = join(__dirname, "..", "contributors.json");
/**
* Resolves a GitHub username from an email address.
*
* 1. Checks the manual mapping.
* 2. Extracts from GitHub noreply emails (e.g. "12345+user@…").
* 3. Scans .mailmap for alternate emails that match the noreply pattern.
*/
function resolveGitHub(email: string, name: string): string | undefined {
if (EMAIL_TO_GITHUB[email]) return EMAIL_TO_GITHUB[email];
const noreply = email.match(NOREPLY_PATTERN);
if (noreply) return noreply[1];
// Grep .mailmap for alternate emails that match the noreply pattern
try {
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const mailmapContent = execSync(`grep -i "${escapedName}" .mailmap 2>/dev/null`).toString();
for (const line of mailmapContent.split("\n")) {
// Extract all emails from the line (inside angle brackets)
for (const [, email] of line.matchAll(/<([^>]+)>/g)) {
const match = email.match(NOREPLY_PATTERN);
if (match) return match[1];
}
}
} catch { /* no matches */ }
return undefined;
}
function parseShortlog(rawOutput: string): Map<string, { email: string; commitCount: number }> {
const result = new Map<string, { email: string; commitCount: number }>();
for (const line of rawOutput.split("\n")) {
const match = line.match(/^\s*(\d+)\s+(.+?)\s+<(.+)>$/);
if (match) {
result.set(match[2], { email: match[3], commitCount: parseInt(match[1]) });
}
}
return result;
}
2026-04-19 15:44:52 +03:00
async function main() {
const { developers } = listLocalGitContributors();
await listGitHubContributors();
updateContributorsJson(developers);
}
function listLocalGitContributors() {
const allOutput = execSync("git shortlog -sne --no-merges HEAD -- src/ apps/").toString();
const translationOutput = execSync(`git shortlog -sne --no-merges HEAD -- ${TRANSLATION_PATHS.join(" ")}`).toString();
const allContribs = parseShortlog(allOutput);
const translationContribs = parseShortlog(translationOutput);
const developers: ContributorInfo[] = [];
const translators: ContributorInfo[] = [];
2026-04-19 15:44:52 +03:00
const MIN_COMMITS = 100;
for (const [name, { email, commitCount }] of allContribs) {
if (EXCLUDED_AUTHORS.has(name)) continue;
const translationCommitCount = translationContribs.get(name)?.commitCount ?? 0;
const isTranslator = translationCommitCount > commitCount * 0.5;
2026-04-19 15:44:52 +03:00
const githubUsername = resolveGitHub(email, name);
const url = githubUsername ? `https://github.com/${githubUsername}` : undefined;
const entry: ContributorInfo = { name, email, commitCount, url };
if (isTranslator) {
2026-04-19 15:44:52 +03:00
if (commitCount >= 20) translators.push(entry);
} else if (commitCount >= MIN_COMMITS) {
developers.push(entry);
}
}
2026-04-19 15:44:52 +03:00
// showTable({
// title: "Local Git Contributors (Developers)",
// columns: ["name", "url", "commitCount"],
// contributors: developers
// });
// showTable({
// title: "Local Git Contributors (Translators)",
// comment: "Contributors where >50% of commits are to translation files.",
// columns: ["name", "url", "commitCount"],
// contributors: translators
// });
return { developers, translators };
}
async function listGitHubContributors() {
let list: any[] | null = null;
const response = await fetch("https://api.github.com/repos/TriliumNext/Trilium/contributors");
if (response.ok) {
list = await response.json();
} else {
console.error(`Unable to request the contributor list from GitHub. Reason: ${response.statusText}`);
}
if (!list) {
return;
}
const MIN_CONTRIBUTIONS = 125;
const contributors: ContributorInfo[] = list
.filter((c) => c.contributions >= MIN_CONTRIBUTIONS)
.map((c) => {
return {
name: c.login,
url: c.html_url,
commitCount: c.contributions
} as ContributorInfo;
});
// showTable({
// title: "GitHub Contributor List",
// comment: "Note: the GitHub list also include contributors that did not directly contribute to Trilium, but to submodules used in the Trilium's repo.",
// contributors: contributors,
// columns: ["name", "url", "commitCount"]
// });
}
/**
* Updates contributors.json, preserving pinned entries (those with special
* roles like lead-dev, original-dev) and regenerating the rest from git data.
*/
function updateContributorsJson(developers: ContributorInfo[]) {
// Read existing file to preserve pinned entries
const existing: ContributorFile = JSON.parse(readFileSync(CONTRIBUTORS_PATH, "utf-8"));
const pinnedRoles = new Set<string>(["lead-dev", "original-dev"]);
const pinned = existing.contributors.filter((c) => c.role && pinnedRoles.has(c.role));
// Build a set of pinned GitHub usernames to avoid duplicates
const pinnedNames = new Set(pinned.map((c) => c.name));
const contributors: ContributorEntry[] = [...pinned];
// Add developers (skip those already pinned)
for (const dev of developers) {
const githubName = dev.url?.replace("https://github.com/", "");
if (!githubName || pinnedNames.has(githubName)) continue;
contributors.push({
name: githubName,
fullName: dev.name !== githubName ? dev.name : undefined,
url: dev.url!
});
}
const output = {
"⚠️": "Auto-generated file. Run `pnpm run update-contributors` to regenerate.",
2026-04-19 15:44:52 +03:00
contributors
};
writeFileSync(CONTRIBUTORS_PATH, JSON.stringify(output, null, 4) + "\n");
console.log(`\n✅ Updated ${CONTRIBUTORS_PATH} with ${contributors.length} contributors.`);
}
2026-04-19 15:44:52 +03:00
function showTable(params: ShowTableParams) {
console.log(`\n──── ${params.title} ────`);
if (params.comment) {
console.log(`\n${params.comment}\n`);
}
console.table(params.contributors, params.columns);
}
2026-04-19 15:44:52 +03:00
main();