openclaw/scripts/update-clawtributors.ts
Peter Steinberger afcb342244
refactor(scripts): deslop tooling scripts third pass (#161354)
* refactor(scripts): deslop tooling scripts third pass

Consolidate AST traversal, binding and shadow handling, report grouping,
fixture metadata, and developer harness plumbing. Remove the unused PR
comment verdict parser and one-use forwarding layers while preserving CLI,
guard, baseline, and generated-byte contracts.

* test(scripts): parse mocked fetch requests with Request

* test(scripts): include shared AST helper in protocol fixture
2026-09-29 17:00:46 -07:00

875 lines
26 KiB
TypeScript

import { execSync } from "node:child_process";
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import pMap, { pMapSkip } from "p-map";
import { expectDefined } from "../packages/normalization-core/src/expect.js";
import { readBoundedResponseBytes } from "./lib/bounded-response.mjs";
import { execPlainGh } from "./lib/plain-gh.mjs";
import type { ApiContributor, Entry, MapConfig, User } from "./update-clawtributors.types.js";
const REPO = "openclaw/openclaw";
const PER_LINE = 10;
const AVATAR_PROBE_SIZE = 40;
const AVATAR_PROBE_MAX_BYTES = 256 * 1024;
const AVATAR_PROBE_TIMEOUT_MS = 8000;
const AVATAR_SIZE = 48;
// The 5,000-PR history query can take about a minute; preserve healthy pagination
// headroom while bounding a stalled GitHub CLI process.
const GH_COMMAND_TIMEOUT_MS = 120_000;
const CLAWTRIBUTORS_START = "<!-- clawtributors:start -->";
const CLAWTRIBUTORS_END = "<!-- clawtributors:end -->";
const CLAWTRIBUTORS_HIDDEN_START = "<!-- clawtributors:hidden:start";
const CLAWTRIBUTORS_HIDDEN_END = "clawtributors:hidden:end -->";
const mapPath = resolve("scripts/clawtributors-map.json");
const mapConfig = JSON.parse(readFileSync(mapPath, "utf8")) as MapConfig;
const displayName = mapConfig.displayName ?? {};
const nameToLogin = normalizeMap(mapConfig.nameToLogin ?? {});
const emailToLogin = normalizeMap(mapConfig.emailToLogin ?? {});
const ensureLogins = (mapConfig.ensureLogins ?? []).map((login) => login.toLowerCase());
const readmePath = resolve("README.md");
const seedCommit = mapConfig.seedCommit ?? null;
const seedEntries = seedCommit ? parseReadmeEntries(run(`git show ${seedCommit}:README.md`)) : [];
const currentReadme = readFileSync(readmePath, "utf8");
const hiddenReadmeLogins = new Set(parseHiddenReadmeLogins(currentReadme));
const raw = runGh(["api", `repos/${REPO}/contributors?per_page=100&anon=1`, "--paginate"]);
const contributors = parsePaginatedJson(raw) as ApiContributor[];
const apiByLogin = new Map<string, User>();
const contributionsByLogin = new Map<string, number>();
const defaultAvatarByLogin = new Map<string, Promise<boolean>>();
for (const item of contributors) {
if (!item?.login || !item?.html_url || !item?.avatar_url) {
continue;
}
if (typeof item.contributions === "number") {
contributionsByLogin.set(item.login.toLowerCase(), item.contributions);
}
apiByLogin.set(item.login.toLowerCase(), {
id: item.id,
login: item.login,
html_url: item.html_url,
avatar_url: normalizeAvatar(item.avatar_url),
});
}
for (const login of ensureLogins) {
if (!apiByLogin.has(login)) {
const user = fetchUser(login);
if (user) {
apiByLogin.set(user.login.toLowerCase(), user);
}
}
}
const linesByLogin = new Map<string, number>();
const firstCommitByLogin = new Map<string, string>();
// %x1f = unit separator to avoid collisions with author names containing "|"
const log = run("git log --reverse --format=%aN%x1f%aE%x1f%aI --numstat");
let currentName: string | null = null;
let currentEmail: string | null = null;
for (const line of log.split("\n")) {
if (!line.trim()) {
continue;
}
if (line.includes("\x1f") && !/^[0-9-]/.test(line)) {
const [name, email, date] = line.split("\x1f", 3);
currentName = name?.trim() ?? null;
currentEmail = email?.trim().toLowerCase() ?? null;
// Track first commit date per login (log is --reverse so first seen = earliest)
if (currentName && date) {
const login = resolveLogin(currentName, currentEmail);
if (login) {
const key = login.toLowerCase();
if (!firstCommitByLogin.has(key)) {
firstCommitByLogin.set(key, date.slice(0, 10));
}
}
}
continue;
}
if (!currentName) {
continue;
}
const parts = line.split("\t");
if (parts.length < 3) {
continue;
}
// Skip docs paths so bulk-generated i18n scaffolds don't inflate rankings
const filePath = expectDefined(parts[2], "git numstat file path");
if (filePath.startsWith("docs/")) {
continue;
}
const adds = parseCount(expectDefined(parts[0], "git numstat additions"));
const dels = parseCount(expectDefined(parts[1], "git numstat deletions"));
const total = adds + dels;
if (!total) {
continue;
}
const login = resolveLogin(currentName, currentEmail);
if (!login) {
continue;
}
const key = login.toLowerCase();
linesByLogin.set(key, (linesByLogin.get(key) ?? 0) + total);
}
for (const login of ensureLogins) {
if (!linesByLogin.has(login)) {
linesByLogin.set(login, 0);
}
}
const prsByLogin = new Map<string, number>();
const prRaw = runGh([
"pr",
"list",
"-R",
REPO,
"--state",
"merged",
"--limit",
"5000",
"--json",
"author",
"--jq",
".[].author.login",
]);
for (const login of prRaw.split("\n")) {
const trimmed = login.trim().toLowerCase();
if (!trimmed) {
continue;
}
prsByLogin.set(trimmed, (prsByLogin.get(trimmed) ?? 0) + 1);
}
// Repo epoch for tenure calculation (root commit date)
const rootCommit = run("git rev-list --max-parents=0 HEAD").split("\n")[0];
const repoEpochStr = run(`git log --format=%aI -1 ${rootCommit}`);
const repoEpoch = new Date(repoEpochStr.slice(0, 10)).getTime();
const nowDate = new Date().toISOString().slice(0, 10);
const now = new Date(nowDate).getTime();
const repoAgeDays = Math.max(1, (now - repoEpoch) / 86_400_000);
// Composite score:
// base = commits*2 + merged_PRs*10 + sqrt(code_LOC)
// tenure = 1.0 + (days_since_first_commit / repo_age)^2 * 0.5
// score = base * tenure
// Squared curve: only true early contributors get meaningful boost.
// Day-1 = 1.5x, halfway through repo life = 1.125x, recent = ~1.0x.
function computeTenure(firstDate: string): number {
const daysIn = firstDate
? Math.max(0, (now - new Date(firstDate.slice(0, 10)).getTime()) / 86_400_000)
: 0;
const tenureRatio = Math.min(1, daysIn / repoAgeDays);
return 1 + tenureRatio * tenureRatio * 0.5;
}
function contributionStats(login: string, firstDate = "") {
const loc = linesByLogin.get(login) ?? 0;
const commits = contributionsByLogin.get(login) ?? 0;
const prs = prsByLogin.get(login) ?? 0;
const firstCommitDate = firstCommitByLogin.get(login) ?? firstDate;
return {
lines: loc > 0 ? loc : commits,
commits,
prs,
score: (commits * 2 + prs * 10 + Math.sqrt(loc)) * computeTenure(firstCommitDate),
firstCommitDate,
};
}
const entriesByKey = new Map<string, Entry>();
for (const seed of seedEntries) {
const login =
(seed.html_url ? loginFromUrl(seed.html_url) : null) ?? resolveLogin(seed.display, null);
const accountId = accountIdFromAvatarUrl(seed.avatar_url);
if (!login && !accountId) {
continue;
}
const loginKey = login?.toLowerCase();
const userByLogin = loginKey ? apiByLogin.get(loginKey) : undefined;
// Avatar account IDs survive renames and cannot be claimed by a handle squatter.
const user = accountId
? userByLogin?.id === accountId
? userByLogin
: fetchGitHubUser(`user/${accountId}`)
: (userByLogin ?? (login ? fetchUser(login) : null));
if (!user) {
const key = accountId ? `github-id:${accountId}` : expectDefined(loginKey, "seed login key");
entriesByKey.set(key, {
key,
display: seed.display,
html_url: null,
avatar_url: normalizeAvatar(seed.avatar_url),
lines: 0,
commits: 0,
prs: 0,
score: 0,
firstCommitDate: loginKey ? (firstCommitByLogin.get(loginKey) ?? "") : "",
});
continue;
}
const key = user.login.toLowerCase();
apiByLogin.set(key, user);
const existing = entriesByKey.get(key);
if (!existing) {
const fd = firstCommitByLogin.get(key) ?? "";
entriesByKey.set(key, {
key,
login: user.login,
display: seed.display,
html_url: user.html_url,
avatar_url: user.avatar_url,
lines: 0,
commits: 0,
prs: 0,
score: 0,
firstCommitDate: fd,
});
} else {
existing.display = existing.display || seed.display;
existing.login = user.login;
existing.html_url = user.html_url;
existing.avatar_url = user.avatar_url;
}
}
for (const item of contributors) {
const baseName = item.name?.trim() || item.email?.trim() || item.login?.trim();
if (!baseName) {
continue;
}
const resolvedLogin = item.login ? item.login : resolveLogin(baseName, item.email ?? null);
if (!resolvedLogin) {
continue;
}
const key = resolvedLogin.toLowerCase();
const user = apiByLogin.get(key) ?? fetchUser(resolvedLogin);
if (!user) {
continue;
}
apiByLogin.set(key, user);
const existing = entriesByKey.get(key);
if (!existing) {
entriesByKey.set(key, {
key,
login: user.login,
display: pickDisplay(baseName, user.login),
html_url: user.html_url,
avatar_url: normalizeAvatar(user.avatar_url),
...contributionStats(key),
});
} else {
existing.login = user.login;
existing.display = pickDisplay(baseName, user.login, existing.display);
existing.html_url = user.html_url;
existing.avatar_url = normalizeAvatar(user.avatar_url);
const stats = contributionStats(key, existing.firstCommitDate);
existing.lines = Math.max(existing.lines, stats.lines);
existing.commits = Math.max(existing.commits, stats.commits);
existing.prs = Math.max(existing.prs, stats.prs);
existing.firstCommitDate = stats.firstCommitDate || existing.firstCommitDate;
existing.score = Math.max(existing.score, stats.score);
}
}
for (const login of linesByLogin.keys()) {
if (entriesByKey.has(login)) {
continue;
}
let user = apiByLogin.get(login);
if (!user) {
user = fetchUser(login) || undefined;
}
if (user) {
entriesByKey.set(login, {
key: login,
login: user.login,
display: displayName[user.login.toLowerCase()] ?? user.login,
html_url: user.html_url,
avatar_url: normalizeAvatar(user.avatar_url),
...contributionStats(login),
});
}
}
const entries = Array.from(entriesByKey.values());
const visibleEntries = await filterVisibleEntries(entries, hiddenReadmeLogins);
visibleEntries.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return a.display.localeCompare(b.display);
});
const markdownLines: string[] = [];
for (let i = 0; i < visibleEntries.length; i += PER_LINE) {
const chunk = visibleEntries.slice(i, i + PER_LINE);
const parts = chunk.map((entry) => {
// Fixed 48px tiles: GitHub's avatar resizer sometimes ignores `s=48`
// (default identicons come back 420px) and never upscales tiny source
// avatars, so markdown images render off-grid without explicit sizing.
const alt = escapeHtmlAttribute(entry.display);
const image = `<img src="${entry.avatar_url}" width="48" height="48" alt="${alt}">`;
return entry.html_url ? `<a href="${entry.html_url}">${image}</a>` : image;
});
markdownLines.push(parts.join(" "));
}
const block = `${CLAWTRIBUTORS_START}\n${markdownLines.join("\n")}\n${CLAWTRIBUTORS_END}`;
const hiddenBlock = buildHiddenReadmeBlock(entries, visibleEntries);
const hiddenRange = findMarkerRange(
currentReadme,
CLAWTRIBUTORS_HIDDEN_START,
CLAWTRIBUTORS_HIDDEN_END,
);
const readmeWithoutMeta = hiddenRange
? `${currentReadme.slice(0, hiddenRange.start)}${currentReadme.slice(hiddenRange.end)}`
: currentReadme;
const range = findClawtributorsRange(readmeWithoutMeta);
if (!range) {
throw new Error("README.md missing clawtributors block");
}
const next = `${readmeWithoutMeta.slice(0, range.start)}${block}\n${hiddenBlock}${readmeWithoutMeta.slice(range.end)}`;
writeFileSync(readmePath, next);
console.log(
`Updated README clawtributors: ${visibleEntries.length} visible (${entries.length - visibleEntries.length} default-avatar entries hidden)`,
);
console.log(`\nTop 25 by composite score: (commits*2 + PRs*10 + sqrt(LOC)) * tenure`);
console.log(` tenure = 1.0 + (days_since_first_commit / repo_age)^2 * 0.5`);
console.log(
`${"#".padStart(3)} ${"login".padEnd(24)} ${"score".padStart(8)} ${"tenure".padStart(7)} ${"commits".padStart(8)} ${"PRs".padStart(6)} ${"LOC".padStart(10)} first commit`,
);
console.log("-".repeat(85));
for (const [index, entry] of visibleEntries.slice(0, 25).entries()) {
const login = (entry.login ?? entry.key).slice(0, 24);
const fd = entry.firstCommitDate || "?";
const tenure = computeTenure(fd === "?" ? "" : fd);
console.log(
`${index + 1}`.padStart(3) +
` ${login.padEnd(24)} ${entry.score.toFixed(0).padStart(8)} ${tenure.toFixed(2).padStart(6)}x ${String(entry.commits).padStart(8)} ${String(entry.prs).padStart(6)} ${String(entry.lines).padStart(10)} ${fd}`,
);
}
function run(cmd: string): string {
return execSync(cmd, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 1024 * 1024 * 200,
}).trim();
}
function runGh(args: string[]): string {
return execPlainGh(args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 1024 * 1024 * 200,
timeout: GH_COMMAND_TIMEOUT_MS,
killSignal: "SIGKILL",
}).trim();
}
function parsePaginatedJson(rawLocal: string): unknown[] {
return rawLocal
.split("\n")
.filter((line) => line.trim())
.flatMap((line): unknown[] => {
const parsed: unknown = JSON.parse(line);
return Array.isArray(parsed) ? parsed : [parsed];
});
}
function normalizeMap(map: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(map)) {
out[normalizeName(key)] = value;
}
return out;
}
function normalizeName(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, " ");
}
function parseCount(value: string): number {
return /^\d+$/.test(value) ? Number(value) : 0;
}
function isValidLogin(login: string): boolean {
return (
/^[A-Za-z0-9-]{1,39}$/.test(login) &&
!login.startsWith("-") &&
!login.endsWith("-") &&
!login.includes("--")
);
}
function normalizeLogin(login: string | null): string | null {
if (!login) {
return null;
}
const trimmed = login.trim();
return isValidLogin(trimmed) ? trimmed : null;
}
function normalizeAvatar(url: string): string {
if (!/^https?:/i.test(url)) {
return url;
}
try {
const parsed = new URL(url);
parsed.searchParams.delete("s");
parsed.searchParams.delete("size");
parsed.searchParams.set("s", String(AVATAR_SIZE));
return parsed.toString();
} catch {
return url;
}
}
function accountIdFromAvatarUrl(url: string): number | null {
try {
const parsed = new URL(url);
if (parsed.hostname !== "avatars.githubusercontent.com") {
return null;
}
const match = /^\/u\/(\d+)\/?$/u.exec(parsed.pathname);
const accountId = match?.[1] ? Number(match[1]) : Number.NaN;
return Number.isSafeInteger(accountId) && accountId > 0 ? accountId : null;
} catch {
return null;
}
}
function parseUser(responseText: string): User | null {
const parsed = JSON.parse(responseText);
if (!parsed?.login || !parsed?.html_url || !parsed?.avatar_url) {
return null;
}
return {
id: typeof parsed.id === "number" ? parsed.id : undefined,
login: parsed.login,
html_url: parsed.html_url,
avatar_url: normalizeAvatar(parsed.avatar_url),
};
}
function fetchUser(login: string): User | null {
const normalized = normalizeLogin(login);
return normalized ? fetchGitHubUser(`users/${normalized}`) : null;
}
function fetchGitHubUser(endpoint: string): User | null {
try {
return parseUser(runGh(["api", endpoint]));
} catch (error) {
if (isGitHubMissing(error)) {
return null;
}
throw error;
}
}
function isGitHubMissing(error: unknown): boolean {
const message = error instanceof Error ? error.message : "";
const stderr = (error as { stderr?: unknown } | null)?.stderr;
const stderrText = Buffer.isBuffer(stderr)
? stderr.toString("utf8")
: typeof stderr === "string"
? stderr
: "";
return /\bHTTP (?:404|410)\b/u.test(`${message}\n${stderrText}`);
}
function isDefaultGitHubAvatar(login: string): Promise<boolean> {
const normalized = normalizeLogin(login)?.toLowerCase();
if (!normalized) {
return Promise.resolve(false);
}
const cached = defaultAvatarByLogin.get(normalized);
if (cached) {
return cached;
}
const pending = probeDefaultGitHubAvatar(normalized);
defaultAvatarByLogin.set(normalized, pending);
return pending;
}
async function probeDefaultGitHubAvatar(login: string): Promise<boolean> {
try {
return await withAvatarProbeTimeout(login, async ({ signal, timeoutPromise }) => {
const response = await fetch(`https://github.com/${login}.png?size=${AVATAR_PROBE_SIZE}`, {
headers: { "user-agent": "openclaw-clawtributors" },
signal,
});
if (!response.ok) {
return false;
}
const buffer = await readBoundedResponseBytes(
response,
"avatar probe",
AVATAR_PROBE_MAX_BYTES,
{
timeoutPromise,
formatTooLargeMessage: (_label: string, bytes: number) =>
`avatar probe exceeded ${bytes} bytes`,
},
);
const dimensions = readImageDimensions(buffer);
return Boolean(
dimensions &&
(dimensions.width > AVATAR_PROBE_SIZE || dimensions.height > AVATAR_PROBE_SIZE),
);
});
} catch {
return false;
}
}
type AvatarProbeTimeout = {
signal: AbortSignal;
timeoutPromise: Promise<never>;
};
async function withAvatarProbeTimeout<T>(
login: string,
runProbe: (timeout: AvatarProbeTimeout) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
const error = new Error(
`avatar probe for ${login} exceeded timeout of ${AVATAR_PROBE_TIMEOUT_MS}ms`,
);
reject(error);
controller.abort(error);
}, AVATAR_PROBE_TIMEOUT_MS);
});
try {
return await Promise.race([
runProbe({ signal: controller.signal, timeoutPromise }),
timeoutPromise,
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
async function filterVisibleEntries(
entriesResult: Entry[],
hiddenLogins: ReadonlySet<string>,
): Promise<Entry[]> {
return await pMap(
entriesResult,
async (entry) => {
const login = entry.login ?? entry.key;
if (!login) {
return entry;
}
const normalized = normalizeLogin(login)?.toLowerCase();
if (normalized && hiddenLogins.has(normalized)) {
return pMapSkip;
}
return (await isDefaultGitHubAvatar(login)) ? pMapSkip : entry;
},
{ concurrency: 8, stopOnError: true },
);
}
function readImageDimensions(buffer: Buffer): { width: number; height: number } | null {
if (
buffer.length >= 24 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47 &&
buffer[4] === 0x0d &&
buffer[5] === 0x0a &&
buffer[6] === 0x1a &&
buffer[7] === 0x0a
) {
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
return readJpegDimensions(buffer);
}
return null;
}
function readJpegDimensions(buffer: Buffer): { width: number; height: number } | null {
let offset = 2;
while (offset + 9 < buffer.length) {
if (buffer[offset] !== 0xff) {
offset += 1;
continue;
}
const marker = expectDefined(buffer[offset + 1], `JPEG marker at byte ${offset + 1}`);
offset += 2;
if (marker === 0xd8 || marker === 0xd9) {
continue;
}
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
continue;
}
if (offset + 2 > buffer.length) {
return null;
}
const length = buffer.readUInt16BE(offset);
if (length < 2 || offset + length > buffer.length) {
return null;
}
if (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
) {
if (length < 7) {
return null;
}
return {
height: buffer.readUInt16BE(offset + 3),
width: buffer.readUInt16BE(offset + 5),
};
}
offset += length;
}
return null;
}
function resolveLogin(name: string, email: string | null): string | null {
if (email && emailToLogin[email]) {
return normalizeLogin(emailToLogin[email]);
}
if (email && name) {
const guessed = guessLoginFromEmailName(name, email);
if (guessed) {
return normalizeLogin(guessed);
}
}
if (email && email.endsWith("@users.noreply.github.com")) {
const local = expectDefined(email.split("@", 1)[0], "GitHub noreply email local part");
const login = local.includes("+")
? expectDefined(local.split("+")[1], "GitHub noreply email login suffix")
: local;
return normalizeLogin(login);
}
if (email && email.endsWith("@github.com")) {
const login = expectDefined(email.split("@", 1)[0], "GitHub email local part");
if (apiByLogin.has(login.toLowerCase())) {
return normalizeLogin(login);
}
}
const normalized = normalizeName(name);
if (nameToLogin[normalized]) {
return normalizeLogin(nameToLogin[normalized]);
}
const compact = normalized.replace(/\s+/g, "");
if (nameToLogin[compact]) {
return normalizeLogin(nameToLogin[compact]);
}
if (apiByLogin.has(normalized)) {
return normalizeLogin(normalized);
}
if (apiByLogin.has(compact)) {
return normalizeLogin(compact);
}
return null;
}
function guessLoginFromEmailName(name: string, email: string): string | null {
const local = email.split("@", 1)[0]?.trim();
if (!local) {
return null;
}
const normalizedName = normalizeIdentifier(name);
if (!normalizedName) {
return null;
}
const candidates = new Set([local, local.replace(/[._-]/g, "")]);
for (const candidate of candidates) {
if (!candidate) {
continue;
}
if (normalizeIdentifier(candidate) !== normalizedName) {
continue;
}
const key = candidate.toLowerCase();
if (apiByLogin.has(key)) {
return key;
}
}
return null;
}
function normalizeIdentifier(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
}
function escapeHtmlAttribute(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function parseReadmeEntries(
content: string,
): Array<{ display: string; html_url: string | null; avatar_url: string }> {
const rangeValue = findClawtributorsRange(content);
if (!rangeValue) {
return [];
}
const blockValue = content.slice(rangeValue.start, rangeValue.end);
const entriesValue: Array<{
display: string;
html_url: string | null;
avatar_url: string;
}> = [];
const markdown = /\[!\[([^\]]+)\]\(([^)]+)\)\]\(([^)]+)\)/g;
for (const match of blockValue.matchAll(markdown)) {
const [, alt, src, href] = match;
if (!href || !src || !alt) {
continue;
}
entriesValue.push({
html_url: href,
avatar_url: src,
display: alt.replace(/\\([\\[\]])/g, "$1"),
});
}
const linked = /<a href="([^"]+)"><img src="([^"]+)"[^>]*alt="([^"]+)"[^>]*>/g;
for (const match of blockValue.matchAll(linked)) {
const [, href, src, alt] = match;
if (!href || !src || !alt) {
continue;
}
entriesValue.push({ html_url: href, avatar_url: src, display: alt });
}
const standalone = /<img src="([^"]+)"[^>]*alt="([^"]+)"[^>]*>/g;
for (const match of blockValue.matchAll(standalone)) {
const [, src, alt] = match;
if (!src || !alt) {
continue;
}
if (entriesValue.some((entry) => entry.display === alt && entry.avatar_url === src)) {
continue;
}
entriesValue.push({ html_url: null, avatar_url: src, display: alt });
}
return entriesValue;
}
function parseHiddenReadmeLogins(content: string): string[] {
const rangeLocal = findMarkerRange(content, CLAWTRIBUTORS_HIDDEN_START, CLAWTRIBUTORS_HIDDEN_END);
if (!rangeLocal) {
return [];
}
const blockLocal = content.slice(rangeLocal.start, rangeLocal.end);
return blockLocal
.split("\n")
.map((line) => normalizeLogin(line.trim())?.toLowerCase() ?? null)
.filter((login): login is string => Boolean(login));
}
function buildHiddenReadmeBlock(entriesLocal: Entry[], visibleEntriesLocal: Entry[]): string {
const visibleLogins = new Set(
visibleEntriesLocal
.map((entry) => normalizeLogin(entry.login ?? entry.key)?.toLowerCase() ?? null)
.filter((login): login is string => Boolean(login)),
);
const hiddenLogins = entriesLocal
.map((entry) => normalizeLogin(entry.login ?? entry.key)?.toLowerCase() ?? null)
.filter((login): login is string => Boolean(login))
.filter((login) => !visibleLogins.has(login))
.toSorted((a, b) => a.localeCompare(b));
const notice =
"default-avatar-cache: hidden from the rendered wall because these users still use GitHub's default avatar";
return [CLAWTRIBUTORS_HIDDEN_START, notice, ...hiddenLogins, CLAWTRIBUTORS_HIDDEN_END, ""].join(
"\n",
);
}
function findClawtributorsRange(content: string): { start: number; end: number } | null {
return (
findMarkerRange(content, CLAWTRIBUTORS_START, CLAWTRIBUTORS_END) ??
findMarkerRange(content, '<p align="left">', "</p>")
);
}
function findMarkerRange(
content: string,
start: string,
end: string,
): { start: number; end: number } | null {
const markerStart = content.indexOf(start);
const markerEnd = content.indexOf(end, markerStart);
if (markerStart === -1 || markerEnd === -1) {
return null;
}
return {
start: markerStart,
end: markerEnd + end.length,
};
}
function loginFromUrl(url: string): string | null {
const match = /^https?:\/\/github\.com\/([^/?#]+)/i.exec(url);
if (!match) {
return null;
}
const login = match[1];
if (!login || login.toLowerCase() === "search") {
return null;
}
return login;
}
function pickDisplay(
baseName: string | null | undefined,
login: string,
existing?: string,
): string {
return displayName[login.toLowerCase()] || existing || baseName || login;
}