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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GITHUB_TOKEN=YOUR_GITHUB_TOKEN_HERE
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ npm install @fastify-org/org-admin

### Onboard a user

- [ ] TODO
This command adds a user to the specified teams in the GitHub organization.

```bash
node --env-file=.env index.js onboard --org <org> --username <user> --team <team_1> --team <team_n> [--dryRun]
```

For the fastify organization, the command would look like:

```bash
node --env-file=.env index.js onboard --username <user> --team collaborators --team plugins --team website --team frontend
```

### Offboard a user

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

For the fastify organization, the command would look like:

```bash
node --env-file=.env index.js emeritus --monthsInactiveThreshold 24
```

## License

Licensed under [MIT](./LICENSE).
2 changes: 1 addition & 1 deletion commands/emeritus.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default async function emeritus ({ client, logger }, { org, monthsInactiv
logger.debug('Total users to move to emeritus team: %s', usersToEmeritus.length)

if (dryRun) {
logger.info('These users should be added to emeritus team:')
logger.info('[DRY-RUN] These users should be added to emeritus team:')
usersToEmeritus.forEach(user => logger.info(`- @${user.user}`))
} else {
await client.createIssue(
Expand Down
54 changes: 47 additions & 7 deletions commands/onboard.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,59 @@
import readline from 'node:readline/promises'

/**
* Onboards a user to an organization.
* @param {{ client: import('../github-api.js').default, logger: import('pino').Logger }} deps
* @param {{ org: string, username: string, dryRun: boolean }} options
* @returns {Promise<void>}
*/
export default async function onboard ({ client, logger }, { org, username, dryRun }) {
const orgId = await client.getOrgId(org)
logger.info('Organization ID %s', orgId)
export default async function onboard ({ client, logger }, { org, username, joiningTeams, dryRun }) {
const joiningUser = await client.getUserInfo(username)
if (!await confirm(`Are you sure you want to onboard ${joiningUser.login} [${joiningUser.name}] to ${org}?`)) {
logger.warn('Aborting onboarding')
process.exit(0)
}

const orgChart = await client.getOrgChart(org)
const orgData = await client.getOrgData(org)
logger.info('Organization ID %s', orgData.id)

const orgTeams = await client.getOrgChart(orgData)
const destinationTeams = orgTeams.filter(t => joiningTeams.includes(t.slug))
if (destinationTeams.length !== joiningTeams.length) {
const missing = joiningTeams.filter(t => destinationTeams.find(dt => dt.slug === t) == null)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if you use the Set difference?
If you make joiningTeams a set, you can also use it on line 20, to replace the includes with the has.

logger.error('Team %s not found in organization %s', missing, org)
process.exit(1)
}

// TODO Implement onboarding logic here
if (dryRun) {
logger.info(`[DRY RUN] Would onboard user: ${username}`)
logger.info('[DRY-RUN] This user %s should be added to team %s', joiningUser.login, destinationTeams.map(t => t.slug))
} else {
logger.info(`Onboarding user: ${username}`)
for (const targetTeam of destinationTeams) {
await client.addUserToTeam(org, targetTeam.slug, joiningUser.login)
logger.info('Added %s to team %s', joiningUser.login, targetTeam.slug)
}
}

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

logger.warn('To complete the NPM onboarding, please following these steps:')
// This step cannot be automated, there are no API to add members to an org on NPM
logger.info('1. Invite the user to the organization on NPM: https://www.npmjs.com/org/%s/invite?track=existingOrgAddMembers', org)
logger.info('2. Add the user to the relevant teams by using the commands:');
[
{ slug: 'developers' }, // NPM has a default team for every org
...destinationTeams
].forEach(team => {
logger.info('npm team add @%s:%s %s', org, team.slug, joiningUser.login)
})
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'
}
62 changes: 60 additions & 2 deletions github-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ export default class AdminClient {
return organization
}

/**
* 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<Team>>} Array of team objects with their members and details.
*/
async getOrgChart (orgData) {
let cursor = null
let hasNextPage = true
Expand Down Expand Up @@ -187,6 +196,37 @@ export default class AdminClient {
return membersData
}

/**
*
* @param {string} username
* @returns
*/
async getUserInfo (username) {
try {
const variables = { username }
const userQuery = `
query ($username: String!) {
user(login: $username) {
login
name
socialAccounts(last:4) {
nodes {
displayName
url
provider
}
}
}
}
`
const response = await this.graphqlClient(userQuery, variables)
return response.user
} catch (error) {
this.logger.error({ username, error }, 'Failed to fetch user info')
throw error
}
}

/**
* Add a user to a team in the organization using the REST API.
* @param {string} org - The organization name.
Expand All @@ -202,8 +242,6 @@ export default class AdminClient {
username,
role: 'member',
})

this.logger.info({ username, teamSlug }, 'User added to team')
return response.data
} catch (error) {
this.logger.error({ username, teamSlug, error }, 'Failed to add user to team')
Expand Down Expand Up @@ -275,6 +313,10 @@ function transformGqlTeam ({ node }) {
}
}

/**
* Transforms a GitHub GraphQL member node into a simplified member object.
* @returns {Team}
*/
function transformGqlMember ({ node }) {
return {
user: node.login,
Expand All @@ -288,3 +330,19 @@ function transformGqlMember ({ node }) {
function toDate (dateStr) {
return dateStr ? new Date(dateStr) : null
}

/** @typedef {Object} Team
* @property {string} id - The team's unique identifier.
* @property {string} name - The team's name.
* @property {string} slug - The team's slug.
* @property {string} [description] - The team's description.
* @property {string} privacy - The team's privacy setting.
* @property {Array<TeamMember>} members - The list of team members.
*/

/** @typedef {Object} TeamMember
* @property {string} login - The member's GitHub login.
* @property {string} [name] - The member's name.
* @property {string} [email] - The member's email.
* @property {string} role - The member's role in the team.
*/
14 changes: 11 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const options = {
options: {
dryRun: { type: 'boolean', default: false },
username: { type: 'string', multiple: false, default: undefined },
team: { type: 'string', multiple: true },
org: { type: 'string', multiple: false, default: 'fastify' },
monthsInactiveThreshold: { type: 'string', multiple: false, default: '12' },
},
Expand All @@ -31,7 +32,8 @@ const options = {

const parsed = parseArgs(options)

const [command, ...positionals] = parsed.positionals || []
// const [command, ...positionals] = parsed.positionals || []
const [command] = parsed.positionals || []
const dryRun = parsed.values.dryRun || false
const org = parsed.values.org
const monthsInactiveThreshold = parseInt(parsed.values.monthsInactiveThreshold, 10) || 12
Expand All @@ -47,14 +49,20 @@ const technicalOptions = { client, logger }
switch (command) {
case 'onboard':
case 'offboard': {
const username = positionals[0]
const username = parsed.values.username
if (!username) {
logger.error('Missing required username argument')
process.exit(1)
}

if (command === 'onboard') {
await onboard(technicalOptions, { username, dryRun, org })
if (!parsed.values.team) {
logger.error('Missing required team argument for onboarding')
process.exit(1)
}

const joiningTeams = parsed.values.team
await onboard(technicalOptions, { username, dryRun, org, joiningTeams })
} else {
await offboard(technicalOptions, { username, dryRun, org })
}
Expand Down