Skip to content

Commit e5e5078

Browse files
authored
feat: onboard command (#6)
1 parent b95994e commit e5e5078

File tree

6 files changed

+140
-15
lines changed

6 files changed

+140
-15
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
GITHUB_TOKEN=YOUR_GITHUB_TOKEN_HERE

README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,17 @@ npm install @fastify-org/org-admin
1111

1212
### Onboard a user
1313

14-
- [ ] TODO
14+
This command adds a user to the specified teams in the GitHub organization.
15+
16+
```bash
17+
node --env-file=.env index.js onboard --org <org> --username <user> --team <team_1> --team <team_n> [--dryRun]
18+
```
19+
20+
For the fastify organization, the command would look like:
21+
22+
```bash
23+
node --env-file=.env index.js onboard --username <user> --team collaborators --team plugins --team website --team frontend
24+
```
1525

1626
### Offboard a user
1727

@@ -27,6 +37,12 @@ It creates an issue listing the users that have been inactive for more than a sp
2737
node --env-file=.env index.js emeritus --org <org> [--monthsInactiveThreshold] [--dryRun]
2838
```
2939

40+
For the fastify organization, the command would look like:
41+
42+
```bash
43+
node --env-file=.env index.js emeritus --monthsInactiveThreshold 24
44+
```
45+
3046
## License
3147

3248
Licensed under [MIT](./LICENSE).

commands/emeritus.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export default async function emeritus ({ client, logger }, { org, monthsInactiv
4545
logger.debug('Total users to move to emeritus team: %s', usersToEmeritus.length)
4646

4747
if (dryRun) {
48-
logger.info('These users should be added to emeritus team:')
48+
logger.info('[DRY-RUN] These users should be added to emeritus team:')
4949
usersToEmeritus.forEach(user => logger.info(`- @${user.user}`))
5050
} else {
5151
await client.createIssue(

commands/onboard.js

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,61 @@
1+
import readline from 'node:readline/promises'
2+
13
/**
24
* Onboards a user to an organization.
35
* @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps
4-
* @param {{ org: string, username: string, dryRun: boolean }} options
6+
* @param {{ org: string, username: string, joiningTeams: Set, dryRun: boolean }} options
57
* @returns {Promise<void>}
68
*/
7-
export default async function onboard ({ client, logger }, { org, username, dryRun }) {
8-
const orgId = await client.getOrgId(org)
9-
logger.info('Organization ID %s', orgId)
9+
export default async function onboard ({ client, logger }, { org, username, joiningTeams, dryRun }) {
10+
const joiningUser = await client.getUserInfo(username)
11+
if (!await confirm(`Are you sure you want to onboard ${joiningUser.login} [${joiningUser.name}] to ${org}?`)) {
12+
logger.warn('Aborting onboarding')
13+
process.exit(0)
14+
}
15+
16+
const orgData = await client.getOrgData(org)
17+
logger.info('Organization ID %s', orgData.id)
1018

11-
const orgChart = await client.getOrgChart(org)
19+
const orgTeams = await client.getOrgChart(orgData)
20+
const destinationTeams = orgTeams.filter(t => joiningTeams.has(t.slug))
21+
22+
const teamSlugs = new Set(orgTeams.map(t => t.slug))
23+
const wrongInputTeams = joiningTeams.difference(teamSlugs)
24+
if (wrongInputTeams.size) {
25+
logger.error('Team %s not found in organization %s', [...wrongInputTeams], org)
26+
process.exit(1)
27+
}
1228

13-
// TODO Implement onboarding logic here
1429
if (dryRun) {
15-
logger.info(`[DRY RUN] Would onboard user: ${username}`)
30+
logger.info('[DRY-RUN] This user %s should be added to team %s', joiningUser.login, [...joiningTeams])
1631
} else {
17-
logger.info(`Onboarding user: ${username}`)
32+
for (const targetTeam of destinationTeams) {
33+
await client.addUserToTeam(org, targetTeam.slug, joiningUser.login)
34+
logger.info('Added %s to team %s', joiningUser.login, targetTeam.slug)
35+
}
1836
}
37+
38+
logger.info('GitHub onboarding completed for user %s ✅ ', joiningUser.login)
39+
40+
logger.warn('To complete the NPM onboarding, please following these steps:')
41+
// This step cannot be automated, there are no API to add members to an org on NPM
42+
logger.info('1. Invite the user to the organization on NPM: https://www.npmjs.com/org/%s/invite?track=existingOrgAddMembers', org)
43+
logger.info('2. Add the user to the relevant teams by using the commands:');
44+
[
45+
{ slug: 'developers' }, // NPM has a default team for every org
46+
...destinationTeams
47+
].forEach(team => {
48+
logger.info('npm team add @%s:%s %s', org, team.slug, joiningUser.login)
49+
})
50+
logger.info('When it will be done, the NPM onboarding will be completed for user %s ✅ ', joiningUser.login)
51+
}
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'
1961
}

github-api.js

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ export default class AdminClient {
3434
return organization
3535
}
3636

37+
/**
38+
* Retrieves the organization chart for a given GitHub organization.
39+
* Fetches all teams and their members using the GitHub GraphQL API, handling pagination.
40+
*
41+
* @async
42+
* @param {Object} orgData - The organization data.
43+
* @param {string} orgData.name - The login name of the GitHub organization.
44+
* @returns {Promise<Array<Team>>} Array of team objects with their members and details.
45+
*/
3746
async getOrgChart (orgData) {
3847
let cursor = null
3948
let hasNextPage = true
@@ -187,6 +196,37 @@ export default class AdminClient {
187196
return membersData
188197
}
189198

199+
/**
200+
*
201+
* @param {string} username
202+
* @returns
203+
*/
204+
async getUserInfo (username) {
205+
try {
206+
const variables = { username }
207+
const userQuery = `
208+
query ($username: String!) {
209+
user(login: $username) {
210+
login
211+
name
212+
socialAccounts(last:4) {
213+
nodes {
214+
displayName
215+
url
216+
provider
217+
}
218+
}
219+
}
220+
}
221+
`
222+
const response = await this.graphqlClient(userQuery, variables)
223+
return response.user
224+
} catch (error) {
225+
this.logger.error({ username, error }, 'Failed to fetch user info')
226+
throw error
227+
}
228+
}
229+
190230
/**
191231
* Add a user to a team in the organization using the REST API.
192232
* @param {string} org - The organization name.
@@ -202,8 +242,6 @@ export default class AdminClient {
202242
username,
203243
role: 'member',
204244
})
205-
206-
this.logger.info({ username, teamSlug }, 'User added to team')
207245
return response.data
208246
} catch (error) {
209247
this.logger.error({ username, teamSlug, error }, 'Failed to add user to team')
@@ -275,6 +313,10 @@ function transformGqlTeam ({ node }) {
275313
}
276314
}
277315

316+
/**
317+
* Transforms a GitHub GraphQL member node into a simplified member object.
318+
* @returns {Team}
319+
*/
278320
function transformGqlMember ({ node }) {
279321
return {
280322
user: node.login,
@@ -288,3 +330,19 @@ function transformGqlMember ({ node }) {
288330
function toDate (dateStr) {
289331
return dateStr ? new Date(dateStr) : null
290332
}
333+
334+
/** @typedef {Object} Team
335+
* @property {string} id - The team's unique identifier.
336+
* @property {string} name - The team's name.
337+
* @property {string} slug - The team's slug.
338+
* @property {string} [description] - The team's description.
339+
* @property {string} privacy - The team's privacy setting.
340+
* @property {Array<TeamMember>} members - The list of team members.
341+
*/
342+
343+
/** @typedef {Object} TeamMember
344+
* @property {string} login - The member's GitHub login.
345+
* @property {string} [name] - The member's name.
346+
* @property {string} [email] - The member's email.
347+
* @property {string} role - The member's role in the team.
348+
*/

index.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const options = {
2323
options: {
2424
dryRun: { type: 'boolean', default: false },
2525
username: { type: 'string', multiple: false, default: undefined },
26+
team: { type: 'string', multiple: true },
2627
org: { type: 'string', multiple: false, default: 'fastify' },
2728
monthsInactiveThreshold: { type: 'string', multiple: false, default: '12' },
2829
},
@@ -31,7 +32,8 @@ const options = {
3132

3233
const parsed = parseArgs(options)
3334

34-
const [command, ...positionals] = parsed.positionals || []
35+
// const [command, ...positionals] = parsed.positionals || []
36+
const [command] = parsed.positionals || []
3537
const dryRun = parsed.values.dryRun || false
3638
const org = parsed.values.org
3739
const monthsInactiveThreshold = parseInt(parsed.values.monthsInactiveThreshold, 10) || 12
@@ -47,14 +49,20 @@ const technicalOptions = { client, logger }
4749
switch (command) {
4850
case 'onboard':
4951
case 'offboard': {
50-
const username = positionals[0]
52+
const username = parsed.values.username
5153
if (!username) {
5254
logger.error('Missing required username argument')
5355
process.exit(1)
5456
}
5557

5658
if (command === 'onboard') {
57-
await onboard(technicalOptions, { username, dryRun, org })
59+
if (!parsed.values.team) {
60+
logger.error('Missing required team argument for onboarding')
61+
process.exit(1)
62+
}
63+
64+
const joiningTeams = new Set(parsed.values.team)
65+
await onboard(technicalOptions, { username, dryRun, org, joiningTeams })
5866
} else {
5967
await offboard(technicalOptions, { username, dryRun, org })
6068
}

0 commit comments

Comments
 (0)