mirror of
https://github.com/zebrajr/node.git
synced 2026-01-15 12:15:26 +00:00
tools: add find-inactive-tsc
Automate the implementation of rules in the TSC Charter around automatic removal of members who do not participate in TSC votes and attend fewer than 25% of the meetings in a 3-month period. PR-URL: https://github.com/nodejs/node/pull/40884 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Tobias Nießen <tniessen@tnie.de> Reviewed-By: Michael Dawson <midawson@redhat.com> Reviewed-By: Myles Borins <myles.borins@gmail.com> Reviewed-By: Colin Ihrig <cjihrig@gmail.com> Reviewed-By: Michaël Zasso <targos@protonmail.com> Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> Reviewed-By: Richard Lau <rlau@redhat.com> Reviewed-By: James M Snell <jasnell@gmail.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
47
.github/workflows/find-inactive-tsc.yml
vendored
Normal file
47
.github/workflows/find-inactive-tsc.yml
vendored
Normal file
@@ -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 <github-bot@iojs.org>
|
||||
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"
|
||||
@@ -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).
|
||||
|
||||
<!-- node-core-utils depends on the format of the TSC list. If the
|
||||
format changes, those utilities need to be tested and updated. -->
|
||||
<!-- node-core-utils and find-inactive-tsc.mjs depend on the format of the TSC
|
||||
list. If the format changes, those utilities need to be tested and
|
||||
updated. -->
|
||||
|
||||
### TSC (Technical Steering Committee)
|
||||
|
||||
|
||||
260
tools/find-inactive-tsc.mjs
Executable file
260
tools/find-inactive-tsc.mjs
Executable file
@@ -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.');
|
||||
}
|
||||
Reference in New Issue
Block a user