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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ node --env-file=.env index.js onboard --username <user> --team collaborators --t

### Offboard a user

- [ ] TODO
This command removes a user from the active teams in the GitHub organization and npm teams
and adds the user to the `emeritus` team if it exists.

```bash
node --env-file=.env index.js offboard --org <org> --username <user> [--dryRun]
```

### Check emeritus members

Expand Down
69 changes: 61 additions & 8 deletions commands/offboard.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,67 @@
import { confirm } from './utils/input.js'
import { removeFromNpm } from './utils/remove-from-npm.js'
/**
* Offboards a user from an organization.
* @param {{ logger: import('pino').Logger }} deps
* @param {{ org: string, username: string, dryRun: boolean }} options
* @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps
* @param {{ org: string, username: string, joiningTeams: Set, dryRun: boolean }} options
* @returns {Promise<void>}
*/
export default async function offboard ({ logger }, { username, dryRun }) {
// Implement offboarding logic here
if (dryRun) {
logger.info(`[DRY RUN] Would offboard user: ${username}`)
} else {
logger.info(`Offboarding user: ${username}`)
export default async function offboard ({ logger, client }, { org, username, dryRun }) {
const joiningUser = await client.getUserInfo(username)
if (!await confirm(`Are you sure you want to offboard ${joiningUser.login} [${joiningUser.name}] to ${org}?`)) {
logger.warn('Aborting offboarding')
process.exit(0)
}

const orgData = await client.getOrgData(org)
logger.info('Organization ID %s', orgData.id)
const orgTeams = await client.getOrgChart(orgData)
const emeritusTeam = orgTeams.find(team => team.slug === 'emeritus')

/** GitHub Cleanup */
const userTeams = orgTeams.filter(t => t.members.find(m => m.login === joiningUser.login))

for (const team of userTeams) {
if (dryRun) {
logger.warn('[DRY RUN] This user %s will be removed from team %s', joiningUser.login, team.slug)
continue
}

await client.removeUserFromTeam(orgData.name, team.slug, joiningUser.login)
logger.info('Removed %s from team %s', joiningUser.login, team.slug)
}

if (emeritusTeam) {
if (dryRun) {
logger.warn('[DRY RUN] This user %s will be added to emeritus team', joiningUser.login)
} else {
await client.addUserToTeam(orgData.name, emeritusTeam.slug, joiningUser.login)
logger.info('Added %s to emeritus team', joiningUser.login)
}
}

logger.info('GitHub offboarding completed for user %s ✅ ', joiningUser.login)

/** NPM Cleanup */
const userNpmTeams = [
{ slug: 'developers' }, // NPM has a default team for every org
...userTeams
]

for (const team of userNpmTeams) {
if (dryRun) {
logger.warn('[DRY RUN] This user %s will be removed from NPM team %s', joiningUser.login, team.slug)
continue
}

try {
logger.debug('Removing %s from NPM team %s', joiningUser.login, team.slug)
await removeFromNpm(org, team.slug, joiningUser.login)
logger.info('Removed %s from NPM team %s', joiningUser.login, team.slug)
} catch (error) {
logger.error('Failed to remove %s from NPM team %s', joiningUser.login, team.slug)
logger.error(error)
}
}
logger.info('NPM offboarding completed for user %s ✅ ', joiningUser.login)
}
12 changes: 1 addition & 11 deletions commands/onboard.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import readline from 'node:readline/promises'
import { confirm } from './utils/input.js'

/**
* Onboards a user to an organization.
Expand Down Expand Up @@ -49,13 +49,3 @@ export default async function onboard ({ client, logger }, { org, username, join
})
logger.info('When it will be done, the NPM onboarding will be completed for user %s ✅ ', joiningUser.login)
}

async function confirm (q) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
const answer = await rl.question(`${q} (y/N)`)
rl.close()
return answer.trim().toLowerCase() === 'y'
}
16 changes: 16 additions & 0 deletions commands/utils/input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import readline from 'node:readline/promises'

export async function confirm (q) {
const answer = await askForInput(`${q} (y/N)`)
return answer.trim().toLowerCase() === 'y'
}

export async function askForInput (message) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
const answer = await rl.question(message)
rl.close()
return answer.trim()
}
38 changes: 38 additions & 0 deletions commands/utils/remove-from-npm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { spawn } from 'node:child_process'
import { askForInput } from './input.js'

function runSpawn (cmd, args) {
return new Promise((resolve, reject) => {
const cli = spawn(cmd, args, { env: process.env })
cli.stdout.setEncoding('utf8')
cli.stderr.setEncoding('utf8')

let stdout = ''
let stderr = ''
cli.stdout.on('data', (data) => { stdout += data })
cli.stderr.on('data', (data) => { stderr += data })
cli.on('close', (code, signal) => {
if (code === 0) {
return resolve(stdout.trim())
}
reject(new Error(`${cmd} ${args} returned code ${code} and signal ${signal}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`))
})
})
}

export async function removeFromNpm (org, teamSlug, username) {
const baseArgs = ['team', 'rm', `@${org}:${teamSlug}`, username]

try {
await runSpawn('npm', baseArgs)
} catch (error) {
const isOtpNeeded = error.message.includes('npm ERR! code EOTP') || error.message.includes('one-time password')
if (!isOtpNeeded) {
throw error
}

const otp = await askForInput('NPM OTP code is required to proceed:')
const otpArgs = [...baseArgs, '--otp', otp]
await runSpawn('npm', otpArgs)
}
}