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