1#!/usr/bin/env node 2 3// Identify inactive collaborators. "Inactive" is not quite right, as the things 4// this checks for are not the entirety of collaborator activities. Still, it is 5// a pretty good proxy. Feel free to suggest or implement further metrics. 6 7import cp from 'node:child_process'; 8import fs from 'node:fs'; 9import readline from 'node:readline'; 10import { parseArgs } from 'node:util'; 11 12const args = parseArgs({ 13 allowPositionals: true, 14 options: { verbose: { type: 'boolean', short: 'v' } }, 15}); 16 17const verbose = args.values.verbose; 18const SINCE = args.positionals[0] || '18 months ago'; 19 20async function runGitCommand(cmd, mapFn) { 21 const childProcess = cp.spawn('/bin/sh', ['-c', cmd], { 22 cwd: new URL('..', import.meta.url), 23 encoding: 'utf8', 24 stdio: ['inherit', 'pipe', 'inherit'], 25 }); 26 const lines = readline.createInterface({ 27 input: childProcess.stdout, 28 }); 29 const errorHandler = new Promise( 30 (_, reject) => childProcess.on('error', reject), 31 ); 32 let returnValue = mapFn ? new Set() : ''; 33 await Promise.race([errorHandler, Promise.resolve()]); 34 // If no mapFn, return the value. If there is a mapFn, use it to make a Set to 35 // return. 36 for await (const line of lines) { 37 await Promise.race([errorHandler, Promise.resolve()]); 38 if (mapFn) { 39 const val = mapFn(line); 40 if (val) { 41 returnValue.add(val); 42 } 43 } else { 44 returnValue += line; 45 } 46 } 47 return Promise.race([errorHandler, Promise.resolve(returnValue)]); 48} 49 50// Get all commit authors during the time period. 51const authors = await runGitCommand( 52 `git shortlog -n -s --email --since="${SINCE}" HEAD`, 53 (line) => line.trim().split('\t', 2)[1], 54); 55 56// Get all approving reviewers of landed commits during the time period. 57const approvingReviewers = await runGitCommand( 58 `git log --since="${SINCE}" | egrep "^ Reviewed-By: "`, 59 (line) => /^ {4}Reviewed-By: ([^<]+)/.exec(line)[1].trim(), 60); 61 62async function getCollaboratorsFromReadme() { 63 const readmeText = readline.createInterface({ 64 input: fs.createReadStream(new URL('../README.md', import.meta.url)), 65 crlfDelay: Infinity, 66 }); 67 const returnedArray = []; 68 let foundCollaboratorHeading = false; 69 for await (const line of readmeText) { 70 // If we've found the collaborator heading already, stop processing at the 71 // next heading. 72 if (foundCollaboratorHeading && line.startsWith('#')) { 73 break; 74 } 75 76 const isCollaborator = foundCollaboratorHeading && line.length; 77 78 if (line === '### Collaborators') { 79 foundCollaboratorHeading = true; 80 } 81 if (line.startsWith(' **') && isCollaborator) { 82 const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line); 83 const mailmap = await runGitCommand( 84 `git check-mailmap '${name} <${email}>'`, 85 ); 86 if (mailmap !== `${name} <${email}>`) { 87 console.log(`README entry for Collaborator does not match mailmap:\n ${name} <${email}> => ${mailmap}`); 88 } 89 returnedArray.push({ 90 name, 91 email, 92 mailmap, 93 }); 94 } 95 } 96 97 if (!foundCollaboratorHeading) { 98 throw new Error('Could not find Collaborator section of README'); 99 } 100 101 return returnedArray; 102} 103 104async function moveCollaboratorToEmeritus(peopleToMove) { 105 const readmeText = readline.createInterface({ 106 input: fs.createReadStream(new URL('../README.md', import.meta.url)), 107 crlfDelay: Infinity, 108 }); 109 let fileContents = ''; 110 let inCollaboratorsSection = false; 111 let inCollaboratorEmeritusSection = false; 112 let collaboratorFirstLine = ''; 113 const textToMove = []; 114 for await (const line of readmeText) { 115 // If we've been processing collaborator emeriti and we reach the end of 116 // the list, print out the remaining entries to be moved because they come 117 // alphabetically after the last item. 118 if (inCollaboratorEmeritusSection && line === '' && 119 fileContents.endsWith('>\n')) { 120 while (textToMove.length) { 121 fileContents += textToMove.pop(); 122 } 123 } 124 125 // If we've found the collaborator heading already, stop processing at the 126 // next heading. 127 if (line.startsWith('#')) { 128 inCollaboratorsSection = false; 129 inCollaboratorEmeritusSection = false; 130 } 131 132 const isCollaborator = inCollaboratorsSection && line.length; 133 const isCollaboratorEmeritus = inCollaboratorEmeritusSection && line.length; 134 135 if (line === '### Collaborators') { 136 inCollaboratorsSection = true; 137 } 138 if (line === '### Collaborator emeriti') { 139 inCollaboratorEmeritusSection = true; 140 } 141 142 if (isCollaborator) { 143 if (line.startsWith('* ')) { 144 collaboratorFirstLine = line; 145 } else if (line.startsWith(' **')) { 146 const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line); 147 if (peopleToMove.some((entry) => { 148 return entry.name === name && entry.email === email; 149 })) { 150 textToMove.push(`${collaboratorFirstLine}\n${line}\n`); 151 } else { 152 fileContents += `${collaboratorFirstLine}\n${line}\n`; 153 } 154 } else { 155 fileContents += `${line}\n`; 156 } 157 } 158 159 if (isCollaboratorEmeritus) { 160 if (line.startsWith('* ')) { 161 collaboratorFirstLine = line; 162 } else if (line.startsWith(' **')) { 163 const currentLine = `${collaboratorFirstLine}\n${line}\n`; 164 // If textToMove is empty, this still works because when undefined is 165 // used in a comparison with <, the result is always false. 166 while (textToMove[0]?.toLowerCase() < currentLine.toLowerCase()) { 167 fileContents += textToMove.shift(); 168 } 169 fileContents += currentLine; 170 } else { 171 fileContents += `${line}\n`; 172 } 173 } 174 175 if (!isCollaborator && !isCollaboratorEmeritus) { 176 fileContents += `${line}\n`; 177 } 178 } 179 180 return fileContents; 181} 182 183// Get list of current collaborators from README.md. 184const collaborators = await getCollaboratorsFromReadme(); 185 186if (verbose) { 187 console.log(`Since ${SINCE}:\n`); 188 console.log(`* ${authors.size.toLocaleString()} authors have made commits.`); 189 console.log(`* ${approvingReviewers.size.toLocaleString()} reviewers have approved landed commits.`); 190 console.log(`* ${collaborators.length.toLocaleString()} collaborators currently in the project.`); 191} 192const inactive = collaborators.filter((collaborator) => 193 !authors.has(collaborator.mailmap) && 194 !approvingReviewers.has(collaborator.name), 195); 196 197if (inactive.length) { 198 console.log('\nInactive collaborators:\n'); 199 console.log(inactive.map((entry) => `* ${entry.name}`).join('\n')); 200 if (process.env.GITHUB_ACTIONS) { 201 console.log('\nGenerating new README.md file...'); 202 const newReadmeText = await moveCollaboratorToEmeritus(inactive); 203 fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText); 204 } 205} 206