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