2021-07-05 01:10:20 +00:00
|
|
|
#!/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';
|
2022-07-26 04:08:27 +00:00
|
|
|
import { parseArgs } from 'node:util';
|
2021-07-05 01:10:20 +00:00
|
|
|
|
2022-07-26 04:08:27 +00:00
|
|
|
const args = parseArgs({
|
|
|
|
allowPositionals: true,
|
2022-12-18 16:39:39 +00:00
|
|
|
options: { verbose: { type: 'boolean', short: 'v' } },
|
2022-07-26 04:08:27 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
const verbose = args.values.verbose;
|
2024-04-10 19:03:09 +00:00
|
|
|
const SINCE = args.positionals[0] || '12 months ago';
|
2021-07-05 01:10:20 +00:00
|
|
|
|
|
|
|
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(
|
2022-12-18 16:39:39 +00:00
|
|
|
(_, reject) => childProcess.on('error', reject),
|
2021-07-05 01:10:20 +00:00
|
|
|
);
|
2021-07-19 04:30:03 +00:00
|
|
|
let returnValue = mapFn ? new Set() : '';
|
2021-07-05 01:10:20 +00:00
|
|
|
await Promise.race([errorHandler, Promise.resolve()]);
|
2021-07-19 04:30:03 +00:00
|
|
|
// If no mapFn, return the value. If there is a mapFn, use it to make a Set to
|
|
|
|
// return.
|
2021-07-05 01:10:20 +00:00
|
|
|
for await (const line of lines) {
|
|
|
|
await Promise.race([errorHandler, Promise.resolve()]);
|
2021-07-19 04:30:03 +00:00
|
|
|
if (mapFn) {
|
|
|
|
const val = mapFn(line);
|
|
|
|
if (val) {
|
|
|
|
returnValue.add(val);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
returnValue += line;
|
2021-07-05 01:10:20 +00:00
|
|
|
}
|
|
|
|
}
|
2021-07-19 04:30:03 +00:00
|
|
|
return Promise.race([errorHandler, Promise.resolve(returnValue)]);
|
2021-07-05 01:10:20 +00:00
|
|
|
}
|
|
|
|
|
2024-04-26 19:54:54 +00:00
|
|
|
// 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,
|
2021-07-05 01:10:20 +00:00
|
|
|
);
|
|
|
|
|
2021-07-19 04:30:03 +00:00
|
|
|
async function getCollaboratorsFromReadme() {
|
2021-07-05 01:10:20 +00:00
|
|
|
const readmeText = readline.createInterface({
|
|
|
|
input: fs.createReadStream(new URL('../README.md', import.meta.url)),
|
|
|
|
crlfDelay: Infinity,
|
|
|
|
});
|
|
|
|
const returnedArray = [];
|
2021-08-01 13:29:20 +00:00
|
|
|
let foundCollaboratorHeading = false;
|
2021-07-05 01:10:20 +00:00
|
|
|
for await (const line of readmeText) {
|
2021-08-01 13:29:20 +00:00
|
|
|
// If we've found the collaborator heading already, stop processing at the
|
|
|
|
// next heading.
|
|
|
|
if (foundCollaboratorHeading && line.startsWith('#')) {
|
2021-07-05 01:10:20 +00:00
|
|
|
break;
|
|
|
|
}
|
2021-08-01 13:29:20 +00:00
|
|
|
|
|
|
|
const isCollaborator = foundCollaboratorHeading && line.length;
|
|
|
|
|
|
|
|
if (line === '### Collaborators') {
|
|
|
|
foundCollaboratorHeading = true;
|
|
|
|
}
|
2021-09-17 22:44:08 +00:00
|
|
|
if (line.startsWith(' **') && isCollaborator) {
|
2021-12-23 23:19:19 +00:00
|
|
|
const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line);
|
2021-07-19 04:30:03 +00:00
|
|
|
const mailmap = await runGitCommand(
|
2022-12-18 16:39:39 +00:00
|
|
|
`git check-mailmap '${name} <${email}>'`,
|
2021-07-19 04:30:03 +00:00
|
|
|
);
|
2021-07-19 05:04:10 +00:00
|
|
|
if (mailmap !== `${name} <${email}>`) {
|
|
|
|
console.log(`README entry for Collaborator does not match mailmap:\n ${name} <${email}> => ${mailmap}`);
|
|
|
|
}
|
2021-07-19 04:30:03 +00:00
|
|
|
returnedArray.push({
|
|
|
|
name,
|
|
|
|
email,
|
|
|
|
mailmap,
|
|
|
|
});
|
2021-07-05 01:10:20 +00:00
|
|
|
}
|
|
|
|
}
|
2021-08-01 13:29:20 +00:00
|
|
|
|
|
|
|
if (!foundCollaboratorHeading) {
|
|
|
|
throw new Error('Could not find Collaborator section of README');
|
|
|
|
}
|
|
|
|
|
2021-07-05 01:10:20 +00:00
|
|
|
return returnedArray;
|
|
|
|
}
|
|
|
|
|
2021-08-21 00:54:28 +00:00
|
|
|
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 === '' &&
|
2021-09-20 14:03:44 +00:00
|
|
|
fileContents.endsWith('>\n')) {
|
2021-08-21 00:54:28 +00:00
|
|
|
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;
|
2021-09-17 22:44:08 +00:00
|
|
|
} else if (line.startsWith(' **')) {
|
2021-12-23 23:19:19 +00:00
|
|
|
const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line);
|
2021-08-21 00:54:28 +00:00
|
|
|
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;
|
2021-09-17 22:44:08 +00:00
|
|
|
} else if (line.startsWith(' **')) {
|
2021-08-21 00:54:28 +00:00
|
|
|
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.
|
2022-10-28 04:21:20 +00:00
|
|
|
while (textToMove[0]?.toLowerCase() < currentLine.toLowerCase()) {
|
2021-08-21 00:54:28 +00:00
|
|
|
fileContents += textToMove.shift();
|
|
|
|
}
|
|
|
|
fileContents += currentLine;
|
|
|
|
} else {
|
|
|
|
fileContents += `${line}\n`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!isCollaborator && !isCollaboratorEmeritus) {
|
|
|
|
fileContents += `${line}\n`;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return fileContents;
|
|
|
|
}
|
|
|
|
|
2021-07-05 01:10:20 +00:00
|
|
|
// Get list of current collaborators from README.md.
|
2021-07-19 04:30:03 +00:00
|
|
|
const collaborators = await getCollaboratorsFromReadme();
|
2021-07-05 01:10:20 +00:00
|
|
|
|
2022-07-26 04:08:27 +00:00
|
|
|
if (verbose) {
|
|
|
|
console.log(`Since ${SINCE}:\n`);
|
2024-04-26 19:54:54 +00:00
|
|
|
console.log(`* ${contributors.size.toLocaleString()} contributors`);
|
2022-07-26 04:08:27 +00:00
|
|
|
console.log(`* ${collaborators.length.toLocaleString()} collaborators currently in the project.`);
|
|
|
|
}
|
2021-07-05 01:10:20 +00:00
|
|
|
const inactive = collaborators.filter((collaborator) =>
|
2024-04-26 19:54:54 +00:00
|
|
|
!contributors.has(collaborator.mailmap),
|
2021-08-21 00:54:28 +00:00
|
|
|
);
|
2021-07-05 01:10:20 +00:00
|
|
|
|
|
|
|
if (inactive.length) {
|
2021-07-12 16:22:27 +00:00
|
|
|
console.log('\nInactive collaborators:\n');
|
2021-08-21 00:54:28 +00:00
|
|
|
console.log(inactive.map((entry) => `* ${entry.name}`).join('\n'));
|
2022-01-10 10:14:31 +00:00
|
|
|
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);
|
|
|
|
}
|
2021-07-05 01:10:20 +00:00
|
|
|
}
|