• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env node
2
3// Identify inactive collaborators. "Inactive" is not quite right, as the things
4// this checks for are not the entirety of collaborator activities. Still, it is
5// a pretty good proxy. Feel free to suggest or implement further metrics.
6
7import cp from 'node:child_process';
8import fs from 'node:fs';
9import readline from 'node:readline';
10import { parseArgs } from 'node:util';
11
12const args = parseArgs({
13  allowPositionals: true,
14  options: { verbose: { type: 'boolean', short: 'v' } },
15});
16
17const verbose = args.values.verbose;
18const SINCE = args.positionals[0] || '18 months ago';
19
20async function runGitCommand(cmd, mapFn) {
21  const childProcess = cp.spawn('/bin/sh', ['-c', cmd], {
22    cwd: new URL('..', import.meta.url),
23    encoding: 'utf8',
24    stdio: ['inherit', 'pipe', 'inherit'],
25  });
26  const lines = readline.createInterface({
27    input: childProcess.stdout,
28  });
29  const errorHandler = new Promise(
30    (_, reject) => childProcess.on('error', reject),
31  );
32  let returnValue = mapFn ? new Set() : '';
33  await Promise.race([errorHandler, Promise.resolve()]);
34  // If no mapFn, return the value. If there is a mapFn, use it to make a Set to
35  // return.
36  for await (const line of lines) {
37    await Promise.race([errorHandler, Promise.resolve()]);
38    if (mapFn) {
39      const val = mapFn(line);
40      if (val) {
41        returnValue.add(val);
42      }
43    } else {
44      returnValue += line;
45    }
46  }
47  return Promise.race([errorHandler, Promise.resolve(returnValue)]);
48}
49
50// Get all commit authors during the time period.
51const authors = await runGitCommand(
52  `git shortlog -n -s --email --since="${SINCE}" HEAD`,
53  (line) => line.trim().split('\t', 2)[1],
54);
55
56// Get all approving reviewers of landed commits during the time period.
57const approvingReviewers = await runGitCommand(
58  `git log --since="${SINCE}" | egrep "^    Reviewed-By: "`,
59  (line) => /^ {4}Reviewed-By: ([^<]+)/.exec(line)[1].trim(),
60);
61
62async function getCollaboratorsFromReadme() {
63  const readmeText = readline.createInterface({
64    input: fs.createReadStream(new URL('../README.md', import.meta.url)),
65    crlfDelay: Infinity,
66  });
67  const returnedArray = [];
68  let foundCollaboratorHeading = false;
69  for await (const line of readmeText) {
70    // If we've found the collaborator heading already, stop processing at the
71    // next heading.
72    if (foundCollaboratorHeading && line.startsWith('#')) {
73      break;
74    }
75
76    const isCollaborator = foundCollaboratorHeading && line.length;
77
78    if (line === '### Collaborators') {
79      foundCollaboratorHeading = true;
80    }
81    if (line.startsWith('  **') && isCollaborator) {
82      const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line);
83      const mailmap = await runGitCommand(
84        `git check-mailmap '${name} <${email}>'`,
85      );
86      if (mailmap !== `${name} <${email}>`) {
87        console.log(`README entry for Collaborator does not match mailmap:\n  ${name} <${email}> => ${mailmap}`);
88      }
89      returnedArray.push({
90        name,
91        email,
92        mailmap,
93      });
94    }
95  }
96
97  if (!foundCollaboratorHeading) {
98    throw new Error('Could not find Collaborator section of README');
99  }
100
101  return returnedArray;
102}
103
104async function moveCollaboratorToEmeritus(peopleToMove) {
105  const readmeText = readline.createInterface({
106    input: fs.createReadStream(new URL('../README.md', import.meta.url)),
107    crlfDelay: Infinity,
108  });
109  let fileContents = '';
110  let inCollaboratorsSection = false;
111  let inCollaboratorEmeritusSection = false;
112  let collaboratorFirstLine = '';
113  const textToMove = [];
114  for await (const line of readmeText) {
115    // If we've been processing collaborator emeriti and we reach the end of
116    // the list, print out the remaining entries to be moved because they come
117    // alphabetically after the last item.
118    if (inCollaboratorEmeritusSection && line === '' &&
119        fileContents.endsWith('>\n')) {
120      while (textToMove.length) {
121        fileContents += textToMove.pop();
122      }
123    }
124
125    // If we've found the collaborator heading already, stop processing at the
126    // next heading.
127    if (line.startsWith('#')) {
128      inCollaboratorsSection = false;
129      inCollaboratorEmeritusSection = false;
130    }
131
132    const isCollaborator = inCollaboratorsSection && line.length;
133    const isCollaboratorEmeritus = inCollaboratorEmeritusSection && line.length;
134
135    if (line === '### Collaborators') {
136      inCollaboratorsSection = true;
137    }
138    if (line === '### Collaborator emeriti') {
139      inCollaboratorEmeritusSection = true;
140    }
141
142    if (isCollaborator) {
143      if (line.startsWith('* ')) {
144        collaboratorFirstLine = line;
145      } else if (line.startsWith('  **')) {
146        const [, name, email] = /^ {2}\*\*([^*]+)\*\* <<(.+)>>/.exec(line);
147        if (peopleToMove.some((entry) => {
148          return entry.name === name && entry.email === email;
149        })) {
150          textToMove.push(`${collaboratorFirstLine}\n${line}\n`);
151        } else {
152          fileContents += `${collaboratorFirstLine}\n${line}\n`;
153        }
154      } else {
155        fileContents += `${line}\n`;
156      }
157    }
158
159    if (isCollaboratorEmeritus) {
160      if (line.startsWith('* ')) {
161        collaboratorFirstLine = line;
162      } else if (line.startsWith('  **')) {
163        const currentLine = `${collaboratorFirstLine}\n${line}\n`;
164        // If textToMove is empty, this still works because when undefined is
165        // used in a comparison with <, the result is always false.
166        while (textToMove[0]?.toLowerCase() < currentLine.toLowerCase()) {
167          fileContents += textToMove.shift();
168        }
169        fileContents += currentLine;
170      } else {
171        fileContents += `${line}\n`;
172      }
173    }
174
175    if (!isCollaborator && !isCollaboratorEmeritus) {
176      fileContents += `${line}\n`;
177    }
178  }
179
180  return fileContents;
181}
182
183// Get list of current collaborators from README.md.
184const collaborators = await getCollaboratorsFromReadme();
185
186if (verbose) {
187  console.log(`Since ${SINCE}:\n`);
188  console.log(`* ${authors.size.toLocaleString()} authors have made commits.`);
189  console.log(`* ${approvingReviewers.size.toLocaleString()} reviewers have approved landed commits.`);
190  console.log(`* ${collaborators.length.toLocaleString()} collaborators currently in the project.`);
191}
192const inactive = collaborators.filter((collaborator) =>
193  !authors.has(collaborator.mailmap) &&
194  !approvingReviewers.has(collaborator.name),
195);
196
197if (inactive.length) {
198  console.log('\nInactive collaborators:\n');
199  console.log(inactive.map((entry) => `* ${entry.name}`).join('\n'));
200  if (process.env.GITHUB_ACTIONS) {
201    console.log('\nGenerating new README.md file...');
202    const newReadmeText = await moveCollaboratorToEmeritus(inactive);
203    fs.writeFileSync(new URL('../README.md', import.meta.url), newReadmeText);
204  }
205}
206