Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ node --env-file=.env index.js offboard --org <org> --username <user> [--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 <org> [--monthsInactiveThreshold] [--dryRun]
```
Expand All @@ -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
Expand Down
46 changes: 41 additions & 5 deletions commands/emeritus.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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')
Expand All @@ -50,18 +67,20 @@ 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
and secure the organization's repositories:

${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']
)
}
}
Expand All @@ -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<string>} 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.
Expand Down
63 changes: 63 additions & 0 deletions github-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>>} 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).
Expand Down