1#!/usr/bin/env node 2 3// Identify inactive TSC voting members. 4 5// From the TSC Charter: 6// A TSC voting member is automatically converted to a TSC regular member if 7// they do not participate in three consecutive TSC votes. 8 9import cp from 'node:child_process'; 10import fs from 'node:fs'; 11import path from 'node:path'; 12import readline from 'node:readline'; 13import { parseArgs } from 'node:util'; 14 15const args = parseArgs({ 16 allowPositionals: true, 17 options: { verbose: { type: 'boolean', short: 'v' } }, 18}); 19 20const verbose = args.values.verbose; 21 22async function runShellCommand(cmd, options = {}) { 23 const childProcess = cp.spawn('/bin/sh', ['-c', cmd], { 24 cwd: options.cwd ?? new URL('..', import.meta.url), 25 encoding: 'utf8', 26 stdio: ['inherit', 'pipe', 'inherit'], 27 }); 28 const lines = readline.createInterface({ 29 input: childProcess.stdout, 30 }); 31 const errorHandler = new Promise( 32 (_, reject) => childProcess.on('error', reject), 33 ); 34 let returnValue = options.returnAsArray ? [] : ''; 35 await Promise.race([errorHandler, Promise.resolve()]); 36 // If no mapFn, return the value. If there is a mapFn, use it to make a Set to 37 // return. 38 for await (const line of lines) { 39 await Promise.race([errorHandler, Promise.resolve()]); 40 if (options.returnAsArray) { 41 returnValue.push(line); 42 } else { 43 returnValue += line; 44 } 45 } 46 return Promise.race([errorHandler, Promise.resolve(returnValue)]); 47} 48 49async function getTscFromReadme() { 50 const readmeText = readline.createInterface({ 51 input: fs.createReadStream(new URL('../README.md', import.meta.url)), 52 crlfDelay: Infinity, 53 }); 54 const returnedArray = []; 55 let foundTscHeading = false; 56 for await (const line of readmeText) { 57 // Until three votes have passed from March 16, 2023, we will need this. 58 // After that point, we can use this for setting `foundTscHeading` below 59 // and remove this. 60 if (line === '#### TSC voting members') { 61 continue; 62 } 63 64 // If we've found the TSC heading already, stop processing at the next 65 // heading. 66 if (foundTscHeading && line.startsWith('#')) { 67 break; 68 } 69 70 const isTsc = foundTscHeading && line.length; 71 72 if (line === '### TSC (Technical Steering Committee)') { 73 foundTscHeading = true; 74 } 75 if (line.startsWith('* ') && isTsc) { 76 const handle = line.match(/^\* \[([^\]]+)]/)[1]; 77 returnedArray.push(handle); 78 } 79 } 80 81 if (!foundTscHeading) { 82 throw new Error('Could not find TSC section of README'); 83 } 84 85 return returnedArray; 86} 87 88async function getVotingRecords(tscMembers, votes) { 89 const votingRecords = {}; 90 for (const member of tscMembers) { 91 votingRecords[member] = 0; 92 } 93 for (const vote of votes) { 94 // Get the vote data. 95 const voteData = JSON.parse( 96 await fs.promises.readFile(path.join('.tmp/votes', vote), 'utf8'), 97 ); 98 for (const member in voteData.votes) { 99 if (tscMembers.includes(member)) { 100 votingRecords[member]++; 101 } 102 } 103 } 104 return votingRecords; 105} 106 107async function moveVotingToRegular(peopleToMove) { 108 const readmeText = readline.createInterface({ 109 input: fs.createReadStream(new URL('../README.md', import.meta.url)), 110 crlfDelay: Infinity, 111 }); 112 let fileContents = ''; 113 let inTscVotingSection = false; 114 let inTscRegularSection = false; 115 let memberFirstLine = ''; 116 const textToMove = []; 117 let moveToInactive = false; 118 for await (const line of readmeText) { 119 // If we've been processing TSC regular members and we reach the end of 120 // the list, print out the remaining entries to be moved because they come 121 // alphabetically after the last item. 122 if (inTscRegularSection && line === '' && 123 fileContents.endsWith('>\n')) { 124 while (textToMove.length) { 125 fileContents += textToMove.pop(); 126 } 127 } 128 129 // If we've found the TSC heading already, stop processing at the 130 // next heading. 131 if (line.startsWith('#')) { 132 inTscVotingSection = false; 133 inTscRegularSection = false; 134 } 135 136 const isTscVoting = inTscVotingSection && line.length; 137 const isTscRegular = inTscRegularSection && line.length; 138 139 if (line === '#### TSC voting members') { 140 inTscVotingSection = true; 141 } 142 if (line === '#### TSC regular members') { 143 inTscRegularSection = true; 144 } 145 146 if (isTscVoting) { 147 if (line.startsWith('* ')) { 148 memberFirstLine = line; 149 const match = line.match(/^\* \[([^\]]+)/); 150 if (match && peopleToMove.includes(match[1])) { 151 moveToInactive = true; 152 } 153 } else if (line.startsWith(' **')) { 154 if (moveToInactive) { 155 textToMove.push(`${memberFirstLine}\n${line}\n`); 156 moveToInactive = false; 157 } else { 158 fileContents += `${memberFirstLine}\n${line}\n`; 159 } 160 } else { 161 fileContents += `${line}\n`; 162 } 163 } 164 165 if (isTscRegular) { 166 if (line.startsWith('* ')) { 167 memberFirstLine = line; 168 } else if (line.startsWith(' **')) { 169 const currentLine = `${memberFirstLine}\n${line}\n`; 170 // If textToMove is empty, this still works because when undefined is 171 // used in a comparison with <, the result is always false. 172 while (textToMove[0]?.toLowerCase() < currentLine.toLowerCase()) { 173 fileContents += textToMove.shift(); 174 } 175 fileContents += currentLine; 176 } else { 177 fileContents += `${line}\n`; 178 } 179 } 180 181 if (!isTscVoting && !isTscRegular) { 182 fileContents += `${line}\n`; 183 } 184 } 185 186 return fileContents; 187} 188 189// Get current TSC voting members, then get TSC voting members at start of 190// period. Only check TSC voting members who are on both lists. This way, we 191// don't flag someone who hasn't been on the TSC long enough to have missed 3 192// consecutive votes. 193const tscMembersAtEnd = await getTscFromReadme(); 194 195// Get the last three votes. 196// Assumes that the TSC repo is cloned in the .tmp dir. 197const votes = await runShellCommand( 198 'ls *.json | sort -rn | head -3', 199 { cwd: '.tmp/votes', returnAsArray: true }, 200); 201 202// Reverse the votes list so the oldest of the three votes is first. 203votes.reverse(); 204 205const startCommit = await runShellCommand(`git rev-list -1 --before '${votes[0]}' HEAD`); 206await runShellCommand(`git checkout ${startCommit} -- README.md`); 207const tscMembersAtStart = await getTscFromReadme(); 208await runShellCommand('git reset HEAD README.md'); 209await runShellCommand('git checkout -- README.md'); 210 211const tscMembers = tscMembersAtEnd.filter( 212 (memberAtEnd) => tscMembersAtStart.includes(memberAtEnd), 213); 214 215// Check voting record. 216const votingRecords = await getVotingRecords(tscMembers, votes); 217const inactive = tscMembers.filter( 218 (member) => votingRecords[member] === 0, 219); 220 221if (inactive.length) { 222 // The stdout output is consumed in find-inactive-tsc.yml. If format of output 223 // changes, find-inactive-tsc.yml may need to be updated. 224 console.log(`INACTIVE_TSC_HANDLES=${inactive.map((entry) => '@' + entry).join(' ')}`); 225 const commitDetails = `${inactive.join(' ')} did not participate in three consecutive TSC votes: ${votes.join(' ')}`; 226 console.log(`DETAILS_FOR_COMMIT_BODY=${commitDetails}`); 227 228 if (process.env.GITHUB_ACTIONS) { 229 // Using console.warn() to avoid messing with find-inactive-tsc which 230 // consumes stdout. 231 console.warn('Generating new README.md file...'); 232 const newReadmeText = await moveVotingToRegular(inactive); 233 fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText); 234 } 235} 236 237if (verbose) { 238 console.log(votingRecords); 239} 240