diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3cddd8b --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +GITHUB_TOKEN=YOUR_GITHUB_TOKEN_HERE \ No newline at end of file diff --git a/README.md b/README.md index 98c8ab4..00258e0 100644 --- a/README.md +++ b/README.md @@ -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 --username --team --team [--dryRun] +``` + +For the fastify organization, the command would look like: + +```bash +node --env-file=.env index.js onboard --username --team collaborators --team plugins --team website --team frontend +``` ### Offboard a user @@ -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 [--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). diff --git a/commands/emeritus.js b/commands/emeritus.js index ed1ee99..47480f7 100644 --- a/commands/emeritus.js +++ b/commands/emeritus.js @@ -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( diff --git a/commands/onboard.js b/commands/onboard.js index 89d2bb6..28e279a 100644 --- a/commands/onboard.js +++ b/commands/onboard.js @@ -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} */ -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) + 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' } diff --git a/github-api.js b/github-api.js index 78848ae..b1199a6 100644 --- a/github-api.js +++ b/github-api.js @@ -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 of team objects with their members and details. + */ async getOrgChart (orgData) { let cursor = null let hasNextPage = true @@ -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. @@ -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') @@ -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, @@ -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} 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. + */ diff --git a/index.js b/index.js index 9f90e60..cc9184a 100644 --- a/index.js +++ b/index.js @@ -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' }, }, @@ -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 @@ -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 }) }