Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
58 changes: 50 additions & 8 deletions commands/onboard.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,61 @@
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
* @param {{ org: string, username: string, joiningTeams:Set, 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 orgData = await client.getOrgData(org)
logger.info('Organization ID %s', orgData.id)

const orgChart = await client.getOrgChart(org)
const orgTeams = await client.getOrgChart(orgData)
const destinationTeams = orgTeams.filter(t => joiningTeams.has(t.slug))

const teamSlugs = new Set(orgTeams.map(t => t.slug))
const wrongInputTeams = joiningTeams.difference(teamSlugs)
if (wrongInputTeams.size) {
logger.error('Team %s not found in organization %s', [...wrongInputTeams], 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, [...joiningTeams])
} 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 = new Set(parsed.values.team)
await onboard(technicalOptions, { username, dryRun, org, joiningTeams })
} else {
await offboard(technicalOptions, { username, dryRun, org })
}
Expand Down