#!/usr/bin/env node // Identify inactive TSC members. // From the TSC Charter: // A TSC member is automatically removed from the TSC if, during a 3-month // period, all of the following are true: // * They attend fewer than 25% of the regularly scheduled meetings. // * They do not participate in any 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; const SINCE = args.positionals[0] || '3 months ago'; async function runGitCommand(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.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 (options.mapFn) { const val = options.mapFn(line); if (val) { returnValue.add(val); } } 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) { // 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 getAttendance(tscMembers, meetings) { const attendance = {}; for (const member of tscMembers) { attendance[member] = 0; } for (const meeting of meetings) { // Get the file contents. const meetingFile = await fs.promises.readFile(path.join('.tmp', meeting), 'utf8'); // Extract the attendee list. const startMarker = '## Present'; const start = meetingFile.indexOf(startMarker) + startMarker.length; const end = meetingFile.indexOf('## Agenda'); meetingFile.substring(start, end).trim().split('\n') .map((line) => { const match = line.match(/@(\S+)/); if (match) { return match[1]; } // Using `console.warn` so that stdout output is not generated. // The stdout output is consumed in find-inactive-tsc.yml. console.warn(`Attendee entry does not contain GitHub handle: ${line}`); return ''; }) .filter((handle) => tscMembers.includes(handle)) .forEach((handle) => { attendance[handle]++; }); } return attendance; } 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', vote), 'utf8') ); for (const member in voteData.votes) { if (tscMembers.includes(member)) { votingRecords[member]++; } } } return votingRecords; } async function moveTscToEmeritus(peopleToMove) { const readmeText = readline.createInterface({ input: fs.createReadStream(new URL('../README.md', import.meta.url)), crlfDelay: Infinity, }); let fileContents = ''; let inTscSection = false; let inTscEmeritusSection = false; let memberFirstLine = ''; const textToMove = []; let moveToInactive = false; for await (const line of readmeText) { // If we've been processing TSC 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 (inTscEmeritusSection && 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('#')) { inTscSection = false; inTscEmeritusSection = false; } const isTsc = inTscSection && line.length; const isTscEmeritus = inTscEmeritusSection && line.length; if (line === '### TSC (Technical Steering Committee)') { inTscSection = true; } if (line === '### TSC emeriti') { inTscEmeritusSection = true; } if (isTsc) { 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 (isTscEmeritus) { 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] < currentLine) { fileContents += textToMove.shift(); } fileContents += currentLine; } else { fileContents += `${line}\n`; } } if (!isTsc && !isTscEmeritus) { fileContents += `${line}\n`; } } return fileContents; } // Get current TSC members, then get TSC members at start of period. Only check // TSC members who are on both lists. This way, we don't flag someone who has // only been on the TSC for a week and therefore hasn't attended any meetings. const tscMembersAtEnd = await getTscFromReadme(); const startCommit = await runGitCommand(`git rev-list -1 --before '${SINCE}' HEAD`); await runGitCommand(`git checkout ${startCommit} -- README.md`); const tscMembersAtStart = await getTscFromReadme(); await runGitCommand('git reset HEAD README.md'); await runGitCommand('git checkout -- README.md'); const tscMembers = tscMembersAtEnd.filter( (memberAtEnd) => tscMembersAtStart.includes(memberAtEnd) ); // Get all meetings since SINCE. // Assumes that the TSC repo is cloned in the .tmp dir. const meetings = await runGitCommand( `git whatchanged --since '${SINCE}' --name-only --pretty=format: meetings`, { cwd: '.tmp', mapFn: (line) => line } ); // Get TSC meeting attendance. const attendance = await getAttendance(tscMembers, meetings); const lightAttendance = tscMembers.filter( (member) => attendance[member] < meetings.size * 0.25 ); // Get all votes since SINCE. // Assumes that the TSC repo is cloned in the .tmp dir. const votes = await runGitCommand( `git whatchanged --since '${SINCE}' --name-only --pretty=format: votes/*.json`, { cwd: '.tmp', mapFn: (line) => line } ); // Check voting record. const votingRecords = await getVotingRecords(tscMembers, votes); const noVotes = tscMembers.filter( (member) => votingRecords[member] === 0 ); const inactive = lightAttendance.filter((member) => noVotes.includes(member)); 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.map((entry) => { let details = `Since ${SINCE}, `; details += `${entry} attended ${attendance[entry]} out of ${meetings.size} meetings`; details += ` and voted in ${votingRecords[entry]} of ${votes.size} votes.`; return details; }); console.log(`DETAILS_FOR_COMMIT_BODY=${commitDetails.join(' ')}`); 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 moveTscToEmeritus(inactive); fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText); } } if (verbose) { console.log(attendance); console.log(votingRecords); }