diff --git a/.github/workflows/find-inactive-collaborators.yml b/.github/workflows/find-inactive-collaborators.yml index 4de6f34691f2cc..2fc2b9036fe6ef 100644 --- a/.github/workflows/find-inactive-collaborators.yml +++ b/.github/workflows/find-inactive-collaborators.yml @@ -2,13 +2,13 @@ name: Find inactive collaborators on: schedule: - # Run on the 15th day of the month at 4:05 AM UTC. - - cron: '5 4 15 * *' + # Run every Monday at 4:05 AM UTC. + - cron: '5 4 * * 1' workflow_dispatch: env: - NODE_VERSION: 16.x + NODE_VERSION: lts/* NUM_COMMITS: 5000 jobs: diff --git a/.github/workflows/find-inactive-tsc.yml b/.github/workflows/find-inactive-tsc.yml new file mode 100644 index 00000000000000..978da55dc5c389 --- /dev/null +++ b/.github/workflows/find-inactive-tsc.yml @@ -0,0 +1,47 @@ +name: Find inactive TSC members + +on: + schedule: + # Run every Tuesday 12:05 AM UTC. + - cron: '5 0 * * 2' + + workflow_dispatch: + +env: + NODE_VERSION: lts/* + +jobs: + find: + if: github.repository == 'nodejs/node' + runs-on: ubuntu-latest + + steps: + - name: Checkout the repo + uses: actions/checkout@v2 + + - name: Clone nodejs/TSC repository + uses: actions/checkout@v2 + with: + fetch-depth: 0 + repository: nodejs/TSC + path: .tmp + + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v2 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Find inactive TSC members + run: tools/find-inactive-tsc.mjs + + - name: Open pull request + uses: gr2m/create-or-update-pull-request-action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }} + with: + author: Node.js GitHub Bot + branch: actions/inactive-tsc + body: This PR was generated by tools/find-inactive-tsc.yml. + commit-message: "meta: move one or more TSC members to emeritus" + labels: meta + title: "meta: move one or more TSC members to emeritus" diff --git a/README.md b/README.md index 8006d57c744491..29f53e6f390f9b 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,9 @@ For information on reporting security vulnerabilities in Node.js, see For information about the governance of the Node.js project, see [GOVERNANCE.md](./GOVERNANCE.md). - + ### TSC (Technical Steering Committee) diff --git a/tools/find-inactive-tsc.mjs b/tools/find-inactive-tsc.mjs new file mode 100755 index 00000000000000..c141e9026bb306 --- /dev/null +++ b/tools/find-inactive-tsc.mjs @@ -0,0 +1,260 @@ +#!/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'; + +const SINCE = +process.argv[2] || '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]; + } + 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) { + // Skip if not a .json file, such as README.md. + if (!vote.endsWith('.json')) { + continue; + } + // Get the vote data. + const voteData = JSON.parse( + await fs.promises.readFile(path.join('.tmp', vote), 'utf8') + ); + for (const member in voteData.votes) { + 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(); + +await runGitCommand(`git checkout 'HEAD@{${SINCE}}' -- 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`, + { 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) { + console.log('\nInactive TSC members:\n'); + console.log(inactive.map((entry) => `* ${entry}`).join('\n')); + console.log('\nGenerating new README.md file...'); + const newReadmeText = await moveTscToEmeritus(inactive); + fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText); + console.log('Updated README.md generated. Please commit these changes.'); +}