• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env node
2
3// Identify inactive TSC voting members.
4
5// From the TSC Charter:
6//   A TSC voting member is automatically converted to a TSC regular member if
7//   they do not participate in three consecutive TSC votes.
8
9import cp from 'node:child_process';
10import fs from 'node:fs';
11import path from 'node:path';
12import readline from 'node:readline';
13import { parseArgs } from 'node:util';
14
15const args = parseArgs({
16  allowPositionals: true,
17  options: { verbose: { type: 'boolean', short: 'v' } },
18});
19
20const verbose = args.values.verbose;
21
22async function runShellCommand(cmd, options = {}) {
23  const childProcess = cp.spawn('/bin/sh', ['-c', cmd], {
24    cwd: options.cwd ?? new URL('..', import.meta.url),
25    encoding: 'utf8',
26    stdio: ['inherit', 'pipe', 'inherit'],
27  });
28  const lines = readline.createInterface({
29    input: childProcess.stdout,
30  });
31  const errorHandler = new Promise(
32    (_, reject) => childProcess.on('error', reject),
33  );
34  let returnValue = options.returnAsArray ? [] : '';
35  await Promise.race([errorHandler, Promise.resolve()]);
36  // If no mapFn, return the value. If there is a mapFn, use it to make a Set to
37  // return.
38  for await (const line of lines) {
39    await Promise.race([errorHandler, Promise.resolve()]);
40    if (options.returnAsArray) {
41      returnValue.push(line);
42    } else {
43      returnValue += line;
44    }
45  }
46  return Promise.race([errorHandler, Promise.resolve(returnValue)]);
47}
48
49async function getTscFromReadme() {
50  const readmeText = readline.createInterface({
51    input: fs.createReadStream(new URL('../README.md', import.meta.url)),
52    crlfDelay: Infinity,
53  });
54  const returnedArray = [];
55  let foundTscHeading = false;
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
64    // If we've found the TSC heading already, stop processing at the next
65    // heading.
66    if (foundTscHeading && line.startsWith('#')) {
67      break;
68    }
69
70    const isTsc = foundTscHeading && line.length;
71
72    if (line === '### TSC (Technical Steering Committee)') {
73      foundTscHeading = true;
74    }
75    if (line.startsWith('* ') && isTsc) {
76      const handle = line.match(/^\* \[([^\]]+)]/)[1];
77      returnedArray.push(handle);
78    }
79  }
80
81  if (!foundTscHeading) {
82    throw new Error('Could not find TSC section of README');
83  }
84
85  return returnedArray;
86}
87
88async function getVotingRecords(tscMembers, votes) {
89  const votingRecords = {};
90  for (const member of tscMembers) {
91    votingRecords[member] = 0;
92  }
93  for (const vote of votes) {
94    // Get the vote data.
95    const voteData = JSON.parse(
96      await fs.promises.readFile(path.join('.tmp/votes', vote), 'utf8'),
97    );
98    for (const member in voteData.votes) {
99      if (tscMembers.includes(member)) {
100        votingRecords[member]++;
101      }
102    }
103  }
104  return votingRecords;
105}
106
107async function moveVotingToRegular(peopleToMove) {
108  const readmeText = readline.createInterface({
109    input: fs.createReadStream(new URL('../README.md', import.meta.url)),
110    crlfDelay: Infinity,
111  });
112  let fileContents = '';
113  let inTscVotingSection = false;
114  let inTscRegularSection = false;
115  let memberFirstLine = '';
116  const textToMove = [];
117  let moveToInactive = false;
118  for await (const line of readmeText) {
119    // If we've been processing TSC regular members and we reach the end of
120    // the list, print out the remaining entries to be moved because they come
121    // alphabetically after the last item.
122    if (inTscRegularSection && line === '' &&
123        fileContents.endsWith('>\n')) {
124      while (textToMove.length) {
125        fileContents += textToMove.pop();
126      }
127    }
128
129    // If we've found the TSC heading already, stop processing at the
130    // next heading.
131    if (line.startsWith('#')) {
132      inTscVotingSection = false;
133      inTscRegularSection = false;
134    }
135
136    const isTscVoting = inTscVotingSection && line.length;
137    const isTscRegular = inTscRegularSection && line.length;
138
139    if (line === '#### TSC voting members') {
140      inTscVotingSection = true;
141    }
142    if (line === '#### TSC regular members') {
143      inTscRegularSection = true;
144    }
145
146    if (isTscVoting) {
147      if (line.startsWith('* ')) {
148        memberFirstLine = line;
149        const match = line.match(/^\* \[([^\]]+)/);
150        if (match && peopleToMove.includes(match[1])) {
151          moveToInactive = true;
152        }
153      } else if (line.startsWith('  **')) {
154        if (moveToInactive) {
155          textToMove.push(`${memberFirstLine}\n${line}\n`);
156          moveToInactive = false;
157        } else {
158          fileContents += `${memberFirstLine}\n${line}\n`;
159        }
160      } else {
161        fileContents += `${line}\n`;
162      }
163    }
164
165    if (isTscRegular) {
166      if (line.startsWith('* ')) {
167        memberFirstLine = line;
168      } else if (line.startsWith('  **')) {
169        const currentLine = `${memberFirstLine}\n${line}\n`;
170        // If textToMove is empty, this still works because when undefined is
171        // used in a comparison with <, the result is always false.
172        while (textToMove[0]?.toLowerCase() < currentLine.toLowerCase()) {
173          fileContents += textToMove.shift();
174        }
175        fileContents += currentLine;
176      } else {
177        fileContents += `${line}\n`;
178      }
179    }
180
181    if (!isTscVoting && !isTscRegular) {
182      fileContents += `${line}\n`;
183    }
184  }
185
186  return fileContents;
187}
188
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.
193const tscMembersAtEnd = await getTscFromReadme();
194
195// Get the last three votes.
196// Assumes that the TSC repo is cloned in the .tmp dir.
197const votes = await runShellCommand(
198  'ls *.json | sort -rn | head -3',
199  { cwd: '.tmp/votes', returnAsArray: true },
200);
201
202// Reverse the votes list so the oldest of the three votes is first.
203votes.reverse();
204
205const startCommit = await runShellCommand(`git rev-list -1 --before '${votes[0]}' HEAD`);
206await runShellCommand(`git checkout ${startCommit} -- README.md`);
207const tscMembersAtStart = await getTscFromReadme();
208await runShellCommand('git reset HEAD README.md');
209await runShellCommand('git checkout -- README.md');
210
211const tscMembers = tscMembersAtEnd.filter(
212  (memberAtEnd) => tscMembersAtStart.includes(memberAtEnd),
213);
214
215// Check voting record.
216const votingRecords = await getVotingRecords(tscMembers, votes);
217const inactive = tscMembers.filter(
218  (member) => votingRecords[member] === 0,
219);
220
221if (inactive.length) {
222  // The stdout output is consumed in find-inactive-tsc.yml. If format of output
223  // changes, find-inactive-tsc.yml may need to be updated.
224  console.log(`INACTIVE_TSC_HANDLES=${inactive.map((entry) => '@' + entry).join(' ')}`);
225  const commitDetails = `${inactive.join(' ')} did not participate in three consecutive TSC votes: ${votes.join(' ')}`;
226  console.log(`DETAILS_FOR_COMMIT_BODY=${commitDetails}`);
227
228  if (process.env.GITHUB_ACTIONS) {
229    // Using console.warn() to avoid messing with find-inactive-tsc which
230    // consumes stdout.
231    console.warn('Generating new README.md file...');
232    const newReadmeText = await moveVotingToRegular(inactive);
233    fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText);
234  }
235}
236
237if (verbose) {
238  console.log(votingRecords);
239}
240