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
16 changes: 11 additions & 5 deletions commands/emeritus.js
Original file line number Diff line number Diff line change
@@ -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<void>}
* 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<void>} 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)
Expand Down
20 changes: 15 additions & 5 deletions commands/offboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
* 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<void>} 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)
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 12 additions & 4 deletions commands/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
* 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<string>, dryRun: boolean }} options
* @returns {Promise<void>} 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)
Expand Down
17 changes: 10 additions & 7 deletions commands/sponsors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>}
* 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<void>} 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)
Expand Down
15 changes: 9 additions & 6 deletions commands/utils/remove-from-npm.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { env } from 'node:process'
import { spawn } from 'node:child_process'
import { askForInput } from './input.js'

Expand All @@ -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')

Expand All @@ -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<void>} 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 {
Expand Down
79 changes: 56 additions & 23 deletions github-api.js
Original file line number Diff line number Diff line change
@@ -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<object>} The organization data.
* @param {string} orgName - The login name of the GitHub organization.
* @returns {Promise<Organization|null>} The organization node (`{ id, name }`), or
* `null` when the login does not resolve to an organization.
*/
async getOrgData (orgName) {
const { organization } = await this.graphqlClient(`
Expand All @@ -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<Team[]>} Array of team objects with their members and details.
Expand Down Expand Up @@ -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<Sponsor[]>} Array of normalized sponsor objects, one per backer.
*/
Expand Down Expand Up @@ -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<object>} The `data` payload of the response.
* @returns {Promise<object>} 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', {
Expand All @@ -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<any>} The user information.
* @returns {Promise<UserInfo|null>} The user node (login, name, social accounts),
* or `null` when the username does not resolve.
*/
async getUserInfo (username) {
try {
Expand Down Expand Up @@ -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<import('@octokit/openapi-types').components['schemas']['team-membership']>} The updated team data.
* @returns {Promise<TeamMembership>} The membership record (`url`, `role`, `state`).
*/
async addUserToTeam (org, teamSlug, username) {
try {
Expand All @@ -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<any>} The response data from the API.
* @returns {Promise<void>} 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
Expand Down Expand Up @@ -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').
Expand Down
24 changes: 17 additions & 7 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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':
Expand All @@ -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
}