Skip to content

Commit 878ba91

Browse files
Trotttargos
authored andcommitted
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: #40884 Reviewed-By: Matteo Collina <[email protected]> Reviewed-By: Tobias Nießen <[email protected]> Reviewed-By: Michael Dawson <[email protected]> Reviewed-By: Myles Borins <[email protected]> Reviewed-By: Colin Ihrig <[email protected]> Reviewed-By: Michaël Zasso <[email protected]> Reviewed-By: Antoine du Hamel <[email protected]> Reviewed-By: Richard Lau <[email protected]> Reviewed-By: James M Snell <[email protected]>
1 parent d97ad30 commit 878ba91

File tree

4 files changed

+313
-5
lines changed

4 files changed

+313
-5
lines changed

.github/workflows/find-inactive-collaborators.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ name: Find inactive collaborators
22

33
on:
44
schedule:
5-
# Run on the 15th day of the month at 4:05 AM UTC.
6-
- cron: '5 4 15 * *'
5+
# Run every Monday at 4:05 AM UTC.
6+
- cron: '5 4 * * 1'
77

88
workflow_dispatch:
99

1010
env:
11-
NODE_VERSION: 16.x
11+
NODE_VERSION: lts/*
1212
NUM_COMMITS: 5000
1313

1414
jobs:
+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: Find inactive TSC members
2+
3+
on:
4+
schedule:
5+
# Run every Tuesday 12:05 AM UTC.
6+
- cron: '5 0 * * 2'
7+
8+
workflow_dispatch:
9+
10+
env:
11+
NODE_VERSION: lts/*
12+
13+
jobs:
14+
find:
15+
if: github.repository == 'nodejs/node'
16+
runs-on: ubuntu-latest
17+
18+
steps:
19+
- name: Checkout the repo
20+
uses: actions/checkout@v2
21+
22+
- name: Clone nodejs/TSC repository
23+
uses: actions/checkout@v2
24+
with:
25+
fetch-depth: 0
26+
repository: nodejs/TSC
27+
path: .tmp
28+
29+
- name: Use Node.js ${{ env.NODE_VERSION }}
30+
uses: actions/setup-node@v2
31+
with:
32+
node-version: ${{ env.NODE_VERSION }}
33+
34+
- name: Find inactive TSC members
35+
run: tools/find-inactive-tsc.mjs
36+
37+
- name: Open pull request
38+
uses: gr2m/create-or-update-pull-request-action@v1
39+
env:
40+
GITHUB_TOKEN: ${{ secrets.GH_USER_TOKEN }}
41+
with:
42+
author: Node.js GitHub Bot <[email protected]>
43+
branch: actions/inactive-tsc
44+
body: This PR was generated by tools/find-inactive-tsc.yml.
45+
commit-message: "meta: move one or more TSC members to emeritus"
46+
labels: meta
47+
title: "meta: move one or more TSC members to emeritus"

README.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,9 @@ For information on reporting security vulnerabilities in Node.js, see
156156
For information about the governance of the Node.js project, see
157157
[GOVERNANCE.md](./GOVERNANCE.md).
158158

159-
<!-- node-core-utils depends on the format of the TSC list. If the
160-
format changes, those utilities need to be tested and updated. -->
159+
<!-- node-core-utils and find-inactive-tsc.mjs depend on the format of the TSC
160+
list. If the format changes, those utilities need to be tested and
161+
updated. -->
161162

162163
### TSC (Technical Steering Committee)
163164

tools/find-inactive-tsc.mjs

+260
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
#!/usr/bin/env node
2+
3+
// Identify inactive TSC members.
4+
5+
// From the TSC Charter:
6+
// A TSC member is automatically removed from the TSC if, during a 3-month
7+
// period, all of the following are true:
8+
// * They attend fewer than 25% of the regularly scheduled meetings.
9+
// * They do not participate in any TSC votes.
10+
11+
import cp from 'node:child_process';
12+
import fs from 'node:fs';
13+
import path from 'node:path';
14+
import readline from 'node:readline';
15+
16+
const SINCE = +process.argv[2] || '3 months ago';
17+
18+
async function runGitCommand(cmd, options = {}) {
19+
const childProcess = cp.spawn('/bin/sh', ['-c', cmd], {
20+
cwd: options.cwd ?? new URL('..', import.meta.url),
21+
encoding: 'utf8',
22+
stdio: ['inherit', 'pipe', 'inherit'],
23+
});
24+
const lines = readline.createInterface({
25+
input: childProcess.stdout,
26+
});
27+
const errorHandler = new Promise(
28+
(_, reject) => childProcess.on('error', reject)
29+
);
30+
let returnValue = options.mapFn ? new Set() : '';
31+
await Promise.race([errorHandler, Promise.resolve()]);
32+
// If no mapFn, return the value. If there is a mapFn, use it to make a Set to
33+
// return.
34+
for await (const line of lines) {
35+
await Promise.race([errorHandler, Promise.resolve()]);
36+
if (options.mapFn) {
37+
const val = options.mapFn(line);
38+
if (val) {
39+
returnValue.add(val);
40+
}
41+
} else {
42+
returnValue += line;
43+
}
44+
}
45+
return Promise.race([errorHandler, Promise.resolve(returnValue)]);
46+
}
47+
48+
async function getTscFromReadme() {
49+
const readmeText = readline.createInterface({
50+
input: fs.createReadStream(new URL('../README.md', import.meta.url)),
51+
crlfDelay: Infinity,
52+
});
53+
const returnedArray = [];
54+
let foundTscHeading = false;
55+
for await (const line of readmeText) {
56+
// If we've found the TSC heading already, stop processing at the next
57+
// heading.
58+
if (foundTscHeading && line.startsWith('#')) {
59+
break;
60+
}
61+
62+
const isTsc = foundTscHeading && line.length;
63+
64+
if (line === '### TSC (Technical Steering Committee)') {
65+
foundTscHeading = true;
66+
}
67+
if (line.startsWith('* ') && isTsc) {
68+
const handle = line.match(/^\* \[([^\]]+)]/)[1];
69+
returnedArray.push(handle);
70+
}
71+
}
72+
73+
if (!foundTscHeading) {
74+
throw new Error('Could not find TSC section of README');
75+
}
76+
77+
return returnedArray;
78+
}
79+
80+
async function getAttendance(tscMembers, meetings) {
81+
const attendance = {};
82+
for (const member of tscMembers) {
83+
attendance[member] = 0;
84+
}
85+
for (const meeting of meetings) {
86+
// Get the file contents.
87+
const meetingFile =
88+
await fs.promises.readFile(path.join('.tmp', meeting), 'utf8');
89+
// Extract the attendee list.
90+
const startMarker = '## Present';
91+
const start = meetingFile.indexOf(startMarker) + startMarker.length;
92+
const end = meetingFile.indexOf('## Agenda');
93+
meetingFile.substring(start, end).trim().split('\n')
94+
.map((line) => {
95+
const match = line.match(/@(\S+)/);
96+
if (match) {
97+
return match[1];
98+
}
99+
console.warn(`Attendee entry does not contain GitHub handle: ${line}`);
100+
return '';
101+
})
102+
.filter((handle) => tscMembers.includes(handle))
103+
.forEach((handle) => { attendance[handle]++; });
104+
}
105+
return attendance;
106+
}
107+
108+
async function getVotingRecords(tscMembers, votes) {
109+
const votingRecords = {};
110+
for (const member of tscMembers) {
111+
votingRecords[member] = 0;
112+
}
113+
for (const vote of votes) {
114+
// Skip if not a .json file, such as README.md.
115+
if (!vote.endsWith('.json')) {
116+
continue;
117+
}
118+
// Get the vote data.
119+
const voteData = JSON.parse(
120+
await fs.promises.readFile(path.join('.tmp', vote), 'utf8')
121+
);
122+
for (const member in voteData.votes) {
123+
votingRecords[member]++;
124+
}
125+
}
126+
return votingRecords;
127+
}
128+
129+
async function moveTscToEmeritus(peopleToMove) {
130+
const readmeText = readline.createInterface({
131+
input: fs.createReadStream(new URL('../README.md', import.meta.url)),
132+
crlfDelay: Infinity,
133+
});
134+
let fileContents = '';
135+
let inTscSection = false;
136+
let inTscEmeritusSection = false;
137+
let memberFirstLine = '';
138+
const textToMove = [];
139+
let moveToInactive = false;
140+
for await (const line of readmeText) {
141+
// If we've been processing TSC emeriti and we reach the end of
142+
// the list, print out the remaining entries to be moved because they come
143+
// alphabetically after the last item.
144+
if (inTscEmeritusSection && line === '' &&
145+
fileContents.endsWith('>\n')) {
146+
while (textToMove.length) {
147+
fileContents += textToMove.pop();
148+
}
149+
}
150+
151+
// If we've found the TSC heading already, stop processing at the
152+
// next heading.
153+
if (line.startsWith('#')) {
154+
inTscSection = false;
155+
inTscEmeritusSection = false;
156+
}
157+
158+
const isTsc = inTscSection && line.length;
159+
const isTscEmeritus = inTscEmeritusSection && line.length;
160+
161+
if (line === '### TSC (Technical Steering Committee)') {
162+
inTscSection = true;
163+
}
164+
if (line === '### TSC emeriti') {
165+
inTscEmeritusSection = true;
166+
}
167+
168+
if (isTsc) {
169+
if (line.startsWith('* ')) {
170+
memberFirstLine = line;
171+
const match = line.match(/^\* \[([^\]]+)/);
172+
if (match && peopleToMove.includes(match[1])) {
173+
moveToInactive = true;
174+
}
175+
} else if (line.startsWith(' **')) {
176+
if (moveToInactive) {
177+
textToMove.push(`${memberFirstLine}\n${line}\n`);
178+
moveToInactive = false;
179+
} else {
180+
fileContents += `${memberFirstLine}\n${line}\n`;
181+
}
182+
} else {
183+
fileContents += `${line}\n`;
184+
}
185+
}
186+
187+
if (isTscEmeritus) {
188+
if (line.startsWith('* ')) {
189+
memberFirstLine = line;
190+
} else if (line.startsWith(' **')) {
191+
const currentLine = `${memberFirstLine}\n${line}\n`;
192+
// If textToMove is empty, this still works because when undefined is
193+
// used in a comparison with <, the result is always false.
194+
while (textToMove[0] < currentLine) {
195+
fileContents += textToMove.shift();
196+
}
197+
fileContents += currentLine;
198+
} else {
199+
fileContents += `${line}\n`;
200+
}
201+
}
202+
203+
if (!isTsc && !isTscEmeritus) {
204+
fileContents += `${line}\n`;
205+
}
206+
}
207+
208+
return fileContents;
209+
}
210+
211+
// Get current TSC members, then get TSC members at start of period. Only check
212+
// TSC members who are on both lists. This way, we don't flag someone who has
213+
// only been on the TSC for a week and therefore hasn't attended any meetings.
214+
const tscMembersAtEnd = await getTscFromReadme();
215+
216+
await runGitCommand(`git checkout 'HEAD@{${SINCE}}' -- README.md`);
217+
const tscMembersAtStart = await getTscFromReadme();
218+
await runGitCommand('git reset HEAD README.md');
219+
await runGitCommand('git checkout -- README.md');
220+
221+
const tscMembers = tscMembersAtEnd.filter(
222+
(memberAtEnd) => tscMembersAtStart.includes(memberAtEnd)
223+
);
224+
225+
// Get all meetings since SINCE.
226+
// Assumes that the TSC repo is cloned in the .tmp dir.
227+
const meetings = await runGitCommand(
228+
`git whatchanged --since '${SINCE}' --name-only --pretty=format: meetings`,
229+
{ cwd: '.tmp', mapFn: (line) => line }
230+
);
231+
232+
// Get TSC meeting attendance.
233+
const attendance = await getAttendance(tscMembers, meetings);
234+
const lightAttendance = tscMembers.filter(
235+
(member) => attendance[member] < meetings.size * 0.25
236+
);
237+
238+
// Get all votes since SINCE.
239+
// Assumes that the TSC repo is cloned in the .tmp dir.
240+
const votes = await runGitCommand(
241+
`git whatchanged --since '${SINCE}' --name-only --pretty=format: votes`,
242+
{ cwd: '.tmp', mapFn: (line) => line }
243+
);
244+
245+
// Check voting record.
246+
const votingRecords = await getVotingRecords(tscMembers, votes);
247+
const noVotes = tscMembers.filter(
248+
(member) => votingRecords[member] === 0
249+
);
250+
251+
const inactive = lightAttendance.filter((member) => noVotes.includes(member));
252+
253+
if (inactive.length) {
254+
console.log('\nInactive TSC members:\n');
255+
console.log(inactive.map((entry) => `* ${entry}`).join('\n'));
256+
console.log('\nGenerating new README.md file...');
257+
const newReadmeText = await moveTscToEmeritus(inactive);
258+
fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText);
259+
console.log('Updated README.md generated. Please commit these changes.');
260+
}

0 commit comments

Comments
 (0)