11cb0ef41Sopenharmony_ci#!/usr/bin/env node
21cb0ef41Sopenharmony_ci
31cb0ef41Sopenharmony_ci// Identify inactive collaborators. "Inactive" is not quite right, as the things
41cb0ef41Sopenharmony_ci// this checks for are not the entirety of collaborator activities. Still, it is
51cb0ef41Sopenharmony_ci// a pretty good proxy. Feel free to suggest or implement further metrics.
61cb0ef41Sopenharmony_ci
71cb0ef41Sopenharmony_ciimport cp from 'node:child_process';
81cb0ef41Sopenharmony_ciimport fs from 'node:fs';
91cb0ef41Sopenharmony_ciimport readline from 'node:readline';
101cb0ef41Sopenharmony_ciimport { parseArgs } from 'node:util';
111cb0ef41Sopenharmony_ci
121cb0ef41Sopenharmony_ciconst args = parseArgs({
131cb0ef41Sopenharmony_ci  allowPositionals: true,
141cb0ef41Sopenharmony_ci  options: { verbose: { type: 'boolean', short: 'v' } },
151cb0ef41Sopenharmony_ci});
161cb0ef41Sopenharmony_ci
171cb0ef41Sopenharmony_ciconst verbose = args.values.verbose;
181cb0ef41Sopenharmony_ciconst SINCE = args.positionals[0] || '18 months ago';
191cb0ef41Sopenharmony_ci
201cb0ef41Sopenharmony_ciasync function runGitCommand(cmd, mapFn) {
211cb0ef41Sopenharmony_ci  const childProcess = cp.spawn('/bin/sh', ['-c', cmd], {
221cb0ef41Sopenharmony_ci    cwd: new URL('..', import.meta.url),
231cb0ef41Sopenharmony_ci    encoding: 'utf8',
241cb0ef41Sopenharmony_ci    stdio: ['inherit', 'pipe', 'inherit'],
251cb0ef41Sopenharmony_ci  });
261cb0ef41Sopenharmony_ci  const lines = readline.createInterface({
271cb0ef41Sopenharmony_ci    input: childProcess.stdout,
281cb0ef41Sopenharmony_ci  });
291cb0ef41Sopenharmony_ci  const errorHandler = new Promise(
301cb0ef41Sopenharmony_ci    (_, reject) => childProcess.on('error', reject),
311cb0ef41Sopenharmony_ci  );
321cb0ef41Sopenharmony_ci  let returnValue = mapFn ? new Set() : '';
331cb0ef41Sopenharmony_ci  await Promise.race([errorHandler, Promise.resolve()]);
341cb0ef41Sopenharmony_ci  // If no mapFn, return the value. If there is a mapFn, use it to make a Set to
351cb0ef41Sopenharmony_ci  // return.
361cb0ef41Sopenharmony_ci  for await (const line of lines) {
371cb0ef41Sopenharmony_ci    await Promise.race([errorHandler, Promise.resolve()]);
381cb0ef41Sopenharmony_ci    if (mapFn) {
391cb0ef41Sopenharmony_ci      const val = mapFn(line);
401cb0ef41Sopenharmony_ci      if (val) {
411cb0ef41Sopenharmony_ci        returnValue.add(val);
421cb0ef41Sopenharmony_ci      }
431cb0ef41Sopenharmony_ci    } else {
441cb0ef41Sopenharmony_ci      returnValue += line;
451cb0ef41Sopenharmony_ci    }
461cb0ef41Sopenharmony_ci  }
471cb0ef41Sopenharmony_ci  return Promise.race([errorHandler, Promise.resolve(returnValue)]);
481cb0ef41Sopenharmony_ci}
491cb0ef41Sopenharmony_ci
501cb0ef41Sopenharmony_ci// Get all commit authors during the time period.
511cb0ef41Sopenharmony_ciconst authors = await runGitCommand(
521cb0ef41Sopenharmony_ci  `git shortlog -n -s --email --since="${SINCE}" HEAD`,
531cb0ef41Sopenharmony_ci  (line) => line.trim().split('\t', 2)[1],
541cb0ef41Sopenharmony_ci);
551cb0ef41Sopenharmony_ci
561cb0ef41Sopenharmony_ci// Get all approving reviewers of landed commits during the time period.
571cb0ef41Sopenharmony_ciconst approvingReviewers = await runGitCommand(
581cb0ef41Sopenharmony_ci  `git log --since="${SINCE}" | egrep "^    Reviewed-By: "`,
591cb0ef41Sopenharmony_ci  (line) => /^ {4}Reviewed-By: ([^<]+)/.exec(line)[1].trim(),
601cb0ef41Sopenharmony_ci);
611cb0ef41Sopenharmony_ci
621cb0ef41Sopenharmony_ciasync function getCollaboratorsFromReadme() {
631cb0ef41Sopenharmony_ci  const readmeText = readline.createInterface({
641cb0ef41Sopenharmony_ci    input: fs.createReadStream(new URL('../README.md', import.meta.url)),
651cb0ef41Sopenharmony_ci    crlfDelay: Infinity,
661cb0ef41Sopenharmony_ci  });
671cb0ef41Sopenharmony_ci  const returnedArray = [];
681cb0ef41Sopenharmony_ci  let foundCollaboratorHeading = false;
691cb0ef41Sopenharmony_ci  for await (const line of readmeText) {
701cb0ef41Sopenharmony_ci    // If we've found the collaborator heading already, stop processing at the
711cb0ef41Sopenharmony_ci    // next heading.
721cb0ef41Sopenharmony_ci    if (foundCollaboratorHeading && line.startsWith('#')) {
731cb0ef41Sopenharmony_ci      break;
741cb0ef41Sopenharmony_ci    }
751cb0ef41Sopenharmony_ci
761cb0ef41Sopenharmony_ci    const isCollaborator = foundCollaboratorHeading && line.length;
771cb0ef41Sopenharmony_ci
781cb0ef41Sopenharmony_ci    if (line === '### Collaborators') {
791cb0ef41Sopenharmony_ci      foundCollaboratorHeading = true;
801cb0ef41Sopenharmony_ci    }
811cb0ef41Sopenharmony_ci    if (line.startsWith('  **') && isCollaborator) {
821cb0ef41Sopenharmony_ci      const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line);
831cb0ef41Sopenharmony_ci      const mailmap = await runGitCommand(
841cb0ef41Sopenharmony_ci        `git check-mailmap '${name} <${email}>'`,
851cb0ef41Sopenharmony_ci      );
861cb0ef41Sopenharmony_ci      if (mailmap !== `${name} <${email}>`) {
871cb0ef41Sopenharmony_ci        console.log(`README entry for Collaborator does not match mailmap:\n  ${name} <${email}> => ${mailmap}`);
881cb0ef41Sopenharmony_ci      }
891cb0ef41Sopenharmony_ci      returnedArray.push({
901cb0ef41Sopenharmony_ci        name,
911cb0ef41Sopenharmony_ci        email,
921cb0ef41Sopenharmony_ci        mailmap,
931cb0ef41Sopenharmony_ci      });
941cb0ef41Sopenharmony_ci    }
951cb0ef41Sopenharmony_ci  }
961cb0ef41Sopenharmony_ci
971cb0ef41Sopenharmony_ci  if (!foundCollaboratorHeading) {
981cb0ef41Sopenharmony_ci    throw new Error('Could not find Collaborator section of README');
991cb0ef41Sopenharmony_ci  }
1001cb0ef41Sopenharmony_ci
1011cb0ef41Sopenharmony_ci  return returnedArray;
1021cb0ef41Sopenharmony_ci}
1031cb0ef41Sopenharmony_ci
1041cb0ef41Sopenharmony_ciasync function moveCollaboratorToEmeritus(peopleToMove) {
1051cb0ef41Sopenharmony_ci  const readmeText = readline.createInterface({
1061cb0ef41Sopenharmony_ci    input: fs.createReadStream(new URL('../README.md', import.meta.url)),
1071cb0ef41Sopenharmony_ci    crlfDelay: Infinity,
1081cb0ef41Sopenharmony_ci  });
1091cb0ef41Sopenharmony_ci  let fileContents = '';
1101cb0ef41Sopenharmony_ci  let inCollaboratorsSection = false;
1111cb0ef41Sopenharmony_ci  let inCollaboratorEmeritusSection = false;
1121cb0ef41Sopenharmony_ci  let collaboratorFirstLine = '';
1131cb0ef41Sopenharmony_ci  const textToMove = [];
1141cb0ef41Sopenharmony_ci  for await (const line of readmeText) {
1151cb0ef41Sopenharmony_ci    // If we've been processing collaborator emeriti and we reach the end of
1161cb0ef41Sopenharmony_ci    // the list, print out the remaining entries to be moved because they come
1171cb0ef41Sopenharmony_ci    // alphabetically after the last item.
1181cb0ef41Sopenharmony_ci    if (inCollaboratorEmeritusSection && line === '' &&
1191cb0ef41Sopenharmony_ci        fileContents.endsWith('>\n')) {
1201cb0ef41Sopenharmony_ci      while (textToMove.length) {
1211cb0ef41Sopenharmony_ci        fileContents += textToMove.pop();
1221cb0ef41Sopenharmony_ci      }
1231cb0ef41Sopenharmony_ci    }
1241cb0ef41Sopenharmony_ci
1251cb0ef41Sopenharmony_ci    // If we've found the collaborator heading already, stop processing at the
1261cb0ef41Sopenharmony_ci    // next heading.
1271cb0ef41Sopenharmony_ci    if (line.startsWith('#')) {
1281cb0ef41Sopenharmony_ci      inCollaboratorsSection = false;
1291cb0ef41Sopenharmony_ci      inCollaboratorEmeritusSection = false;
1301cb0ef41Sopenharmony_ci    }
1311cb0ef41Sopenharmony_ci
1321cb0ef41Sopenharmony_ci    const isCollaborator = inCollaboratorsSection && line.length;
1331cb0ef41Sopenharmony_ci    const isCollaboratorEmeritus = inCollaboratorEmeritusSection && line.length;
1341cb0ef41Sopenharmony_ci
1351cb0ef41Sopenharmony_ci    if (line === '### Collaborators') {
1361cb0ef41Sopenharmony_ci      inCollaboratorsSection = true;
1371cb0ef41Sopenharmony_ci    }
1381cb0ef41Sopenharmony_ci    if (line === '### Collaborator emeriti') {
1391cb0ef41Sopenharmony_ci      inCollaboratorEmeritusSection = true;
1401cb0ef41Sopenharmony_ci    }
1411cb0ef41Sopenharmony_ci
1421cb0ef41Sopenharmony_ci    if (isCollaborator) {
1431cb0ef41Sopenharmony_ci      if (line.startsWith('* ')) {
1441cb0ef41Sopenharmony_ci        collaboratorFirstLine = line;
1451cb0ef41Sopenharmony_ci      } else if (line.startsWith('  **')) {
1461cb0ef41Sopenharmony_ci        const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line);
1471cb0ef41Sopenharmony_ci        if (peopleToMove.some((entry) => {
1481cb0ef41Sopenharmony_ci          return entry.name === name && entry.email === email;
1491cb0ef41Sopenharmony_ci        })) {
1501cb0ef41Sopenharmony_ci          textToMove.push(`${collaboratorFirstLine}\n${line}\n`);
1511cb0ef41Sopenharmony_ci        } else {
1521cb0ef41Sopenharmony_ci          fileContents += `${collaboratorFirstLine}\n${line}\n`;
1531cb0ef41Sopenharmony_ci        }
1541cb0ef41Sopenharmony_ci      } else {
1551cb0ef41Sopenharmony_ci        fileContents += `${line}\n`;
1561cb0ef41Sopenharmony_ci      }
1571cb0ef41Sopenharmony_ci    }
1581cb0ef41Sopenharmony_ci
1591cb0ef41Sopenharmony_ci    if (isCollaboratorEmeritus) {
1601cb0ef41Sopenharmony_ci      if (line.startsWith('* ')) {
1611cb0ef41Sopenharmony_ci        collaboratorFirstLine = line;
1621cb0ef41Sopenharmony_ci      } else if (line.startsWith('  **')) {
1631cb0ef41Sopenharmony_ci        const currentLine = `${collaboratorFirstLine}\n${line}\n`;
1641cb0ef41Sopenharmony_ci        // If textToMove is empty, this still works because when undefined is
1651cb0ef41Sopenharmony_ci        // used in a comparison with <, the result is always false.
1661cb0ef41Sopenharmony_ci        while (textToMove[0]?.toLowerCase() < currentLine.toLowerCase()) {
1671cb0ef41Sopenharmony_ci          fileContents += textToMove.shift();
1681cb0ef41Sopenharmony_ci        }
1691cb0ef41Sopenharmony_ci        fileContents += currentLine;
1701cb0ef41Sopenharmony_ci      } else {
1711cb0ef41Sopenharmony_ci        fileContents += `${line}\n`;
1721cb0ef41Sopenharmony_ci      }
1731cb0ef41Sopenharmony_ci    }
1741cb0ef41Sopenharmony_ci
1751cb0ef41Sopenharmony_ci    if (!isCollaborator && !isCollaboratorEmeritus) {
1761cb0ef41Sopenharmony_ci      fileContents += `${line}\n`;
1771cb0ef41Sopenharmony_ci    }
1781cb0ef41Sopenharmony_ci  }
1791cb0ef41Sopenharmony_ci
1801cb0ef41Sopenharmony_ci  return fileContents;
1811cb0ef41Sopenharmony_ci}
1821cb0ef41Sopenharmony_ci
1831cb0ef41Sopenharmony_ci// Get list of current collaborators from README.md.
1841cb0ef41Sopenharmony_ciconst collaborators = await getCollaboratorsFromReadme();
1851cb0ef41Sopenharmony_ci
1861cb0ef41Sopenharmony_ciif (verbose) {
1871cb0ef41Sopenharmony_ci  console.log(`Since ${SINCE}:\n`);
1881cb0ef41Sopenharmony_ci  console.log(`* ${authors.size.toLocaleString()} authors have made commits.`);
1891cb0ef41Sopenharmony_ci  console.log(`* ${approvingReviewers.size.toLocaleString()} reviewers have approved landed commits.`);
1901cb0ef41Sopenharmony_ci  console.log(`* ${collaborators.length.toLocaleString()} collaborators currently in the project.`);
1911cb0ef41Sopenharmony_ci}
1921cb0ef41Sopenharmony_ciconst inactive = collaborators.filter((collaborator) =>
1931cb0ef41Sopenharmony_ci  !authors.has(collaborator.mailmap) &&
1941cb0ef41Sopenharmony_ci  !approvingReviewers.has(collaborator.name),
1951cb0ef41Sopenharmony_ci);
1961cb0ef41Sopenharmony_ci
1971cb0ef41Sopenharmony_ciif (inactive.length) {
1981cb0ef41Sopenharmony_ci  console.log('\nInactive collaborators:\n');
1991cb0ef41Sopenharmony_ci  console.log(inactive.map((entry) => `* ${entry.name}`).join('\n'));
2001cb0ef41Sopenharmony_ci  if (process.env.GITHUB_ACTIONS) {
2011cb0ef41Sopenharmony_ci    console.log('\nGenerating new README.md file...');
2021cb0ef41Sopenharmony_ci    const newReadmeText = await moveCollaboratorToEmeritus(inactive);
2031cb0ef41Sopenharmony_ci    fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText);
2041cb0ef41Sopenharmony_ci  }
2051cb0ef41Sopenharmony_ci}
206