| #!/usr/bin/env node |
| |
| // Identify inactive collaborators. "Inactive" is not quite right, as the things |
| // this checks for are not the entirety of collaborator activities. Still, it is |
| // a pretty good proxy. Feel free to suggest or implement further metrics. |
| |
| import cp from 'node:child_process'; |
| import fs from 'node:fs'; |
| import readline from 'node:readline'; |
| import { parseArgs } from 'node:util'; |
| |
| const args = parseArgs({ |
| allowPositionals: true, |
| options: { verbose: { type: 'boolean', short: 'v' } }, |
| }); |
| |
| const verbose = args.values.verbose; |
| const SINCE = args.positionals[0] || '12 months ago'; |
| |
| async function runGitCommand(cmd, mapFn) { |
| const childProcess = cp.spawn('/bin/sh', ['-c', cmd], { |
| cwd: new URL('..', import.meta.url), |
| encoding: 'utf8', |
| stdio: ['inherit', 'pipe', 'inherit'], |
| }); |
| const lines = readline.createInterface({ |
| input: childProcess.stdout, |
| }); |
| const errorHandler = new Promise( |
| (_, reject) => childProcess.on('error', reject), |
| ); |
| let returnValue = mapFn ? new Set() : ''; |
| await Promise.race([errorHandler, Promise.resolve()]); |
| // If no mapFn, return the value. If there is a mapFn, use it to make a Set to |
| // return. |
| for await (const line of lines) { |
| await Promise.race([errorHandler, Promise.resolve()]); |
| if (mapFn) { |
| const val = mapFn(line); |
| if (val) { |
| returnValue.add(val); |
| } |
| } else { |
| returnValue += line; |
| } |
| } |
| return Promise.race([errorHandler, Promise.resolve(returnValue)]); |
| } |
| |
| // Get all commit contributors during the time period. |
| const contributors = await runGitCommand( |
| `git log --pretty='format:%aN <%aE>%n%(trailers:only,valueonly,key=Co-authored-by)%n%(trailers:only,valueonly,key=Reviewed-by)' --since="${SINCE}" HEAD`, |
| String, |
| ); |
| |
| async function getCollaboratorsFromReadme() { |
| const readmeText = readline.createInterface({ |
| input: fs.createReadStream(new URL('../README.md', import.meta.url)), |
| crlfDelay: Infinity, |
| }); |
| const returnedArray = []; |
| let foundCollaboratorHeading = false; |
| for await (const line of readmeText) { |
| // If we've found the collaborator heading already, stop processing at the |
| // next heading. |
| if (foundCollaboratorHeading && line.startsWith('#')) { |
| break; |
| } |
| |
| const isCollaborator = foundCollaboratorHeading && line.length; |
| |
| if (line === '### Collaborators') { |
| foundCollaboratorHeading = true; |
| } |
| if (line.startsWith(' **') && isCollaborator) { |
| const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line); |
| const mailmap = await runGitCommand( |
| `git check-mailmap '${name} <${email}>'`, |
| ); |
| if (mailmap !== `${name} <${email}>`) { |
| console.log(`README entry for Collaborator does not match mailmap:\n ${name} <${email}> => ${mailmap}`); |
| } |
| returnedArray.push({ |
| name, |
| email, |
| mailmap, |
| }); |
| } |
| } |
| |
| if (!foundCollaboratorHeading) { |
| throw new Error('Could not find Collaborator section of README'); |
| } |
| |
| return returnedArray; |
| } |
| |
| async function moveCollaboratorToEmeritus(peopleToMove) { |
| const readmeText = readline.createInterface({ |
| input: fs.createReadStream(new URL('../README.md', import.meta.url)), |
| crlfDelay: Infinity, |
| }); |
| let fileContents = ''; |
| let inCollaboratorsSection = false; |
| let inCollaboratorEmeritusSection = false; |
| let collaboratorFirstLine = ''; |
| const textToMove = []; |
| for await (const line of readmeText) { |
| // If we've been processing collaborator emeriti and we reach the end of |
| // the list, print out the remaining entries to be moved because they come |
| // alphabetically after the last item. |
| if (inCollaboratorEmeritusSection && line === '' && |
| fileContents.endsWith('>\n')) { |
| while (textToMove.length) { |
| fileContents += textToMove.pop(); |
| } |
| } |
| |
| // If we've found the collaborator heading already, stop processing at the |
| // next heading. |
| if (line.startsWith('#')) { |
| inCollaboratorsSection = false; |
| inCollaboratorEmeritusSection = false; |
| } |
| |
| const isCollaborator = inCollaboratorsSection && line.length; |
| const isCollaboratorEmeritus = inCollaboratorEmeritusSection && line.length; |
| |
| if (line === '### Collaborators') { |
| inCollaboratorsSection = true; |
| } |
| if (line === '### Collaborator emeriti') { |
| inCollaboratorEmeritusSection = true; |
| } |
| |
| if (isCollaborator) { |
| if (line.startsWith('* ')) { |
| collaboratorFirstLine = line; |
| } else if (line.startsWith(' **')) { |
| const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line); |
| if (peopleToMove.some((entry) => { |
| return entry.name === name && entry.email === email; |
| })) { |
| textToMove.push(`${collaboratorFirstLine}\n${line}\n`); |
| } else { |
| fileContents += `${collaboratorFirstLine}\n${line}\n`; |
| } |
| } else { |
| fileContents += `${line}\n`; |
| } |
| } |
| |
| if (isCollaboratorEmeritus) { |
| if (line.startsWith('* ')) { |
| collaboratorFirstLine = line; |
| } else if (line.startsWith(' **')) { |
| const currentLine = `${collaboratorFirstLine}\n${line}\n`; |
| // If textToMove is empty, this still works because when undefined is |
| // used in a comparison with <, the result is always false. |
| while (textToMove[0]?.toLowerCase() < currentLine.toLowerCase()) { |
| fileContents += textToMove.shift(); |
| } |
| fileContents += currentLine; |
| } else { |
| fileContents += `${line}\n`; |
| } |
| } |
| |
| if (!isCollaborator && !isCollaboratorEmeritus) { |
| fileContents += `${line}\n`; |
| } |
| } |
| |
| return fileContents; |
| } |
| |
| // Get list of current collaborators from README.md. |
| const collaborators = await getCollaboratorsFromReadme(); |
| |
| if (verbose) { |
| console.log(`Since ${SINCE}:\n`); |
| console.log(`* ${contributors.size.toLocaleString()} contributors`); |
| console.log(`* ${collaborators.length.toLocaleString()} collaborators currently in the project.`); |
| } |
| const inactive = collaborators.filter((collaborator) => |
| !contributors.has(collaborator.mailmap), |
| ); |
| |
| if (inactive.length) { |
| console.log('\nInactive collaborators:\n'); |
| console.log(inactive.map((entry) => `* ${entry.name}`).join('\n')); |
| if (process.env.GITHUB_ACTIONS) { |
| console.log('\nGenerating new README.md file...'); |
| const newReadmeText = await moveCollaboratorToEmeritus(inactive); |
| fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText); |
| } |
| } |