#!/usr/bin/env node // Identify inactive TSC voting members. // From the TSC Charter: // A TSC voting member is automatically converted to a TSC regular member if // they do not participate in three consecutive TSC votes. import cp from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; 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; async function runShellCommand(cmd, options = {}) { const childProcess = cp.spawn('/bin/sh', ['-c', cmd], { cwd: options.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 = options.returnAsArray ? [] : ''; 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 (options.returnAsArray) { returnValue.push(line); } else { returnValue += line; } } return Promise.race([errorHandler, Promise.resolve(returnValue)]); } async function getTscFromReadme() { const readmeText = readline.createInterface({ input: fs.createReadStream(new URL('../README.md', import.meta.url)), crlfDelay: Infinity, }); const returnedArray = []; let foundTscHeading = false; for await (const line of readmeText) { // Until three votes have passed from March 16, 2023, we will need this. // After that point, we can use this for setting `foundTscHeading` below // and remove this. if (line === '#### TSC voting members') { continue; } // If we've found the TSC heading already, stop processing at the next // heading. if (foundTscHeading && line.startsWith('#')) { break; } const isTsc = foundTscHeading && line.length; if (line === '### TSC (Technical Steering Committee)') { foundTscHeading = true; } if (line.startsWith('* ') && isTsc) { const handle = line.match(/^\* \[([^\]]+)]/)[1]; returnedArray.push(handle); } } if (!foundTscHeading) { throw new Error('Could not find TSC section of README'); } return returnedArray; } async function getVotingRecords(tscMembers, votes) { const votingRecords = {}; for (const member of tscMembers) { votingRecords[member] = 0; } for (const vote of votes) { // Get the vote data. const voteData = JSON.parse( await fs.promises.readFile(path.join('.tmp/votes', vote), 'utf8'), ); for (const member in voteData.votes) { if (tscMembers.includes(member)) { votingRecords[member]++; } } } return votingRecords; } async function moveVotingToRegular(peopleToMove) { const readmeText = readline.createInterface({ input: fs.createReadStream(new URL('../README.md', import.meta.url)), crlfDelay: Infinity, }); let fileContents = ''; let inTscVotingSection = false; let inTscRegularSection = false; let memberFirstLine = ''; const textToMove = []; let moveToInactive = false; for await (const line of readmeText) { // If we've been processing TSC regular members 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 (inTscRegularSection && line === '' && fileContents.endsWith('>\n')) { while (textToMove.length) { fileContents += textToMove.pop(); } } // If we've found the TSC heading already, stop processing at the // next heading. if (line.startsWith('#')) { inTscVotingSection = false; inTscRegularSection = false; } const isTscVoting = inTscVotingSection && line.length; const isTscRegular = inTscRegularSection && line.length; if (line === '#### TSC voting members') { inTscVotingSection = true; } if (line === '#### TSC regular members') { inTscRegularSection = true; } if (isTscVoting) { if (line.startsWith('* ')) { memberFirstLine = line; const match = line.match(/^\* \[([^\]]+)/); if (match && peopleToMove.includes(match[1])) { moveToInactive = true; } } else if (line.startsWith(' **')) { if (moveToInactive) { textToMove.push(`${memberFirstLine}\n${line}\n`); moveToInactive = false; } else { fileContents += `${memberFirstLine}\n${line}\n`; } } else { fileContents += `${line}\n`; } } if (isTscRegular) { if (line.startsWith('* ')) { memberFirstLine = line; } else if (line.startsWith(' **')) { const currentLine = `${memberFirstLine}\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 (!isTscVoting && !isTscRegular) { fileContents += `${line}\n`; } } return fileContents; } // Get current TSC voting members, then get TSC voting members at start of // period. Only check TSC voting members who are on both lists. This way, we // don't flag someone who hasn't been on the TSC long enough to have missed 3 // consecutive votes. const tscMembersAtEnd = await getTscFromReadme(); // Get the last three votes. // Assumes that the TSC repo is cloned in the .tmp dir. const votes = await runShellCommand( 'ls *.json | sort -rn | head -3', { cwd: '.tmp/votes', returnAsArray: true }, ); // Reverse the votes list so the oldest of the three votes is first. votes.reverse(); const startCommit = await runShellCommand(`git rev-list -1 --before '${votes[0]}' HEAD`); await runShellCommand(`git checkout ${startCommit} -- README.md`); const tscMembersAtStart = await getTscFromReadme(); await runShellCommand('git reset HEAD README.md'); await runShellCommand('git checkout -- README.md'); const tscMembers = tscMembersAtEnd.filter( (memberAtEnd) => tscMembersAtStart.includes(memberAtEnd), ); // Check voting record. const votingRecords = await getVotingRecords(tscMembers, votes); const inactive = tscMembers.filter( (member) => votingRecords[member] === 0, ); if (inactive.length) { // The stdout output is consumed in find-inactive-tsc.yml. If format of output // changes, find-inactive-tsc.yml may need to be updated. console.log(`INACTIVE_TSC_HANDLES=${inactive.map((entry) => '@' + entry).join(' ')}`); const commitDetails = `${inactive.join(' ')} did not participate in three consecutive TSC votes: ${votes.join(' ')}`; console.log(`DETAILS_FOR_COMMIT_BODY=${commitDetails}`); if (process.env.GITHUB_ACTIONS) { // Using console.warn() to avoid messing with find-inactive-tsc which // consumes stdout. console.warn('Generating new README.md file...'); const newReadmeText = await moveVotingToRegular(inactive); fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText); } } if (verbose) { console.log(votingRecords); }