diff --git a/AGENTS.md b/AGENTS.md index ca40cb52a..a175f3875 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ - When working on an issue: reference the issue in the changelog entry. - When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes. - When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list. +- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README. ### PR Workflow (Review vs Land) - **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code. diff --git a/README.md b/README.md index 2fae9b001..660808a2e 100644 --- a/README.md +++ b/README.md @@ -447,11 +447,9 @@ AI/vibe-coded PRs welcome! 🤖 Thanks to all clawtributors:

- steipete thewilloftheshadow joaohlisboa mneves75 mukhtharcm maxsumrall xadenryan joshp123 hsrvc obviyus - jamesgroat jalehman dbhurley dantelex mcinteerj zats jeffersonwarrior azade-c Asleep123 omniwired - mbelinky jverdi julianengel fcatuhe sreekaransrinath kitze Jonathan D. Rhyne (DJ-D) vsabavat claude petter-b - scald oswalpalash nachoiacovino andranik-sahakyan sircrumpet manmal rafaelreis-r meaningfool jonasjancarik ratulsarna - lutr0 kkarimi emanuelst Syhids osolmaz kiranjd wstock CashWilliams cash-echo-bot onutc - minghinmatthewlam buddyh MSch timkrase gupsammy hrdwdmrbl reeltimeapps snopoke ngutman antons - imfing RandyVentures loukotal VACInc djangonavarro220 erikpr1994 gtsifrikas jayhickey hugobarauna + joaohlisboa mneves75 maxsumrall xadenryan joshp123 mukhtharcm hsrvc jamesgroat dantelex azade-c + omniwired mbelinky julianengel sreekaransrinath dbhurley kitze Jonathan D. Rhyne (DJ-D) vsabavat jeffersonwarrior claude + scald nachoiacovino andranik-sahakyan Nachx639 tobiasbischoff sircrumpet manmal rafaelreis-r meaningfool ratulsarna + lutr0 AbhisekBasu1 emanuelst osolmaz kiranjd wstock CashWilliams ManuelHettich minghinmatthewlam buddyh + timkrase gupsammy imfing RandyVentures Iamadig VACInc djangonavarro220 erikpr1994 jayhickey

diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json new file mode 100644 index 000000000..657de666c --- /dev/null +++ b/scripts/clawtributors-map.json @@ -0,0 +1,40 @@ +{ + "ensureLogins": [ + "jdrhyne", + "manmal" + ], + "displayName": { + "jdrhyne": "Jonathan D. Rhyne (DJ-D)" + }, + "nameToLogin": { + "azade": "azade-c", + "eng. juan combetto": "omniwired", + "mariano belinky": "mbelinky", + "kit": "kitze", + "vasanth rao naik sabavat": "vsabavat", + "tobias bischoff": "tobiasbischoff", + "tu nombre real": "nachx639", + "william stock": "wstock", + "abhisekbasu1": "AbhisekBasu1", + "manuelhettich": "manuelhettich", + "iamadig": "Iamadig", + "django navarro": "djangonavarro220", + "erik": "erikpr1994", + "jonathan d. rhyne": "jdrhyne", + "jonathan rhyne": "jdrhyne", + "manuel maly": "manmal", + "manuel mali": "manmal" + }, + "emailToLogin": { + "manuel.maly@gmail.com": "manmal", + "omniwired@gmail.com": "omniwired", + "mbelinky@gmail.com": "mbelinky", + "vsabavat@nvidia.com": "vsabavat", + "nacho639@gmail.com": "nachx639", + "w.stock@yahoo.com": "wstock", + "rltorres26+github@gmail.com": "RandyVentures", + "hixvac@gmail.com": "VACInc", + "djangonavarro220@gmail.com": "djangonavarro220", + "erikpastorrios1994@gmail.com": "erikpr1994" + } +} diff --git a/scripts/update-clawtributors.ts b/scripts/update-clawtributors.ts new file mode 100644 index 000000000..e49b68ac9 --- /dev/null +++ b/scripts/update-clawtributors.ts @@ -0,0 +1,272 @@ +import { execSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const REPO = "clawdbot/clawdbot"; +const PER_LINE = 10; + +type MapConfig = { + ensureLogins?: string[]; + displayName?: Record; + nameToLogin?: Record; + emailToLogin?: Record; +}; + +type User = { + login: string; + html_url: string; + avatar_url: string; +}; + +type Entry = { + login: string; + display: string; + html_url: string; + avatar_url: string; + lines: number; +}; + +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 raw = run(`gh api "repos/${REPO}/contributors?per_page=100&anon=1" --paginate`); +const contributors = parsePaginatedJson(raw); +const apiByLogin = new Map(); + +for (const item of contributors) { + if (!item?.login || !item?.html_url || !item?.avatar_url) { + continue; + } + 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 log = run("git log --format=%aN%x7c%aE --numstat"); +const linesByLogin = new Map(); + +let currentName: string | null = null; +let currentEmail: string | null = null; + +for (const line of log.split("\n")) { + if (!line.trim()) { + continue; + } + + if (line.includes("|") && !/^[0-9-]/.test(line)) { + const [name, email] = line.split("|", 2); + currentName = name?.trim() ?? null; + currentEmail = email?.trim().toLowerCase() ?? null; + continue; + } + + if (!currentName) { + continue; + } + + const parts = line.split("\t"); + if (parts.length < 2) { + continue; + } + + const adds = parseCount(parts[0]); + const dels = parseCount(parts[1]); + const total = adds + dels; + if (!total) { + continue; + } + + let 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 entries: Entry[] = []; +for (const [login, lines] of linesByLogin.entries()) { + let user = apiByLogin.get(login); + if (!user) { + user = fetchUser(login); + } + if (!user || !user.avatar_url) { + continue; + } + + entries.push({ + login: user.login, + display: displayName[user.login.toLowerCase()] ?? user.login, + html_url: user.html_url, + avatar_url: normalizeAvatar(user.avatar_url), + lines, + }); +} + +entries.sort((a, b) => { + if (b.lines !== a.lines) { + return b.lines - a.lines; + } + return a.display.localeCompare(b.display); +}); + +const lines: string[] = []; +for (let i = 0; i < entries.length; i += PER_LINE) { + const chunk = entries.slice(i, i + PER_LINE); + const parts = chunk.map((entry) => { + return `\"${entry.display}\"`; + }); + lines.push(` ${parts.join(" ")}`); +} + +const block = `${lines.join("\n")}\n`; +const readmePath = resolve("README.md"); +const readme = readFileSync(readmePath, "utf8"); +const start = readme.indexOf('

'); +const end = readme.indexOf("

", start); + +if (start === -1 || end === -1) { + throw new Error("README.md missing clawtributors block"); +} + +const next = `${readme.slice(0, start)}

\n${block}${readme.slice(end)}`; +writeFileSync(readmePath, next); + +console.log(`Updated README clawtributors: ${entries.length} entries`); + +function run(cmd: string): string { + return execSync(cmd, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 1024 * 1024 * 200, + }).trim(); +} + +function parsePaginatedJson(raw: string): any[] { + const items: any[] = []; + for (const line of raw.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): Record { + const out: Record = {}; + 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 normalizeAvatar(url: string): string { + const lower = url.toLowerCase(); + if (lower.includes("s=") || lower.includes("size=")) { + return url; + } + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}s=48`; +} + +function fetchUser(login: string): User | null { + try { + const data = execSync(`gh api users/${login}`, { + 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 resolveLogin( + name: string, + email: string | null, + apiByLogin: Map, + nameToLogin: Record, + emailToLogin: Record +): string | null { + if (email && emailToLogin[email]) { + return emailToLogin[email]; + } + + if (email && email.endsWith("@users.noreply.github.com")) { + const local = email.split("@", 1)[0]; + const login = local.includes("+") ? local.split("+")[1] : local; + return login || null; + } + + if (email && email.endsWith("@github.com")) { + const login = email.split("@", 1)[0]; + if (apiByLogin.has(login.toLowerCase())) { + return login; + } + } + + const normalized = normalizeName(name); + if (nameToLogin[normalized]) { + return nameToLogin[normalized]; + } + + const compact = normalized.replace(/\s+/g, ""); + if (nameToLogin[compact]) { + return nameToLogin[compact]; + } + + if (apiByLogin.has(normalized)) { + return normalized; + } + + if (apiByLogin.has(compact)) { + return compact; + } + + return null; +}