|
1 | 1 | #!/usr/bin/env node |
2 | 2 |
|
3 | | -// Identify inactive TSC members. |
| 3 | +// Identify inactive TSC voting members. |
4 | 4 |
|
5 | 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. |
| 6 | +// A TSC voting member is automatically converted to a TSC regular member if |
| 7 | +// they do not participate in three consecutive TSC votes. |
10 | 8 |
|
11 | 9 | import cp from 'node:child_process'; |
12 | 10 | import fs from 'node:fs'; |
@@ -20,9 +18,8 @@ const args = parseArgs({ |
20 | 18 | }); |
21 | 19 |
|
22 | 20 | const verbose = args.values.verbose; |
23 | | -const SINCE = args.positionals[0] || '3 months ago'; |
24 | 21 |
|
25 | | -async function runGitCommand(cmd, options = {}) { |
| 22 | +async function runShellCommand(cmd, options = {}) { |
26 | 23 | const childProcess = cp.spawn('/bin/sh', ['-c', cmd], { |
27 | 24 | cwd: options.cwd ?? new URL('..', import.meta.url), |
28 | 25 | encoding: 'utf8', |
@@ -34,17 +31,14 @@ async function runGitCommand(cmd, options = {}) { |
34 | 31 | const errorHandler = new Promise( |
35 | 32 | (_, reject) => childProcess.on('error', reject), |
36 | 33 | ); |
37 | | - let returnValue = options.mapFn ? new Set() : ''; |
| 34 | + let returnValue = options.returnAsArray ? [] : ''; |
38 | 35 | await Promise.race([errorHandler, Promise.resolve()]); |
39 | 36 | // If no mapFn, return the value. If there is a mapFn, use it to make a Set to |
40 | 37 | // return. |
41 | 38 | for await (const line of lines) { |
42 | 39 | await Promise.race([errorHandler, Promise.resolve()]); |
43 | | - if (options.mapFn) { |
44 | | - const val = options.mapFn(line); |
45 | | - if (val) { |
46 | | - returnValue.add(val); |
47 | | - } |
| 40 | + if (options.returnAsArray) { |
| 41 | + returnValue.push(line); |
48 | 42 | } else { |
49 | 43 | returnValue += line; |
50 | 44 | } |
@@ -60,6 +54,13 @@ async function getTscFromReadme() { |
60 | 54 | const returnedArray = []; |
61 | 55 | let foundTscHeading = false; |
62 | 56 | for await (const line of readmeText) { |
| 57 | + // Until three votes have passed from March 16, 2023, we will need this. |
| 58 | + // After that point, we can use this for setting `foundTscHeading` below |
| 59 | + // and remove this. |
| 60 | + if (line === '#### TSC voting members') { |
| 61 | + continue; |
| 62 | + } |
| 63 | + |
63 | 64 | // If we've found the TSC heading already, stop processing at the next |
64 | 65 | // heading. |
65 | 66 | if (foundTscHeading && line.startsWith('#')) { |
@@ -84,36 +85,6 @@ async function getTscFromReadme() { |
84 | 85 | return returnedArray; |
85 | 86 | } |
86 | 87 |
|
87 | | -async function getAttendance(tscMembers, meetings) { |
88 | | - const attendance = {}; |
89 | | - for (const member of tscMembers) { |
90 | | - attendance[member] = 0; |
91 | | - } |
92 | | - for (const meeting of meetings) { |
93 | | - // Get the file contents. |
94 | | - const meetingFile = |
95 | | - await fs.promises.readFile(path.join('.tmp', meeting), 'utf8'); |
96 | | - // Extract the attendee list. |
97 | | - const startMarker = '## Present'; |
98 | | - const start = meetingFile.indexOf(startMarker) + startMarker.length; |
99 | | - const end = meetingFile.indexOf('## Agenda'); |
100 | | - meetingFile.substring(start, end).trim().split('\n') |
101 | | - .map((line) => { |
102 | | - const match = line.match(/@(\S+)/); |
103 | | - if (match) { |
104 | | - return match[1]; |
105 | | - } |
106 | | - // Using `console.warn` so that stdout output is not generated. |
107 | | - // The stdout output is consumed in find-inactive-tsc.yml. |
108 | | - console.warn(`Attendee entry does not contain GitHub handle: ${line}`); |
109 | | - return ''; |
110 | | - }) |
111 | | - .filter((handle) => tscMembers.includes(handle)) |
112 | | - .forEach((handle) => { attendance[handle]++; }); |
113 | | - } |
114 | | - return attendance; |
115 | | -} |
116 | | - |
117 | 88 | async function getVotingRecords(tscMembers, votes) { |
118 | 89 | const votingRecords = {}; |
119 | 90 | for (const member of tscMembers) { |
@@ -122,7 +93,7 @@ async function getVotingRecords(tscMembers, votes) { |
122 | 93 | for (const vote of votes) { |
123 | 94 | // Get the vote data. |
124 | 95 | const voteData = JSON.parse( |
125 | | - await fs.promises.readFile(path.join('.tmp', vote), 'utf8'), |
| 96 | + await fs.promises.readFile(path.join('.tmp/votes', vote), 'utf8'), |
126 | 97 | ); |
127 | 98 | for (const member in voteData.votes) { |
128 | 99 | if (tscMembers.includes(member)) { |
@@ -133,22 +104,22 @@ async function getVotingRecords(tscMembers, votes) { |
133 | 104 | return votingRecords; |
134 | 105 | } |
135 | 106 |
|
136 | | -async function moveTscToEmeritus(peopleToMove) { |
| 107 | +async function moveVotingToRegular(peopleToMove) { |
137 | 108 | const readmeText = readline.createInterface({ |
138 | 109 | input: fs.createReadStream(new URL('../README.md', import.meta.url)), |
139 | 110 | crlfDelay: Infinity, |
140 | 111 | }); |
141 | 112 | let fileContents = ''; |
142 | | - let inTscSection = false; |
143 | | - let inTscEmeritusSection = false; |
| 113 | + let inTscVotingSection = false; |
| 114 | + let inTscRegularSection = false; |
144 | 115 | let memberFirstLine = ''; |
145 | 116 | const textToMove = []; |
146 | 117 | let moveToInactive = false; |
147 | 118 | for await (const line of readmeText) { |
148 | | - // If we've been processing TSC emeriti and we reach the end of |
| 119 | + // If we've been processing TSC regular members and we reach the end of |
149 | 120 | // the list, print out the remaining entries to be moved because they come |
150 | 121 | // alphabetically after the last item. |
151 | | - if (inTscEmeritusSection && line === '' && |
| 122 | + if (inTscRegularSection && line === '' && |
152 | 123 | fileContents.endsWith('>\n')) { |
153 | 124 | while (textToMove.length) { |
154 | 125 | fileContents += textToMove.pop(); |
@@ -158,21 +129,21 @@ async function moveTscToEmeritus(peopleToMove) { |
158 | 129 | // If we've found the TSC heading already, stop processing at the |
159 | 130 | // next heading. |
160 | 131 | if (line.startsWith('#')) { |
161 | | - inTscSection = false; |
162 | | - inTscEmeritusSection = false; |
| 132 | + inTscVotingSection = false; |
| 133 | + inTscRegularSection = false; |
163 | 134 | } |
164 | 135 |
|
165 | | - const isTsc = inTscSection && line.length; |
166 | | - const isTscEmeritus = inTscEmeritusSection && line.length; |
| 136 | + const isTscVoting = inTscVotingSection && line.length; |
| 137 | + const isTscRegular = inTscRegularSection && line.length; |
167 | 138 |
|
168 | | - if (line === '### TSC (Technical Steering Committee)') { |
169 | | - inTscSection = true; |
| 139 | + if (line === '#### TSC voting members') { |
| 140 | + inTscVotingSection = true; |
170 | 141 | } |
171 | | - if (line === '### TSC emeriti') { |
172 | | - inTscEmeritusSection = true; |
| 142 | + if (line === '#### TSC regular members') { |
| 143 | + inTscRegularSection = true; |
173 | 144 | } |
174 | 145 |
|
175 | | - if (isTsc) { |
| 146 | + if (isTscVoting) { |
176 | 147 | if (line.startsWith('* ')) { |
177 | 148 | memberFirstLine = line; |
178 | 149 | const match = line.match(/^\* \[([^\]]+)/); |
@@ -191,7 +162,7 @@ async function moveTscToEmeritus(peopleToMove) { |
191 | 162 | } |
192 | 163 | } |
193 | 164 |
|
194 | | - if (isTscEmeritus) { |
| 165 | + if (isTscRegular) { |
195 | 166 | if (line.startsWith('* ')) { |
196 | 167 | memberFirstLine = line; |
197 | 168 | } else if (line.startsWith(' **')) { |
@@ -207,79 +178,62 @@ async function moveTscToEmeritus(peopleToMove) { |
207 | 178 | } |
208 | 179 | } |
209 | 180 |
|
210 | | - if (!isTsc && !isTscEmeritus) { |
| 181 | + if (!isTscVoting && !isTscRegular) { |
211 | 182 | fileContents += `${line}\n`; |
212 | 183 | } |
213 | 184 | } |
214 | 185 |
|
215 | 186 | return fileContents; |
216 | 187 | } |
217 | 188 |
|
218 | | -// Get current TSC members, then get TSC members at start of period. Only check |
219 | | -// TSC members who are on both lists. This way, we don't flag someone who has |
220 | | -// only been on the TSC for a week and therefore hasn't attended any meetings. |
| 189 | +// Get current TSC voting members, then get TSC voting members at start of |
| 190 | +// period. Only check TSC voting members who are on both lists. This way, we |
| 191 | +// don't flag someone who hasn't been on the TSC long enough to have missed 3 |
| 192 | +// consecutive votes. |
221 | 193 | const tscMembersAtEnd = await getTscFromReadme(); |
222 | 194 |
|
223 | | -const startCommit = await runGitCommand(`git rev-list -1 --before '${SINCE}' HEAD`); |
224 | | -await runGitCommand(`git checkout ${startCommit} -- README.md`); |
225 | | -const tscMembersAtStart = await getTscFromReadme(); |
226 | | -await runGitCommand('git reset HEAD README.md'); |
227 | | -await runGitCommand('git checkout -- README.md'); |
228 | | - |
229 | | -const tscMembers = tscMembersAtEnd.filter( |
230 | | - (memberAtEnd) => tscMembersAtStart.includes(memberAtEnd), |
231 | | -); |
232 | | - |
233 | | -// Get all meetings since SINCE. |
| 195 | +// Get the last three votes. |
234 | 196 | // Assumes that the TSC repo is cloned in the .tmp dir. |
235 | | -const meetings = await runGitCommand( |
236 | | - `git whatchanged --since '${SINCE}' --name-only --pretty=format: meetings`, |
237 | | - { cwd: '.tmp', mapFn: (line) => line }, |
| 197 | +const votes = await runShellCommand( |
| 198 | + 'ls *.json | sort -rn | head -3', |
| 199 | + { cwd: '.tmp/votes', returnAsArray: true }, |
238 | 200 | ); |
239 | 201 |
|
240 | | -// Get TSC meeting attendance. |
241 | | -const attendance = await getAttendance(tscMembers, meetings); |
242 | | -const lightAttendance = tscMembers.filter( |
243 | | - (member) => attendance[member] < meetings.size * 0.25, |
244 | | -); |
| 202 | +// Reverse the votes list so the oldest of the three votes is first. |
| 203 | +votes.reverse(); |
245 | 204 |
|
246 | | -// Get all votes since SINCE. |
247 | | -// Assumes that the TSC repo is cloned in the .tmp dir. |
248 | | -const votes = await runGitCommand( |
249 | | - `git whatchanged --since '${SINCE}' --name-only --pretty=format: votes/*.json`, |
250 | | - { cwd: '.tmp', mapFn: (line) => line }, |
| 205 | +const startCommit = await runShellCommand(`git rev-list -1 --before '${votes[0]}' HEAD`); |
| 206 | +await runShellCommand(`git checkout ${startCommit} -- README.md`); |
| 207 | +const tscMembersAtStart = await getTscFromReadme(); |
| 208 | +await runShellCommand('git reset HEAD README.md'); |
| 209 | +await runShellCommand('git checkout -- README.md'); |
| 210 | + |
| 211 | +const tscMembers = tscMembersAtEnd.filter( |
| 212 | + (memberAtEnd) => tscMembersAtStart.includes(memberAtEnd), |
251 | 213 | ); |
252 | 214 |
|
253 | 215 | // Check voting record. |
254 | 216 | const votingRecords = await getVotingRecords(tscMembers, votes); |
255 | | -const noVotes = tscMembers.filter( |
| 217 | +const inactive = tscMembers.filter( |
256 | 218 | (member) => votingRecords[member] === 0, |
257 | 219 | ); |
258 | 220 |
|
259 | | -const inactive = lightAttendance.filter((member) => noVotes.includes(member)); |
260 | | - |
261 | 221 | if (inactive.length) { |
262 | 222 | // The stdout output is consumed in find-inactive-tsc.yml. If format of output |
263 | 223 | // changes, find-inactive-tsc.yml may need to be updated. |
264 | 224 | console.log(`INACTIVE_TSC_HANDLES=${inactive.map((entry) => '@' + entry).join(' ')}`); |
265 | | - const commitDetails = inactive.map((entry) => { |
266 | | - let details = `Since ${SINCE}, `; |
267 | | - details += `${entry} attended ${attendance[entry]} out of ${meetings.size} meetings`; |
268 | | - details += ` and voted in ${votingRecords[entry]} of ${votes.size} votes.`; |
269 | | - return details; |
270 | | - }); |
271 | | - console.log(`DETAILS_FOR_COMMIT_BODY=${commitDetails.join(' ')}`); |
| 225 | + const commitDetails = `${inactive.join(' ')} did not participate in three consecutive TSC votes: ${votes.join(' ')}`; |
| 226 | + console.log(`DETAILS_FOR_COMMIT_BODY=${commitDetails}`); |
272 | 227 |
|
273 | 228 | if (process.env.GITHUB_ACTIONS) { |
274 | 229 | // Using console.warn() to avoid messing with find-inactive-tsc which |
275 | 230 | // consumes stdout. |
276 | 231 | console.warn('Generating new README.md file...'); |
277 | | - const newReadmeText = await moveTscToEmeritus(inactive); |
| 232 | + const newReadmeText = await moveVotingToRegular(inactive); |
278 | 233 | fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText); |
279 | 234 | } |
280 | 235 | } |
281 | 236 |
|
282 | 237 | if (verbose) { |
283 | | - console.log(attendance); |
284 | 238 | console.log(votingRecords); |
285 | 239 | } |