diff --git a/commands/emeritus.js b/commands/emeritus.js index 722fb96..78be7d2 100644 --- a/commands/emeritus.js +++ b/commands/emeritus.js @@ -1,9 +1,15 @@ /** - * 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. - * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps - Dependencies. - * @param {{ org: string, monthsInactiveThreshold: number, dryRun: boolean }} options - Command options. - * @returns {Promise} + * Builds the list of members who haven't contributed to `org` in the last + * `monthsInactiveThreshold` months (excluding the `leads` team and members + * already in the `emeritus` team) and opens a tracking issue in the + * `org-admin` repository listing them. In `dryRun` mode the issue is not + * created — the list is just logged. + * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps + * @param {{ org: string, monthsInactiveThreshold: number, dryRun: boolean }} options + * @returns {Promise} Resolves once the analysis is complete and the + * tracking issue has been opened (or the dry-run log has been emitted). + * @throws {Error} Propagates errors from `client.getOrgData`, `client.getOrgChart`, + * `client.getUsersContributions`, or `client.createIssue`. */ export default async function emeritus ({ client, logger }, { org, monthsInactiveThreshold, dryRun }) { logger.info('Running emeritus command for organization: %s', org) diff --git a/commands/offboard.js b/commands/offboard.js index c9f088b..e2b9e90 100644 --- a/commands/offboard.js +++ b/commands/offboard.js @@ -2,11 +2,21 @@ import { exit } from 'node:process' import { confirm } from './utils/input.js' import { removeFromNpm } from './utils/remove-from-npm.js' import { listNpmTeams, listNpmTeamMembers } from './utils/npm-teams.js' + /** - * Offboards a user from an organization. - * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps - Dependencies. - * @param {{ org: string, username: string, joiningTeams: Set, dryRun: boolean }} options - Command options. - * @returns {Promise} + * Removes `username` from every GitHub team they belong to in `org`, adds them + * to the `emeritus` team when it exists, then runs `npm team rm` for each + * affected NPM team the user currently belongs to (the user's NPM handle is + * read from their GitHub social accounts, falling back to the GitHub login). + * Prompts for confirmation before any change; in `dryRun` mode logs what + * would happen without making the mutations or spawning `npm`. + * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps + * @param {{ org: string, username: string, dryRun: boolean }} options + * @returns {Promise} Resolves once all GitHub + NPM cleanup is done. + * Calls `process.exit(0)` if the user declines the confirmation prompt. + * @throws {Error} Propagates errors from `client.getUserInfo`, `client.getOrgData`, + * `client.getOrgChart`, `client.removeUserFromTeam`, `client.addUserToTeam`, + * or `removeFromNpm` (excluding OTP handling, which is internal). */ export default async function offboard ({ logger, client }, { org, username, dryRun }) { const joiningUser = await client.getUserInfo(username) @@ -59,7 +69,7 @@ export default async function offboard ({ logger, client }, { org, username, dry try { logger.debug('Removing %s from NPM team %s', npmHandle, team) - await removeFromNpm(org, team, npmHandle) + await removeFromNpm({ org, teamSlug: team, username: npmHandle }) logger.info('Removed %s from NPM team %s', npmHandle, team) } catch (error) { logger.error('Failed to remove %s from NPM team %s', npmHandle, team) diff --git a/commands/onboard.js b/commands/onboard.js index 9e07b44..f1f694a 100644 --- a/commands/onboard.js +++ b/commands/onboard.js @@ -3,10 +3,18 @@ import { confirm } from './utils/input.js' import { listNpmTeams } from './utils/npm-teams.js' /** - * Onboards a user to an organization. - * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps - Dependencies. - * @param {{ org: string, username: string, joiningTeams: Set, dryRun: boolean }} options - Command options. - * @returns {Promise} + * Adds `username` to the requested GitHub teams in `org`, then prints the manual + * steps the operator must follow for NPM (invite URL + `npm team add` commands). + * Prompts the user for a yes/no confirmation before making any change; in + * `dryRun` mode logs what would happen without performing the GitHub mutations. + * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps + * @param {{ org: string, username: string, joiningTeams: Set, dryRun: boolean }} options + * @returns {Promise} Resolves once GitHub changes are applied and the NPM + * instructions have been logged. + * Calls `process.exit(0)` if the user declines the confirmation prompt, and + * `process.exit(1)` if `joiningTeams` contains slugs that don't exist in `org`. + * @throws {Error} Propagates errors from `client.getUserInfo`, `client.getOrgData`, + * `client.getOrgChart`, or `client.addUserToTeam`. */ export default async function onboard ({ client, logger }, { org, username, joiningTeams, dryRun }) { const joiningUser = await client.getUserInfo(username) diff --git a/commands/sponsors.js b/commands/sponsors.js index b7dd5a6..b92e302 100644 --- a/commands/sponsors.js +++ b/commands/sponsors.js @@ -16,13 +16,16 @@ const TIERS = [ ] /** - * Fetches all sponsors of an organization from GitHub Sponsors and Open Collective, - * keeps only the recurring tier sponsors (monthly contribution within a tier), - * sorts them by monthly amount descending and flags those that stopped paying. - * The result is logged and written to a JSON file for later inspection. - * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps - Dependencies. - * @param {{ org: string }} options - Command options. - * @returns {Promise} + * Merges GitHub Sponsors and Open Collective backers for `org`, keeps only + * recurring tier sponsors (monthly contribution ≥ tier 1), sorts them by + * monthly amount descending, flags lapsed ones (cancelled or overdue), logs + * the result, and writes it to `./sponsors.json` for later inspection. + * @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps + * @param {{ org: string }} options + * @returns {Promise} Resolves once the result has been logged and + * written to `sponsors.json`. + * @throws {Error} Propagates errors from `client.getGithubSponsors`, + * `client.getOpenCollectiveSponsors`, or `writeFileSync`. */ export default async function sponsors ({ client, logger }, { org }) { logger.info('Running sponsors command for organization: %s', org) diff --git a/commands/utils/remove-from-npm.js b/commands/utils/remove-from-npm.js index 6eaa6c2..b7c382b 100644 --- a/commands/utils/remove-from-npm.js +++ b/commands/utils/remove-from-npm.js @@ -1,4 +1,3 @@ -import { env } from 'node:process' import { spawn } from 'node:child_process' import { askForInput } from './input.js' @@ -11,7 +10,7 @@ import { askForInput } from './input.js' */ function runSpawn (cmd, args) { return new Promise((resolve, reject) => { - const cli = spawn(cmd, args, { env }) + const cli = spawn(cmd, args) cli.stdout.setEncoding('utf8') cli.stderr.setEncoding('utf8') @@ -30,13 +29,17 @@ function runSpawn (cmd, args) { /** * Removes a user from an NPM organization team, prompting for OTP if required. - * @param {string} org - The NPM organization name. - * @param {string} teamSlug - The team slug. - * @param {string} username - The NPM username to remove from the team. + * The `npm` subprocess inherits its environment from the parent process — no + * env is forwarded here, so any auth (e.g. `NPM_TOKEN`) the user has set in + * their shell is picked up naturally. + * @param {object} params - Parameters. + * @param {string} params.org - The NPM organization name. + * @param {string} params.teamSlug - The team slug. + * @param {string} params.username - The NPM username to remove from the team. * @returns {Promise} A promise that resolves when the user is successfully removed. * @throws {Error} Throws an error if the npm command fails for reasons other than missing OTP. */ -export async function removeFromNpm (org, teamSlug, username) { +export async function removeFromNpm ({ org, teamSlug, username }) { const baseArgs = ['team', 'rm', `@${org}:${teamSlug}`, username] try { diff --git a/github-api.js b/github-api.js index 55c4d26..143433c 100644 --- a/github-api.js +++ b/github-api.js @@ -1,31 +1,38 @@ -import { env } from 'node:process' import { Octokit } from '@octokit/rest' import { graphql } from '@octokit/graphql' export default class AdminClient { - /** @param {import('pino').Logger} [logger] - Optional logger instance, defaults to console. */ - constructor (logger) { - if (!env.GITHUB_TOKEN) { - throw new Error('GITHUB_TOKEN environment variable is not set') + /** + * GitHub + Open Collective admin client. Holds an Octokit REST client and a + * GraphQL client authenticated with `githubToken`; methods reuse them for every + * call. The Open Collective client is stateless and built per call (it doesn't + * need auth, just optionally forwards `ocPersonalToken` as a rate-limit header). + * @param {{ githubToken: string, ocPersonalToken?: string, logger?: import('pino').Logger }} deps + * @throws {Error} When `githubToken` is missing. + */ + constructor ({ githubToken, ocPersonalToken, logger } = {}) { + if (!githubToken) { + throw new Error('githubToken is required') } this.logger = logger || console + this.ocPersonalToken = ocPersonalToken this.restClient = new Octokit({ - auth: env.GITHUB_TOKEN, + auth: githubToken, userAgent: 'fastify-org-admin-cli', }) - this.graphqlClient = graphql.defaults({ headers: { - authorization: `token ${env.GITHUB_TOKEN}`, + authorization: `token ${githubToken}`, }, }) } /** * Retrieves organization data for a given GitHub organization. - * @param {string} orgName - The name of the GitHub organization. - * @returns {Promise} The organization data. + * @param {string} orgName - The login name of the GitHub organization. + * @returns {Promise} The organization node (`{ id, name }`), or + * `null` when the login does not resolve to an organization. */ async getOrgData (orgName) { const { organization } = await this.graphqlClient(` @@ -43,7 +50,6 @@ export default class AdminClient { /** * Retrieves the organization chart for a given GitHub organization. * Fetches all teams and their members using the GitHub GraphQL API, handling pagination. - * @async * @param {object} orgData - The organization data. * @param {string} orgData.name - The login name of the GitHub organization. * @returns {Promise} Array of team objects with their members and details. @@ -271,9 +277,10 @@ export default class AdminClient { /** * Fetches all Open Collective backers of a collective using the public GraphQL v2 API. - * Reading backers is public and needs no authentication; if `OC_PERSONAL_TOKEN` is set - * it is sent to raise rate limits. Drives off incoming orders (the source of payment - * truth) so recurring contributions that lapsed can be flagged. + * Reading backers is public and needs no authentication; if the Open Collective personal + * token was passed to the constructor it is forwarded as `Personal-Token` to raise rate + * limits. Drives off incoming orders (the source of payment truth) so recurring + * contributions that lapsed can be flagged. * @param {string} slug - The Open Collective collective slug (e.g. 'fastify'). * @returns {Promise} Array of normalized sponsor objects, one per backer. */ @@ -336,14 +343,18 @@ export default class AdminClient { /** * Sends a GraphQL request to the Open Collective v2 API. + * Forwards `ocPersonalToken` as the `Personal-Token` header when set. * @param {string} query - The GraphQL query string. * @param {object} variables - The query variables. - * @returns {Promise} The `data` payload of the response. + * @returns {Promise} The untyped `data` payload of the response. The + * shape is fully determined by the caller's `query` — pick the fields you + * need out of the returned object. + * @throws {Error} When the HTTP response is not OK or the payload contains GraphQL errors. */ async #openCollectiveRequest (query, variables) { const headers = { 'Content-Type': 'application/json' } - if (env.OC_PERSONAL_TOKEN) { - headers['Personal-Token'] = env.OC_PERSONAL_TOKEN + if (this.ocPersonalToken) { + headers['Personal-Token'] = this.ocPersonalToken } const response = await fetch('https://api.opencollective.com/graphql/v2', { @@ -367,7 +378,8 @@ export default class AdminClient { /** * Fetches user information from GitHub using the GraphQL API. * @param {string} username - The GitHub username. - * @returns {Promise} The user information. + * @returns {Promise} The user node (login, name, social accounts), + * or `null` when the username does not resolve. */ async getUserInfo (username) { try { @@ -417,11 +429,11 @@ export default class AdminClient { } /** - * Add a user to a team in the organization using the REST API. + * Adds a user to a team in the organization using the REST API. * @param {string} org - The organization name. * @param {string} teamSlug - The team slug. * @param {string} username - The GitHub username to add. - * @returns {Promise} The updated team data. + * @returns {Promise} The membership record (`url`, `role`, `state`). */ async addUserToTeam (org, teamSlug, username) { try { @@ -440,20 +452,21 @@ export default class AdminClient { /** * Removes a user from a team in the organization using the REST API. + * The endpoint returns 204 No Content with an empty body, so the function + * resolves with no value. Callers only need to `await` for completion. * @param {string} org - The organization name. * @param {string} teamSlug - The team slug. * @param {string} username - The GitHub username to remove. - * @returns {Promise} The response data from the API. + * @returns {Promise} Resolves once the membership has been removed. */ async removeUserFromTeam (org, teamSlug, username) { try { - const response = await this.restClient.teams.removeMembershipForUserInOrg({ + await this.restClient.teams.removeMembershipForUserInOrg({ org, team_slug: teamSlug, username }) this.logger.info({ username, teamSlug }, 'User removed from team') - return response.data } catch (error) { this.logger.error({ username, teamSlug, error }, 'Failed to remove user from team') throw error @@ -658,6 +671,26 @@ function toDate (dateStr) { * @property {object[]} [socialAccounts] - The user's social accounts. */ +/** + * @typedef {object} Organization + * @property {string} id - The organization's GitHub node ID. + * @property {string} name - The organization's display name. + */ + +/** + * @typedef {object} UserInfo + * @property {string} login - The user's GitHub login. + * @property {string|null} name - The user's display name (may be null if unset). + * @property {{ nodes: Array<{ displayName: string, url: string, provider: string }> }} socialAccounts - The user's social account links (most recent four). + */ + +/** + * @typedef {object} TeamMembership + * @property {string} url - The API URL for the membership. + * @property {'member'|'maintainer'} role - The role granted by the membership. + * @property {'active'|'pending'} state - Whether the membership is active or still pending the user's invitation. + */ + /** * @typedef {object} Sponsor * @property {string} source - The funding platform ('github' or 'opencollective'). diff --git a/index.js b/index.js index 13f2a4d..b35d1e8 100644 --- a/index.js +++ b/index.js @@ -20,6 +20,16 @@ const logger = pino({ } }) +// Read the env vars this CLI consumes explicitly. Nothing below this file +// should touch `process.env` directly — values are passed down as named args. +const githubToken = process.env.GITHUB_TOKEN +const ocPersonalToken = process.env.OC_PERSONAL_TOKEN + +if (!githubToken) { + logger.error('GITHUB_TOKEN environment variable is not set') + process.exit(1) +} + const options = { commands: ['onboard', 'offboard', 'emeritus', 'sponsors', 'sync-npm-org'], options: { @@ -45,8 +55,8 @@ if (!options.commands.includes(command)) { process.exit(1) } -const client = new AdminClient(logger) -const technicalOptions = { client, logger } +const client = new AdminClient({ githubToken, ocPersonalToken, logger }) +const deps = { client, logger } switch (command) { case 'onboard': @@ -64,19 +74,19 @@ switch (command) { } const joiningTeams = new Set(parsed.values.team) - await onboard(technicalOptions, { username, dryRun, org, joiningTeams }) + await onboard(deps, { org, username, joiningTeams, dryRun }) } else { - await offboard(technicalOptions, { username, dryRun, org }) + await offboard(deps, { org, username, dryRun }) } break } case 'emeritus': - await emeritus(technicalOptions, { dryRun, org, monthsInactiveThreshold }) + await emeritus(deps, { org, monthsInactiveThreshold, dryRun }) break case 'sponsors': - await sponsors(technicalOptions, { org }) + await sponsors(deps, { org }) break case 'sync-npm-org': - await syncNpmOrg(technicalOptions, { org }) + await syncNpmOrg(deps, { org }) break }