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 <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>
1 parent d97ad30 commit 878ba91

4 files changed

Lines changed: 313 additions & 5 deletions

File tree

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

Lines changed: 3 additions & 3 deletions
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:
Lines changed: 47 additions & 0 deletions
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 <github-bot@iojs.org>
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‎

Lines changed: 3 additions & 2 deletions
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‎

Lines changed: 260 additions & 0 deletions
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+
importcpfrom'node:child_process';
12+
importfsfrom'node:fs';
13+
importpathfrom'node:path';
14+
importreadlinefrom'node:readline';
15+
16+
constSINCE=+process.argv[2]||'3 months ago';
17+
18+
asyncfunctionrunGitCommand(cmd,options={}){
19+
constchildProcess=cp.spawn('/bin/sh',['-c',cmd],{
20+
cwd: options.cwd??newURL('..',import.meta.url),
21+
encoding: 'utf8',
22+
stdio: ['inherit','pipe','inherit'],
23+
});
24+
constlines=readline.createInterface({
25+
input: childProcess.stdout,
26+
});
27+
consterrorHandler=newPromise(
28+
(_,reject)=>childProcess.on('error',reject)
29+
);
30+
letreturnValue=options.mapFn ? newSet() : '';
31+
awaitPromise.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+
forawait(constlineoflines){
35+
awaitPromise.race([errorHandler,Promise.resolve()]);
36+
if(options.mapFn){
37+
constval=options.mapFn(line);
38+
if(val){
39+
returnValue.add(val);
40+
}
41+
}else{
42+
returnValue+=line;
43+
}
44+
}
45+
returnPromise.race([errorHandler,Promise.resolve(returnValue)]);
46+
}
47+
48+
asyncfunctiongetTscFromReadme(){
49+
constreadmeText=readline.createInterface({
50+
input: fs.createReadStream(newURL('../README.md',import.meta.url)),
51+
crlfDelay: Infinity,
52+
});
53+
constreturnedArray=[];
54+
letfoundTscHeading=false;
55+
forawait(constlineofreadmeText){
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+
constisTsc=foundTscHeading&&line.length;
63+
64+
if(line==='### TSC (Technical Steering Committee)'){
65+
foundTscHeading=true;
66+
}
67+
if(line.startsWith('* ')&&isTsc){
68+
consthandle=line.match(/^\*\[([^\]]+)]/)[1];
69+
returnedArray.push(handle);
70+
}
71+
}
72+
73+
if(!foundTscHeading){
74+
thrownewError('Could not find TSC section of README');
75+
}
76+
77+
returnreturnedArray;
78+
}
79+
80+
asyncfunctiongetAttendance(tscMembers,meetings){
81+
constattendance={};
82+
for(constmemberoftscMembers){
83+
attendance[member]=0;
84+
}
85+
for(constmeetingofmeetings){
86+
// Get the file contents.
87+
constmeetingFile=
88+
awaitfs.promises.readFile(path.join('.tmp',meeting),'utf8');
89+
// Extract the attendee list.
90+
conststartMarker='## Present';
91+
conststart=meetingFile.indexOf(startMarker)+startMarker.length;
92+
constend=meetingFile.indexOf('## Agenda');
93+
meetingFile.substring(start,end).trim().split('\n')
94+
.map((line)=>{
95+
constmatch=line.match(/@(\S+)/);
96+
if(match){
97+
returnmatch[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+
returnattendance;
106+
}
107+
108+
asyncfunctiongetVotingRecords(tscMembers,votes){
109+
constvotingRecords={};
110+
for(constmemberoftscMembers){
111+
votingRecords[member]=0;
112+
}
113+
for(constvoteofvotes){
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+
constvoteData=JSON.parse(
120+
awaitfs.promises.readFile(path.join('.tmp',vote),'utf8')
121+
);
122+
for(constmemberinvoteData.votes){
123+
votingRecords[member]++;
124+
}
125+
}
126+
returnvotingRecords;
127+
}
128+
129+
asyncfunctionmoveTscToEmeritus(peopleToMove){
130+
constreadmeText=readline.createInterface({
131+
input: fs.createReadStream(newURL('../README.md',import.meta.url)),
132+
crlfDelay: Infinity,
133+
});
134+
letfileContents='';
135+
letinTscSection=false;
136+
letinTscEmeritusSection=false;
137+
letmemberFirstLine='';
138+
consttextToMove=[];
139+
letmoveToInactive=false;
140+
forawait(constlineofreadmeText){
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+
constisTsc=inTscSection&&line.length;
159+
constisTscEmeritus=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+
constmatch=line.match(/^\*\[([^\]]+)/);
172+
if(match&&peopleToMove.includes(match[1])){
173+
moveToInactive=true;
174+
}
175+
}elseif(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+
}elseif(line.startsWith(' **')){
191+
constcurrentLine=`${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+
returnfileContents;
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+
consttscMembersAtEnd=awaitgetTscFromReadme();
215+
216+
awaitrunGitCommand(`git checkout 'HEAD@{${SINCE}}' -- README.md`);
217+
consttscMembersAtStart=awaitgetTscFromReadme();
218+
awaitrunGitCommand('git reset HEAD README.md');
219+
awaitrunGitCommand('git checkout -- README.md');
220+
221+
consttscMembers=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+
constmeetings=awaitrunGitCommand(
228+
`git whatchanged --since '${SINCE}' --name-only --pretty=format: meetings`,
229+
{cwd: '.tmp',mapFn: (line)=>line}
230+
);
231+
232+
// Get TSC meeting attendance.
233+
constattendance=awaitgetAttendance(tscMembers,meetings);
234+
constlightAttendance=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+
constvotes=awaitrunGitCommand(
241+
`git whatchanged --since '${SINCE}' --name-only --pretty=format: votes`,
242+
{cwd: '.tmp',mapFn: (line)=>line}
243+
);
244+
245+
// Check voting record.
246+
constvotingRecords=awaitgetVotingRecords(tscMembers,votes);
247+
constnoVotes=tscMembers.filter(
248+
(member)=>votingRecords[member]===0
249+
);
250+
251+
constinactive=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+
constnewReadmeText=awaitmoveTscToEmeritus(inactive);
258+
fs.writeFileSync(newURL('../README.md',import.meta.url),newReadmeText);
259+
console.log('Updated README.md generated. Please commit these changes.');
260+
}

0 commit comments

Comments
 (0)