Ctrl K
ComunidadGuías…

scripts/generate-contributors-doc.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

import { execSync } from 'child_process';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');

// Monorepo: every package's history was absorbed in-tree, so a single `git log` over
// this repository captures all contributors across all packages. All commits now live
// in silexlabs/Silex (submodule commit hashes were rewritten by git filter-repo).
const REPO_URL = 'https://github.com/silexlabs/Silex';

// Authors to exclude (bots, placeholders, AI assistants credited as co-authors)
const EXCLUDE = /ubuntu|john doe|^undefined$|grrhosting|gitter badger|claude|anthropic|copilot|\[bot\]/i;

// Author identity normalization (e.g. lexoyo / lexa / Alexandre Hoyau → Alex Hoyau) is
// handled by git via .mailmap: `git log %aN` applies it. No hardcoded alias map here —
// .mailmap is the single source of truth (kept local / untracked).

// Names link to the contributor's GitHub profile (not a random commit, and no email
// is exposed in the README). The login comes from the commit author email:
//   1. GitHub noreply emails (`<id>+<login>@users.noreply.github.com`) encode the login.
//   2. Otherwise we ask GitHub, through the `gh` CLI (already authenticated locally),
//      for a public commit with that author email. The rewritten monorepo commits
//      aren't pushed yet, but the originals are, so the search finds them.
// Run this script yourself: it needs `gh` logged in. Without `gh`, step 2 is skipped
// and unresolved contributors render as plain text.

function loginFromNoreply(email) {
  const m = (email || '').match(/^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/i);
  return m ? m[1] : null;
}

let ghAvailable = true;
function resolveViaGh(email, tries = 0) {
  if (!ghAvailable) return null;
  try {
    const out = execSync(
      `gh api -H "Accept: application/vnd.github+json" ` +
      `"search/commits?q=author-email:${encodeURIComponent(email)}&per_page=1" ` +
      `--jq ".items[0].author.login // empty"`,
      { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
    ).trim();
    execSync('sleep 2.2'); // GitHub commit search: 30 requests/min
    return out || null;
  } catch (e) {
    // Silently dropping the links would rewrite the README with fewer of them
    if (/rate limit/i.test(`${e.stderr ?? ''}${e}`)) {
      if (tries >= 5) throw new Error('GitHub search rate limit reached, run again in a minute')
      // The secondary limit refuses searches even when `gh api rate_limit` shows a full quota
      execSync('sleep 65');
      return resolveViaGh(email, tries + 1);
    }
    if (/command not found|not found in PATH|executable file not found/i.test(String(e))) {
      ghAvailable = false;
      console.error('[contributors] gh CLI not available — profile links limited to noreply emails');
    }
    return null;
  }
}

// Co-authors are credited too: a squashed PR keeps its real author in a
// `Co-authored-by` trailer, so `%aN` alone would drop them. Records are NUL separated
// because a commit can carry several trailers, each one on its own line.
// HEAD and tags, not --all: fetched PR branches would credit work that was never merged,
// and the pre-monorepo tag keeps the history lost in the rewrite.
function getContributors(dir) {
  try {
    const output = execSync(
      'git log --format="%aN|%aE|%aI|%(trailers:key=Co-authored-by,valueonly,separator=%x1F)%x00" HEAD --tags',
      {
        cwd: dir,
        encoding: 'utf-8',
        stdio: ['pipe', 'pipe', 'pipe'],
        maxBuffer: 256 * 1024 * 1024, // full monorepo history exceeds the 1MB default
      });
    return output.split('\0').filter((record) => record.trim()).flatMap((record) => {
      const [name, email, date, coauthors] = record.trim().split('|');
      const contributions = [{ name, email, date }];
      for (const trailer of (coauthors || '').split('\x1F').filter(Boolean)) {
        const parsed = trailer.match(/^\s*(.+?)\s*<(.+?)>\s*$/);
        if (parsed) contributions.push({ name: parsed[1], email: parsed[2], date, coauthor: true });
      }
      return contributions;
    });
  } catch {
    return [];
  }
}

function main() {
  // Single git log over the monorepo captures every package's contributors.
  const allContributions = getContributors(ROOT);

  // Collect every email seen per author name (to resolve a login).
  const contributionsByName = {};
  const emailsByName = {};
  for (const c of allContributions) {
    const name = c.name;
    if (EXCLUDE.test(name)) continue;
    (contributionsByName[name] ||= []).push(c);
    (emailsByName[name] ||= new Set()).add(c.email);
  }

  // One GitHub login per author: noreply emails first (free), then `gh` for the rest.
  const loginByName = {};
  let resolved = 0;
  for (const [name, emails] of Object.entries(emailsByName)) {
    let login = null;
    for (const email of emails) { login = loginFromNoreply(email); if (login) break; }
    if (!login) {
      for (const email of emails) { login = resolveViaGh(email); if (login) { resolved++; break; } }
    }
    loginByName[name] = login || null;
  }
  if (resolved) console.error(`[contributors] resolved ${resolved} login(s) via gh`);

  // A machine address (`lexoyo@lexoyo.local`) is unknown to GitHub, but often starts with the login
  const knownLogins = new Map(Object.values(loginByName).filter(Boolean).map((login) => [login.toLowerCase(), login]));
  for (const [name, emails] of Object.entries(emailsByName)) {
    for (const email of emails) loginByName[name] ||= knownLogins.get(email.split('@')[0].toLowerCase()) || null;
  }

  // Identity is the GitHub login, not the name a contributor happened to configure:
  // `lexoyo`, `lexa` and `Alex Hoyau` all resolve to the same profile, so they are one
  // person. Names without a resolved login fall back to their own name as key.
  // The display name is the one used in the most recent commit of the group.
  const byIdentityYear = {};
  const displayNameByIdentity = {};
  const latestDateByIdentity = {};
  for (const [name, contributions] of Object.entries(contributionsByName)) {
    // GitHub adds the local identity of the PR author as a co-author when squashing, and he is already credited
    if (!loginByName[name] && contributions.every((c) => c.coauthor)) continue;
    const identity = loginByName[name] ? `gh:${loginByName[name]}` : `name:${name}`;
    for (const c of contributions) {
      if (!latestDateByIdentity[identity] || c.date > latestDateByIdentity[identity]) {
        latestDateByIdentity[identity] = c.date;
        displayNameByIdentity[identity] = name;
      }
      const year = c.date.substring(0, 4);
      const key = `${identity}|${year}`;
      if (!byIdentityYear[key] || c.date > byIdentityYear[key].date) {
        byIdentityYear[key] = { identity, year, date: c.date };
      }
    }
  }

  // Group by year
  const byYear = {};
  for (const entry of Object.values(byIdentityYear)) {
    (byYear[entry.year] ||= []).push(entry);
  }
  for (const year of Object.keys(byYear)) {
    byYear[year].sort((a, b) => b.date.localeCompare(a.date));
  }

  // Generate markdown — link to the GitHub profile when known, plain name otherwise.
  let md = '\n';
  for (const year of Object.keys(byYear).sort((a, b) => b - a)) {
    const names = byYear[year].map((entry) => {
      const name = displayNameByIdentity[entry.identity];
      const login = entry.identity.startsWith('gh:') ? entry.identity.slice(3) : null;
      return login ? `[${name}](https://github.com/${login})` : name;
    });
    md += `**${year}** — ${names.join(', ')}\n\n`;
  }

  console.log(md);
}

main();