Skip to content

Commit 5426daf

Browse files
committed
feat: offboarding
1 parent e5e5078 commit 5426daf

File tree

5 files changed

+110
-20
lines changed

5 files changed

+110
-20
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ node --env-file=.env index.js onboard --username <user> --team collaborators --t
2525

2626
### Offboard a user
2727

28-
- [ ] TODO
28+
This command removes a user from the active teams in the GitHub organization and npm maintainers.
29+
30+
```bash
31+
node --env-file=.env index.js offboard --org <org> --username <user> [--dryRun]
32+
```
2933

3034
### Check emeritus members
3135

commands/offboard.js

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,56 @@
1+
import { confirm } from './utils/input.js'
2+
import { removeFromNpm } from './utils/remove-from-npm.js'
13
/**
24
* Offboards a user from an organization.
3-
* @param {{ logger: import('pino').Logger }} deps
4-
* @param {{ org: string, username: string, dryRun: boolean }} options
5+
* @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps
6+
* @param {{ org: string, username: string, joiningTeams: Set, dryRun: boolean }} options
57
* @returns {Promise<void>}
68
*/
7-
export default async function offboard ({ logger }, { username, dryRun }) {
8-
// Implement offboarding logic here
9-
if (dryRun) {
10-
logger.info(`[DRY RUN] Would offboard user: ${username}`)
11-
} else {
12-
logger.info(`Offboarding user: ${username}`)
9+
export default async function offboard ({ logger, client }, { org, username, dryRun }) {
10+
const joiningUser = await client.getUserInfo(username)
11+
if (!await confirm(`Are you sure you want to offboard ${joiningUser.login} [${joiningUser.name}] to ${org}?`)) {
12+
logger.warn('Aborting offboarding')
13+
process.exit(0)
1314
}
15+
16+
const orgData = await client.getOrgData(org)
17+
logger.info('Organization ID %s', orgData.id)
18+
const orgTeams = await client.getOrgChart(orgData)
19+
20+
/** GitHub Cleanup */
21+
const userTeams = orgTeams.filter(t => t.members.find(m => m.login === joiningUser.login))
22+
23+
for (const team of userTeams) {
24+
if (dryRun) {
25+
logger.warn('[DRY RUN] This user %s will be removed from team %s', joiningUser.login, team.slug)
26+
continue
27+
}
28+
29+
await client.removeUserFromTeam(orgData.name, team.slug, joiningUser.login)
30+
logger.info('Removed %s from team %s', joiningUser.login, team.slug)
31+
}
32+
logger.info('GitHub offboarding completed for user %s ✅ ', joiningUser.login)
33+
34+
/** NPM Cleanup */
35+
const userNpmTeams = [
36+
{ slug: 'developers' }, // NPM has a default team for every org
37+
...userTeams
38+
]
39+
40+
for (const team of userNpmTeams) {
41+
if (dryRun) {
42+
logger.warn('[DRY RUN] This user %s will be removed from NPM team %s', joiningUser.login, team.slug)
43+
continue
44+
}
45+
46+
try {
47+
logger.debug('Removing %s from NPM team %s', joiningUser.login, team.slug)
48+
await removeFromNpm(org, team.slug, joiningUser.login)
49+
logger.info('Removed %s from NPM team %s', joiningUser.login, team.slug)
50+
} catch (error) {
51+
logger.error('Failed to remove %s from NPM team %s', joiningUser.login, team.slug)
52+
logger.error(error)
53+
}
54+
}
55+
logger.info('NPM offboarding completed for user %s ✅ ', joiningUser.login)
1456
}

commands/onboard.js

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import readline from 'node:readline/promises'
1+
import { confirm } from './utils/input.js'
22

33
/**
44
* Onboards a user to an organization.
@@ -49,13 +49,3 @@ export default async function onboard ({ client, logger }, { org, username, join
4949
})
5050
logger.info('When it will be done, the NPM onboarding will be completed for user %s ✅ ', joiningUser.login)
5151
}
52-
53-
async function confirm (q) {
54-
const rl = readline.createInterface({
55-
input: process.stdin,
56-
output: process.stdout
57-
})
58-
const answer = await rl.question(`${q} (y/N)`)
59-
rl.close()
60-
return answer.trim().toLowerCase() === 'y'
61-
}

commands/utils/input.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import readline from 'node:readline/promises'
2+
3+
export async function confirm (q) {
4+
const answer = await askForInput(`${q} (y/N)`)
5+
return answer.trim().toLowerCase() === 'y'
6+
}
7+
8+
export async function askForInput (message) {
9+
const rl = readline.createInterface({
10+
input: process.stdin,
11+
output: process.stdout
12+
})
13+
const answer = await rl.question(message)
14+
rl.close()
15+
return answer.trim()
16+
}

commands/utils/remove-from-npm.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { spawn } from 'node:child_process'
2+
import { askForInput } from './input.js'
3+
4+
function runSpawn (cmd, args) {
5+
return new Promise((resolve, reject) => {
6+
const cli = spawn(cmd, args, { env: process.env })
7+
cli.stdout.setEncoding('utf8')
8+
cli.stderr.setEncoding('utf8')
9+
10+
let stdout = ''
11+
let stderr = ''
12+
cli.stdout.on('data', (data) => { stdout += data })
13+
cli.stderr.on('data', (data) => { stderr += data })
14+
cli.on('close', (code, signal) => {
15+
if (code === 0) {
16+
return resolve(stdout.trim())
17+
}
18+
reject(new Error(`${cmd} ${args} returned code ${code} and signal ${signal}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`))
19+
})
20+
})
21+
}
22+
23+
export async function removeFromNpm (org, teamSlug, username) {
24+
const baseArgs = ['team', 'rm', `@${org}:${teamSlug}`, username]
25+
26+
try {
27+
await runSpawn('npm', baseArgs)
28+
} catch (error) {
29+
const isOtpNeeded = error.message.includes('npm ERR! code EOTP') || error.message.includes('one-time password')
30+
if (!isOtpNeeded) {
31+
throw error
32+
}
33+
34+
const otp = await askForInput('NPM OTP code is required to proceed:')
35+
const otpArgs = [...baseArgs, '--otp', otp]
36+
await runSpawn('npm', otpArgs)
37+
}
38+
}

0 commit comments

Comments
 (0)