diff --git a/.github/workflows/get-inactive-members.yml b/.github/workflows/get-inactive-members.yml new file mode 100644 index 0000000..ca764d3 --- /dev/null +++ b/.github/workflows/get-inactive-members.yml @@ -0,0 +1,24 @@ +name: Linters + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * *' + +permissions: + contents: read + +jobs: + cull-inactive-members: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + persist-credentials: false + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + # This token needs read:org and public_repo scopes to check team membership and repo activity. + github-token: ${{ secrets.GH_USER_TOKEN }} + script: | + const { default: run } = await import('../tools/get-inactive-members.mjs'); + await run({ github, context, core }); diff --git a/request-an-access-token.md b/request-an-access-token.md index 25f40ae..d3d2305 100644 --- a/request-an-access-token.md +++ b/request-an-access-token.md @@ -52,6 +52,7 @@ Repo | Secret name | Expirati [`nodejs-private/security-release`][] | `SECURITY_WG_GITHUB_TOKEN` | 2027-01-30 | | [`nodejs/require-in-the-middle`][] | `RELEASE_PLEASE_GITHUB_TOKEN` | 2027-01-30 | | [`nodejs/orchestrion-js`][] | `RELEASE_PLEASE_GITHUB_TOKEN` | 2026-11-08 | | +[`nodejs/admin`][] | `GH_USER_TOKEN` | | | [`@nodejs-github-bot`]: https://github.com/nodejs-github-bot [`nodejs-private/security-release`]: https://github.com/nodejs-private/security-release @@ -63,3 +64,4 @@ Repo | Secret name | Expirati [`nodejs/require-in-the-middle`]: https://github.com/nodejs/require-in-the-middle [`nodejs/wasm-builder`]: https://github.com/nodejs/wasm-builder [`nodejs/doc-kit`]: https://github.com/nodejs/doc-kit +[`nodejs/admin`][]: https://github.com/nodejs/admin \ No newline at end of file diff --git a/tools/get-inactive-members.mjs b/tools/get-inactive-members.mjs new file mode 100644 index 0000000..79f7a77 --- /dev/null +++ b/tools/get-inactive-members.mjs @@ -0,0 +1,498 @@ +// Months of inactivity to flag members for. +const MONTHS = 12; +const MONTHS_MS = MONTHS * 30 * 24 * 60 * 60 * 1000; + +// Issue title to use for the report; also serves as the search query when looking +const ISSUE_TITLE = "Inactive Members Report"; + +// Throttling: 30 requests per minute = 1 request every 2000ms. +const RATE_LIMIT_PER_MINUTE = 30; +const MIN_REQUEST_INTERVAL_MS = Math.ceil(60_000 / RATE_LIMIT_PER_MINUTE); + +// How many members to batch into a single aliased GraphQL activity query. +// GitHub's GraphQL node limit is 500k and aliased search queries are cheap, +// but we keep this modest to stay well under per-call complexity limits. +const ACTIVITY_BATCH_SIZE = 20; + +/** + * A simple serialized rate limiter: ensures at most one request starts every + * MIN_REQUEST_INTERVAL_MS across all callers. Every outbound GitHub call goes + * through `throttle.run(fn)`. + */ +function createThrottle(intervalMs) { + let nextAvailable = 0; + let chain = Promise.resolve(); + + return { + run(fn) { + const task = chain.then(async () => { + const now = Date.now(); + const wait = Math.max(0, nextAvailable - now); + + if (wait > 0) { + await new Promise((resolve) => setTimeout(resolve, wait)); + } + + nextAvailable = Date.now() + intervalMs; + return fn(); + }); + + // Keep the chain alive even if a task rejects. + chain = task.catch(() => {}); + return task; + }, + }; +} + +/** + * Fetches all org members and their team memberships via GraphQL. + * + * @param {object} github - Authenticated Octokit client (exposes .graphql). + * @param {string} org - Organization login. + * @param {object} throttle - Rate limiter. + * @param {object} core - Logger. + * @returns {Promise<{members: Array<{login: string}>, memberTeams: Map}>} + */ +async function fetchMembersAndTeams(github, org, throttle, core) { + core.info(`Fetching members + teams for org "${org}" via GraphQL...`); + + const query = ` + query ($org: String!, $membersCursor: String, $teamsCursor: String, $fetchMembers: Boolean!, $fetchTeams: Boolean!) { + organization(login: $org) { + membersWithRole(first: 100, after: $membersCursor) @include(if: $fetchMembers) { + pageInfo { hasNextPage endCursor } + nodes { login } + } + teams(first: 100, after: $teamsCursor) @include(if: $fetchTeams) { + pageInfo { hasNextPage endCursor } + nodes { + slug + members(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { login } + } + } + } + } + rateLimit { remaining resetAt } + } + `; + + const members = []; + const memberTeams = new Map(); + const teamsNeedingMorePages = []; + + let membersCursor = null; + let teamsCursor = null; + let membersDone = false; + let teamsDone = false; + let requestCount = 0; + + while (!membersDone || !teamsDone) { + requestCount++; + + const data = await throttle.run(() => + github.graphql(query, { + org, + membersCursor, + teamsCursor, + fetchMembers: !membersDone, + fetchTeams: !teamsDone, + }), + ); + + if (!membersDone) { + for (const node of data.organization.membersWithRole.nodes) { + members.push({ login: node.login }); + + if (!memberTeams.has(node.login)) { + memberTeams.set(node.login, []); + } + } + + membersDone = !data.organization.membersWithRole.pageInfo.hasNextPage; + membersCursor = data.organization.membersWithRole.pageInfo.endCursor; + } + + if (!teamsDone) { + for (const team of data.organization.teams.nodes) { + for (const member of team.members.nodes) { + if (!memberTeams.has(member.login)) { + memberTeams.set(member.login, []); + } + + memberTeams.get(member.login).push(team.slug); + } + + if (team.members.pageInfo.hasNextPage) { + teamsNeedingMorePages.push({ + slug: team.slug, + cursor: team.members.pageInfo.endCursor, + }); + } + } + + teamsDone = !data.organization.teams.pageInfo.hasNextPage; + teamsCursor = data.organization.teams.pageInfo.endCursor; + } + + core.debug( + ` gql request ${requestCount}: membersDone=${membersDone} teamsDone=${teamsDone} rateLimit.remaining=${data.rateLimit.remaining}`, + ); + } + + if (teamsNeedingMorePages.length > 0) { + core.info( + `Paginating ${teamsNeedingMorePages.length} team(s) with >100 members...`, + ); + + const teamPageQuery = ` + query ($org: String!, $slug: String!, $cursor: String) { + organization(login: $org) { + team(slug: $slug) { + members(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { login } + } + } + } + } + `; + + for (const { slug, cursor: startCursor } of teamsNeedingMorePages) { + let cursor = startCursor; + let done = false; + + while (!done) { + const data = await throttle.run(() => + github.graphql(teamPageQuery, { org, slug, cursor }), + ); + + for (const member of data.organization.team.members.nodes) { + if (!memberTeams.has(member.login)) { + memberTeams.set(member.login, []); + } + + memberTeams.get(member.login).push(slug); + } + + done = !data.organization.team.members.pageInfo.hasNextPage; + cursor = data.organization.team.members.pageInfo.endCursor; + } + } + } + + const noTeamCount = members.filter( + (member) => (memberTeams.get(member.login) || []).length === 0, + ).length; + + core.info( + `Fetched ${members.length} member(s) in ${requestCount} GraphQL request(s). ${ + members.length - noTeamCount + } on a team, ${noTeamCount} on none.`, + ); + + return { members, memberTeams }; +} + +/** + * Checks activity for a batch of members in a single GraphQL request using + * aliased sub-queries. For each member we ask for the count of issues/PRs + * they've been involved in within the org since the cutoff. A member is + * active if the count is > 0. + * + * @param {object} github - Authenticated Octokit client. + * @param {string} org - Organization login. + * @param {string[]} logins - Member logins to check in this batch. + * @param {string} cutoffISO - ISO date string (YYYY-MM-DD). + * @returns {Promise>} Logins from this batch that are active. + */ +async function checkActivityBatch(github, org, logins, cutoffISO) { + const fragments = logins.map((_, i) => { + // Aliases must be valid GraphQL identifiers, so we index by position + // rather than login (logins can contain hyphens). + return `a${i}: search(type: ISSUE, query: $q${i}, first: 0) { issueCount }`; + }); + + const varDefs = logins.map((_, i) => `$q${i}: String!`).join(", "); + const query = ` + query (${varDefs}) { + ${fragments.join("\n ")} + rateLimit { remaining } + } + `; + + const variables = {}; + for (let i = 0; i < logins.length; i++) { + variables[`q${i}`] = + `org:${org} involves:${logins[i]} updated:>=${cutoffISO}`; + } + + const data = await github.graphql(query, variables); + const active = new Set(); + + for (let i = 0; i < logins.length; i++) { + if ((data[`a${i}`]?.issueCount ?? 0) > 0) { + active.add(logins[i]); + } + } + + return active; +} + +/** + * Determines which members have had any repo interaction since the cutoff. + * Members who are already inactive-by-no-teams are skipped to save requests. + * + * @param {object} github - Authenticated Octokit client. + * @param {string} org - Organization login. + * @param {Array<{login: string}>} members - Members to check. + * @param {Map} memberTeams - Team memberships (for skip logic). + * @param {string} cutoffISO - ISO date string (YYYY-MM-DD). + * @param {object} throttle - Rate limiter. + * @param {object} core - Logger. + * @returns {Promise>} + */ +async function findActiveMembers( + github, + org, + members, + memberTeams, + cutoffISO, + throttle, + core, +) { + // A member with no teams is already inactive — the activity result doesn't + // change the classification, so we skip the API cost for them. + const toCheck = members.filter( + ({ login }) => (memberTeams.get(login) || []).length > 0, + ); + const skipped = members.length - toCheck.length; + + core.info( + `Checking activity since ${cutoffISO} for ${toCheck.length} member(s) ` + + `in batches of ${ACTIVITY_BATCH_SIZE} (skipping ${skipped} already-inactive no-team member(s))...`, + ); + + const active = new Set(); + const totalBatches = Math.ceil(toCheck.length / ACTIVITY_BATCH_SIZE); + + for (let b = 0; b < totalBatches; b++) { + const start = b * ACTIVITY_BATCH_SIZE; + const batch = toCheck.slice(start, start + ACTIVITY_BATCH_SIZE); + const logins = batch.map(({ login }) => login); + + const batchActive = await throttle.run(() => + checkActivityBatch(github, org, logins, cutoffISO), + ); + + for (const login of batchActive) { + active.add(login); + } + + core.debug( + ` batch ${b + 1}/${totalBatches}: ${batchActive.size}/${logins.length} active`, + ); + } + + core.info( + `Activity check complete: ${active.size} active, ${ + toCheck.length - active.size + } inactive (of ${toCheck.length} checked).`, + ); + + return active; +} + +/** + * Classifies members as inactive based on team membership and recent activity. + * + * @param {Array<{login: string}>} members - All org members. + * @param {Map} memberTeams - Map of login to team slugs. + * @param {Set} activeMembers - Members with recent activity. + * @returns {Array<{login: string, teams: string[], reasons: string[]}>} + */ +function classifyInactiveMembers(members, memberTeams, activeMembers) { + const inactive = []; + + for (const { login } of members) { + const teams = memberTeams.get(login) || []; + const noTeams = teams.length === 0; + // Members with no teams are skipped from the activity check and thus + // won't be in activeMembers — treat them as "activity unknown" rather + // than "no activity" to avoid a misleading reason line. + const noActivity = teams.length > 0 && !activeMembers.has(login); + + if (noTeams || noActivity) { + const reasons = []; + + if (noTeams) { + reasons.push("no team membership"); + } + + if (noActivity) { + reasons.push(`no repo activity in ${MONTHS} months`); + } + + inactive.push({ login, teams, reasons }); + } + } + + return inactive; +} + +/** + * Renders the inactive members report as a Markdown issue body. + */ +function renderReport({ org, cutoffISO, totalMembers, inactive }) { + const today = new Date().toISOString().split("T")[0]; + + let body = `# Inactive Members Report\n\n`; + body += `_Generated on ${today} for organization \`${org}\`_\n\n`; + body += `An **inactive member** is defined as any organization member who meets at least one of the following criteria:\n\n`; + body += `1. Belongs to **no teams** in the organization\n`; + body += `2. Has had **no interaction with any organization repository since ${cutoffISO}** (${MONTHS} months)\n\n`; + body += `## Summary\n\n`; + body += `- **Total org members:** ${totalMembers}\n`; + body += `- **Inactive members:** ${inactive.length}\n`; + body += `- **Active members:** ${totalMembers - inactive.length}\n\n`; + + if (inactive.length === 0) { + body += `🎉 No inactive members found!\n`; + } else { + body += `## Inactive Members\n\n`; + body += `| Member | Reason(s) | Teams |\n`; + body += `| --- | --- | --- |\n`; + + const sorted = [...inactive].sort((a, b) => a.login.localeCompare(b.login)); + + for (const member of sorted) { + const teamsCell = + member.teams.length > 0 + ? member.teams.map((team) => `\`${team}\``).join(", ") + : "_none_"; + + body += `| @${member.login} | ${member.reasons.join("; ")} | ${teamsCell} |\n`; + } + } + + body += `\n---\n_This report was generated automatically._\n`; + + return body; +} + +/** + * Finds an open issue with the report title and updates it, or creates one. + */ +async function upsertReportIssue(github, owner, repo, body, throttle, core) { + // Octokit's paginate helper makes multiple requests internally; wrap each + // page by using a custom iterator that goes through the throttle. + const issues = []; + let page = 1; + + const searchQuery = `repo:${owner}/${repo} is:issue is:open in:title "${ISSUE_TITLE}"`; + const searchResult = await throttle.run(() => + github.rest.search.issuesAndPullRequests({ + q: searchQuery, + per_page: 1, + }), + ); + + const existing = searchResult.data.items.find( + (issue) => issue.title === ISSUE_TITLE, + ); + + if (existing) { + const { data } = await throttle.run(() => + github.rest.issues.update({ + owner, + repo, + issue_number: existing.number, + body, + }), + ); + + core.info(`Updated issue #${data.number}: ${data.html_url}`); + + return { number: data.number, html_url: data.html_url, action: "updated" }; + } + + const { data } = await throttle.run(() => + github.rest.issues.create({ owner, repo, title: ISSUE_TITLE, body }), + ); + + core.info(`Created issue #${data.number}: ${data.html_url}`); + + return { number: data.number, html_url: data.html_url, action: "created" }; +} + +/** + * Identifies inactive members of a GitHub organization and emits a Markdown report. + * + * @param {object} params + * @param {object} params.github - Authenticated Octokit client. + * @param {object} params.context - GitHub Actions context object. + * @param {object} params.core - GitHub Actions core toolkit. + * @returns {Promise} + */ +export default async function getInactiveMembers({ github, context, core }) { + const throttle = createThrottle(MIN_REQUEST_INTERVAL_MS); + + const org = context.repo.owner; + const repo = context.repo.repo; + const cutoffISO = new Date(Date.now() - MONTHS_MS) + .toISOString() + .split("T")[0]; + + core.info( + `Starting inactive-members scan for org "${org}" (cutoff: ${cutoffISO}, rate limit: ${RATE_LIMIT_PER_MINUTE}/min).`, + ); + + const startedAt = Date.now(); + + const { members, memberTeams } = await fetchMembersAndTeams( + github, + org, + throttle, + core, + ); + + const activeMembers = await findActiveMembers( + github, + org, + members, + memberTeams, + cutoffISO, + throttle, + core, + ); + + const inactive = classifyInactiveMembers(members, memberTeams, activeMembers); + + core.info( + `Classified ${inactive.length} inactive member(s) out of ${members.length} in ${( + (Date.now() - startedAt) / + 1000 + ).toFixed(1)}s.`, + ); + + const body = renderReport({ + org, + cutoffISO, + totalMembers: members.length, + inactive, + }); + + if (inactive.length > 0) { + const { html_url } = await upsertReportIssue( + github, + org, + repo, + body, + throttle, + core, + ); + + core.info(`Report available at: ${html_url}`); + } +}