Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
972
scripts/update-clawtributors.ts
Normal file
972
scripts/update-clawtributors.ts
Normal file
@@ -0,0 +1,972 @@
|
||||
// Update Clawtributors script supports OpenClaw repository automation.
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
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;
|
||||
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 = run(`gh 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(), {
|
||||
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, apiByLogin, nameToLogin, emailToLogin);
|
||||
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 = parts[2];
|
||||
if (filePath.startsWith("docs/")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const adds = parseCount(parts[0]);
|
||||
const dels = parseCount(parts[1]);
|
||||
const total = adds + dels;
|
||||
if (!total) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const login = resolveLogin(currentName, currentEmail, apiByLogin, nameToLogin, emailToLogin);
|
||||
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 = run(
|
||||
`gh 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 computeScore(loc: number, commits: number, prs: number, firstDate: string): number {
|
||||
const base = commits * 2 + prs * 10 + Math.sqrt(loc);
|
||||
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);
|
||||
const tenure = 1 + tenureRatio * tenureRatio * 0.5;
|
||||
return base * tenure;
|
||||
}
|
||||
|
||||
const entriesByKey = new Map<string, Entry>();
|
||||
|
||||
for (const seed of seedEntries) {
|
||||
const login =
|
||||
loginFromUrl(seed.html_url) ??
|
||||
resolveLogin(seed.display, null, apiByLogin, nameToLogin, emailToLogin);
|
||||
if (!login) {
|
||||
continue;
|
||||
}
|
||||
const key = login.toLowerCase();
|
||||
const user = apiByLogin.get(key) ?? fetchUser(login);
|
||||
if (!user) {
|
||||
continue;
|
||||
}
|
||||
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, apiByLogin, nameToLogin, emailToLogin);
|
||||
|
||||
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) {
|
||||
const loc = linesByLogin.get(key) ?? 0;
|
||||
const commits = contributionsByLogin.get(key) ?? 0;
|
||||
const prs = prsByLogin.get(key) ?? 0;
|
||||
const fd = firstCommitByLogin.get(key) ?? "";
|
||||
entriesByKey.set(key, {
|
||||
key,
|
||||
login: user.login,
|
||||
display: pickDisplay(baseName, user.login),
|
||||
html_url: user.html_url,
|
||||
avatar_url: normalizeAvatar(user.avatar_url),
|
||||
lines: loc > 0 ? loc : commits,
|
||||
commits,
|
||||
prs,
|
||||
score: computeScore(loc, commits, prs, fd),
|
||||
firstCommitDate: fd,
|
||||
});
|
||||
} 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 loc = linesByLogin.get(key) ?? 0;
|
||||
const commits = contributionsByLogin.get(key) ?? 0;
|
||||
const prs = prsByLogin.get(key) ?? 0;
|
||||
const fd = firstCommitByLogin.get(key) ?? existing.firstCommitDate;
|
||||
existing.lines = Math.max(existing.lines, loc > 0 ? loc : commits);
|
||||
existing.commits = Math.max(existing.commits, commits);
|
||||
existing.prs = Math.max(existing.prs, prs);
|
||||
existing.firstCommitDate = fd || existing.firstCommitDate;
|
||||
existing.score = Math.max(existing.score, computeScore(loc, commits, prs, fd));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [login, loc] of linesByLogin.entries()) {
|
||||
if (entriesByKey.has(login)) {
|
||||
continue;
|
||||
}
|
||||
let user = apiByLogin.get(login);
|
||||
if (!user) {
|
||||
user = fetchUser(login) || undefined;
|
||||
}
|
||||
if (user) {
|
||||
const commits = contributionsByLogin.get(login) ?? 0;
|
||||
const prs = prsByLogin.get(login) ?? 0;
|
||||
const fd = firstCommitByLogin.get(login) ?? "";
|
||||
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),
|
||||
lines: loc > 0 ? loc : commits,
|
||||
commits,
|
||||
prs,
|
||||
score: computeScore(loc, commits, prs, fd),
|
||||
firstCommitDate: fd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
return `[](${entry.html_url})`;
|
||||
});
|
||||
markdownLines.push(parts.join(" "));
|
||||
}
|
||||
|
||||
const block = `${CLAWTRIBUTORS_START}\n${markdownLines.join("\n")}\n${CLAWTRIBUTORS_END}`;
|
||||
const hiddenBlock = buildHiddenReadmeBlock(entries, visibleEntries);
|
||||
const hiddenRange = findHiddenReadmeRange(currentReadme);
|
||||
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 daysIn =
|
||||
fd !== "?" ? Math.max(0, (now - new Date(fd.slice(0, 10)).getTime()) / 86_400_000) : 0;
|
||||
const tr = Math.min(1, daysIn / repoAgeDays);
|
||||
const tenure = 1 + tr * tr * 0.5;
|
||||
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 parsePaginatedJson(rawLocal: string): unknown[] {
|
||||
const items: unknown[] = [];
|
||||
for (const line of rawLocal.split("\n")) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
const parsed = JSON.parse(line);
|
||||
if (Array.isArray(parsed)) {
|
||||
items.push(...parsed);
|
||||
} else {
|
||||
items.push(parsed);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!/^[A-Za-z0-9-]{1,39}$/.test(login)) {
|
||||
return false;
|
||||
}
|
||||
if (login.startsWith("-") || login.endsWith("-")) {
|
||||
return false;
|
||||
}
|
||||
if (login.includes("--")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
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 fetchUser(login: string): User | null {
|
||||
const normalized = normalizeLogin(login);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const data = execFileSync("gh", ["api", `users/${normalized}`], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const parsed = JSON.parse(data);
|
||||
if (!parsed?.login || !parsed?.html_url || !parsed?.avatar_url) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
login: parsed.login,
|
||||
html_url: parsed.html_url,
|
||||
avatar_url: normalizeAvatar(parsed.avatar_url),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 readAvatarProbeBuffer(response, timeoutPromise);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelAvatarProbeReaderSoon(reader: ReadableStreamDefaultReader<Uint8Array>): void {
|
||||
void Promise.resolve()
|
||||
.then(() => reader.cancel())
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function toAvatarProbeError(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
return new Error(fallbackMessage, { cause: value });
|
||||
}
|
||||
|
||||
async function readAvatarProbeChunkWithTimeout(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
timeoutPromise: Promise<never> | undefined,
|
||||
markCanceled: () => void,
|
||||
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
||||
const readPromise = reader.read();
|
||||
if (!timeoutPromise) {
|
||||
return await readPromise;
|
||||
}
|
||||
|
||||
let waitingForRead = true;
|
||||
const timeoutReadPromise = timeoutPromise.catch((error: unknown) => {
|
||||
if (waitingForRead) {
|
||||
markCanceled();
|
||||
cancelAvatarProbeReaderSoon(reader);
|
||||
}
|
||||
throw toAvatarProbeError(error, "avatar probe response body read timed out");
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([readPromise, timeoutReadPromise]);
|
||||
} finally {
|
||||
waitingForRead = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readAvatarProbeArrayBuffer(
|
||||
response: Response,
|
||||
timeoutPromise: Promise<never> | undefined,
|
||||
): Promise<ArrayBuffer> {
|
||||
if (!timeoutPromise) {
|
||||
return await response.arrayBuffer();
|
||||
}
|
||||
return await Promise.race([
|
||||
response.arrayBuffer(),
|
||||
timeoutPromise.catch((error: unknown) => {
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
throw toAvatarProbeError(error, "avatar probe response body read timed out");
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function readAvatarProbeBuffer(
|
||||
response: Response,
|
||||
timeoutPromise?: Promise<never>,
|
||||
): Promise<Buffer> {
|
||||
const contentLengthRaw = response.headers.get("content-length");
|
||||
if (contentLengthRaw && /^\d+$/u.test(contentLengthRaw)) {
|
||||
const contentLength = Number(contentLengthRaw);
|
||||
if (!Number.isSafeInteger(contentLength) || contentLength > AVATAR_PROBE_MAX_BYTES) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new Error(`avatar probe exceeded ${AVATAR_PROBE_MAX_BYTES} bytes`);
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const buffer = Buffer.from(await readAvatarProbeArrayBuffer(response, timeoutPromise));
|
||||
if (buffer.byteLength > AVATAR_PROBE_MAX_BYTES) {
|
||||
throw new Error(`avatar probe exceeded ${AVATAR_PROBE_MAX_BYTES} bytes`);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
let canceled = false;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await readAvatarProbeChunkWithTimeout(reader, timeoutPromise, () => {
|
||||
canceled = true;
|
||||
});
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value?.byteLength) {
|
||||
continue;
|
||||
}
|
||||
const chunk = Buffer.from(value);
|
||||
const nextTotal = total + chunk.byteLength;
|
||||
if (nextTotal > AVATAR_PROBE_MAX_BYTES) {
|
||||
canceled = true;
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new Error(`avatar probe exceeded ${AVATAR_PROBE_MAX_BYTES} bytes`);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
total = nextTotal;
|
||||
}
|
||||
} finally {
|
||||
if (!canceled) {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
return Buffer.concat(chunks, total);
|
||||
}
|
||||
|
||||
async function filterVisibleEntries(
|
||||
entriesResult: Entry[],
|
||||
hiddenLogins: ReadonlySet<string>,
|
||||
): Promise<Entry[]> {
|
||||
const results = await mapConcurrent(entriesResult, 8, async (entry) => {
|
||||
const login = entry.login ?? entry.key;
|
||||
if (!login) {
|
||||
return entry;
|
||||
}
|
||||
const normalized = normalizeLogin(login)?.toLowerCase();
|
||||
if (normalized && hiddenLogins.has(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return (await isDefaultGitHubAvatar(login)) ? null : entry;
|
||||
});
|
||||
return results.filter((entry): entry is Entry => entry !== null);
|
||||
}
|
||||
|
||||
async function mapConcurrent<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
mapper: (item: T, index: number) => Promise<R>,
|
||||
): Promise<R[]> {
|
||||
const results: R[] = [];
|
||||
results.length = items.length;
|
||||
let nextIndex = 0;
|
||||
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex++;
|
||||
results[index] = await mapper(items[index], index);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function readImageDimensions(buffer: Buffer): { width: number; height: number } | null {
|
||||
if (isPng(buffer)) {
|
||||
return readPngDimensions(buffer);
|
||||
}
|
||||
if (isJpeg(buffer)) {
|
||||
return readJpegDimensions(buffer);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPng(buffer: Buffer): boolean {
|
||||
return (
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
function readPngDimensions(buffer: Buffer): { width: number; height: number } | null {
|
||||
if (buffer.length < 24) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
width: buffer.readUInt32BE(16),
|
||||
height: buffer.readUInt32BE(20),
|
||||
};
|
||||
}
|
||||
|
||||
function isJpeg(buffer: Buffer): boolean {
|
||||
return buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8;
|
||||
}
|
||||
|
||||
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 = buffer[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,
|
||||
apiByLoginValue: Map<string, User>,
|
||||
nameToLoginLocal: Record<string, string>,
|
||||
emailToLoginLocal: Record<string, string>,
|
||||
): string | null {
|
||||
if (email && emailToLoginLocal[email]) {
|
||||
return normalizeLogin(emailToLoginLocal[email]);
|
||||
}
|
||||
|
||||
if (email && name) {
|
||||
const guessed = guessLoginFromEmailName(name, email, apiByLoginValue);
|
||||
if (guessed) {
|
||||
return normalizeLogin(guessed);
|
||||
}
|
||||
}
|
||||
|
||||
if (email && email.endsWith("@users.noreply.github.com")) {
|
||||
const local = email.split("@", 1)[0];
|
||||
const login = local.includes("+") ? local.split("+")[1] : local;
|
||||
return normalizeLogin(login);
|
||||
}
|
||||
|
||||
if (email && email.endsWith("@github.com")) {
|
||||
const login = email.split("@", 1)[0];
|
||||
if (apiByLoginValue.has(login.toLowerCase())) {
|
||||
return normalizeLogin(login);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeName(name);
|
||||
if (nameToLoginLocal[normalized]) {
|
||||
return normalizeLogin(nameToLoginLocal[normalized]);
|
||||
}
|
||||
|
||||
const compact = normalized.replace(/\s+/g, "");
|
||||
if (nameToLoginLocal[compact]) {
|
||||
return normalizeLogin(nameToLoginLocal[compact]);
|
||||
}
|
||||
|
||||
if (apiByLoginValue.has(normalized)) {
|
||||
return normalizeLogin(normalized);
|
||||
}
|
||||
|
||||
if (apiByLoginValue.has(compact)) {
|
||||
return normalizeLogin(compact);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function guessLoginFromEmailName(
|
||||
name: string,
|
||||
email: string,
|
||||
apiByLoginLocal: Map<string, User>,
|
||||
): 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 (apiByLoginLocal.has(key)) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeIdentifier(value: string): string {
|
||||
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
function escapeMarkdownLabel(value: string): string {
|
||||
return value.replace(/([\\[\]])/g, "\\$1");
|
||||
}
|
||||
|
||||
function parseReadmeEntries(
|
||||
content: string,
|
||||
): Array<{ display: string; html_url: string; 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; 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: fallbackHref(alt), avatar_url: src, display: alt });
|
||||
}
|
||||
return entriesValue;
|
||||
}
|
||||
|
||||
function parseHiddenReadmeLogins(content: string): string[] {
|
||||
const rangeLocal = findHiddenReadmeRange(content);
|
||||
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";
|
||||
if (hiddenLogins.length === 0) {
|
||||
return `${CLAWTRIBUTORS_HIDDEN_START}\n${notice}\n${CLAWTRIBUTORS_HIDDEN_END}\n`;
|
||||
}
|
||||
return `${CLAWTRIBUTORS_HIDDEN_START}\n${notice}\n${hiddenLogins.join("\n")}\n${CLAWTRIBUTORS_HIDDEN_END}\n`;
|
||||
}
|
||||
|
||||
function findClawtributorsRange(content: string): { start: number; end: number } | null {
|
||||
const markerStart = content.indexOf(CLAWTRIBUTORS_START);
|
||||
const markerEnd = content.indexOf(CLAWTRIBUTORS_END, markerStart);
|
||||
if (markerStart !== -1 && markerEnd !== -1) {
|
||||
return {
|
||||
start: markerStart,
|
||||
end: markerEnd + CLAWTRIBUTORS_END.length,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyStart = content.indexOf('<p align="left">');
|
||||
const legacyEnd = content.indexOf("</p>", legacyStart);
|
||||
if (legacyStart === -1 || legacyEnd === -1) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
start: legacyStart,
|
||||
end: legacyEnd + "</p>".length,
|
||||
};
|
||||
}
|
||||
|
||||
function findHiddenReadmeRange(content: string): { start: number; end: number } | null {
|
||||
const markerStart = content.indexOf(CLAWTRIBUTORS_HIDDEN_START);
|
||||
const markerEnd = content.indexOf(CLAWTRIBUTORS_HIDDEN_END, markerStart);
|
||||
if (markerStart === -1 || markerEnd === -1) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
start: markerStart,
|
||||
end: markerEnd + CLAWTRIBUTORS_HIDDEN_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 fallbackHref(value: string): string {
|
||||
const encoded = encodeURIComponent(value.trim());
|
||||
return encoded ? `https://github.com/search?q=${encoded}` : "https://github.com";
|
||||
}
|
||||
|
||||
function pickDisplay(
|
||||
baseName: string | null | undefined,
|
||||
login: string,
|
||||
existing?: string,
|
||||
): string {
|
||||
const key = login.toLowerCase();
|
||||
if (displayName[key]) {
|
||||
return displayName[key];
|
||||
}
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
if (baseName) {
|
||||
return baseName;
|
||||
}
|
||||
return login;
|
||||
}
|
||||
Reference in New Issue
Block a user