From e8c55f84347aa8e7cadf9522ef9ddeea8ed62325 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Fri, 7 Aug 2026 19:36:37 +0200 Subject: [PATCH 1/2] feat: emeritus cooldown --- commands/emeritus.js | 46 ++++++++++++++++++++++++++++---- github-api.js | 63 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/commands/emeritus.js b/commands/emeritus.js index 722fb96..91540ba 100644 --- a/commands/emeritus.js +++ b/commands/emeritus.js @@ -1,3 +1,9 @@ +/** + * Keyword a member must include in their comment to confirm their account is still + * monitored and secured. Matched case-insensitively against comment bodies. + */ +const CONFIRMATION_KEYWORD = '/confirm-active' + /** * Finds inactive members in an organization for the given number of months * and opens an issue in the repository to propose moving them to the emeritus team. @@ -32,10 +38,21 @@ export default async function emeritus ({ client, logger }, { org, monthsInactiv const yearsToRead = Math.ceil(monthsInactiveThreshold / 12) const membersContributions = await client.getUsersContributions(orgData, membersList, yearsToRead) + // Members who commented a confirmation on a previous emeritus issue within the same + // inactivity window are considered "alive": they proved their account is still + // monitored and secured, so they must not be pinged again until the window elapses. + const confirmationSince = new Date() + confirmationSince.setMonth(confirmationSince.getMonth() - monthsInactiveThreshold) + const owner = orgData.name.toLowerCase() + const repo = 'org-admin' + const confirmedUsers = await client.getEmeritusConfirmations(owner, repo, confirmationSince, CONFIRMATION_KEYWORD) + logger.info('Total members that recently confirmed they are active: %s', confirmedUsers.size) + const leadTeam = orgTeams.find(team => team.slug === 'leads') const usersThatShouldBeEmeritus = membersContributions .filter(isEmeritus(monthsInactiveThreshold)) .filter(isNotLead(leadTeam)) + .filter(isNotRecentlyConfirmed(confirmedUsers, logger)) logger.info('Total emeritus members found: %s', usersThatShouldBeEmeritus.length) const emeritusTeam = orgTeams.find(team => team.slug === 'emeritus') @@ -50,8 +67,8 @@ export default async function emeritus ({ client, logger }, { org, monthsInactiv } else { if (usersToEmeritus.length > 0) { await client.createIssue( - orgData.name.toLowerCase(), - 'org-admin', + owner, + repo, 'Move to emeritus members', `The following users have been inactive for more than ${monthsInactiveThreshold} months and should be added to the emeritus team to control the access to the Fastify organization @@ -59,9 +76,11 @@ export default async function emeritus ({ client, logger }, { org, monthsInactiv ${usersToEmeritus.map(user => `- @${user.user}`).join('\n')} - \nComment here if you don't want to be moved to emeritus team and confirm that your account - is still monitored and secured.`, - ['question'] + \nIf you don't want to be moved to the emeritus team, comment here with \`${CONFIRMATION_KEYWORD}\` + to confirm that your account is still monitored and secured. This confirmation is valid for + ${monthsInactiveThreshold} months, after which you'll be asked to confirm again — you never need + to open a pull request.`, + ['question', 'emeritus-check'] ) } } @@ -77,6 +96,23 @@ function isNotLead (leadTeam) { return member => !leads.includes(member.user) } +/** + * Returns a filter function that excludes members who recently confirmed they are active + * by commenting on a previous emeritus issue, logging each one that is skipped. + * @param {Set} confirmedUsers - Set of lowercased logins that recently confirmed. + * @param {import('pino').Logger} logger - Logger used to report skipped members. + * @returns {(member: { user: string }) => boolean} Filter function. + */ +function isNotRecentlyConfirmed (confirmedUsers, logger) { + return member => { + if (confirmedUsers.has(member.user.toLowerCase())) { + logger.info('Skipping @%s: recently confirmed their account is active', member.user) + return false + } + return true + } +} + /** * Returns a filter function that checks if a member has been inactive for the specified number of months. * @param {number} monthsInactiveThreshold - The number of months of inactivity to consider a member emeritus. diff --git a/github-api.js b/github-api.js index 55c4d26..4bc7021 100644 --- a/github-api.js +++ b/github-api.js @@ -460,6 +460,69 @@ export default class AdminClient { } } + /** + * Reads back "proof of life" confirmations from previous emeritus-check issues. + * + * The emeritus check invites inactive members to comment on the tracking issue to + * confirm their account is still monitored and secured. This method scans the + * `emeritus-check` labelled issues updated since `sinceDate`, and returns the set of + * users who left a confirmation comment (one containing `keyword`) within the window, + * so they can be excluded from the next candidate list instead of being pinged again. + * + * Fails open: on any API error it logs a warning and returns an empty set, so a + * transient failure never causes an active, recently-confirmed member to be flagged + * as emeritus. + * @param {string} owner - The repository owner (org or user). + * @param {string} repo - The repository name. + * @param {Date} sinceDate - Only comments created at or after this date count. + * @param {string} keyword - Case-insensitive keyword a comment must contain to count. + * @returns {Promise>} Set of lowercased logins that recently confirmed. + */ + async getEmeritusConfirmations (owner, repo, sinceDate, keyword) { + const confirmed = new Set() + const since = sinceDate.toISOString() + const needle = keyword.toLowerCase() + + try { + const issues = await this.restClient.paginate(this.restClient.issues.listForRepo, { + owner, + repo, + labels: 'emeritus-check', + state: 'all', + since, + per_page: 100, + }) + + for (const issue of issues) { + const comments = await this.restClient.paginate(this.restClient.issues.listComments, { + owner, + repo, + issue_number: issue.number, + since, + per_page: 100, + }) + + for (const comment of comments) { + const login = comment.user?.login + if (!login) { + continue + } + if (new Date(comment.created_at) < sinceDate) { + continue + } + if ((comment.body ?? '').toLowerCase().includes(needle)) { + confirmed.add(login.toLowerCase()) + } + } + } + } catch (error) { + this.logger.warn({ owner, repo, error }, 'Failed to read emeritus confirmations; proceeding without exclusions') + return new Set() + } + + return confirmed + } + /** * Creates a new issue in a repository using the REST API. * @param {string} owner - The repository owner (org or user). From 6968ecee9af6531aed8a895ebd0a86aa206a6edb Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Fri, 7 Aug 2026 19:40:57 +0200 Subject: [PATCH 2/2] docs: fix readme --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6525a29..ca4fe8c 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ node --env-file=.env index.js offboard --org --username [--dryRun] This command checks the last contribution date of org's members. It creates an issue listing the users that have been inactive for more than a specified number of months. - ```bash node --env-file=.env index.js emeritus --org [--monthsInactiveThreshold] [--dryRun] ``` @@ -48,6 +47,19 @@ For the fastify organization, the command would look like: node --env-file=.env index.js emeritus --monthsInactiveThreshold 24 ``` +#### Proof of life (avoid being re-listed) + +A member is listed purely from their GitHub activity, so someone who is alive but simply +hasn't contributed code would otherwise be pinged every month. To avoid this, a listed +member can **comment `/confirm-active` on the emeritus issue** to confirm that their account +is still monitored and secured — no pull request required. + +On the next run the command reads back the comments of previous `emeritus-check`-labelled +issues and excludes anyone who confirmed. The confirmation is valid for `monthsInactiveThreshold` +months (the same inactivity window); after that the member is asked to confirm again. The +lookup fails open: if the comments cannot be read, the run proceeds without exclusions rather +than risk flagging an active member. + ### List sponsors This command reads the organization's sponsors from both GitHub Sponsors and