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