From 2efa6c4ead0a13e9215b12957a6b3b9a40703b56 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Sun, 5 Oct 2025 15:36:51 +0200 Subject: [PATCH 1/3] feat: onboard user --- .env.example | 1 + README.md | 18 ++++++++++++- commands/emeritus.js | 2 +- commands/onboard.js | 40 +++++++++++++++++++++++----- commands/raw-data.js | 21 +++++++++++++++ github-api.js | 62 ++++++++++++++++++++++++++++++++++++++++++-- index.js | 14 +++++++--- 7 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 .env.example create mode 100644 commands/raw-data.js 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..d5c8a5a 100644 --- a/commands/onboard.js +++ b/commands/onboard.js @@ -1,19 +1,45 @@ +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 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.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) + } } } + +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/commands/raw-data.js b/commands/raw-data.js new file mode 100644 index 0000000..b890529 --- /dev/null +++ b/commands/raw-data.js @@ -0,0 +1,21 @@ +{ + user: 'zekth', + lastPR: 2025-03-13T21:29:08.000Z, + lastIssue: 2025-02-18T12:54:59.000Z, + lastCommit: 2024-09-26T07:00:00.000Z, + socialAccounts: [ + { + displayName: 'in/vincelg', + url: 'https://www.linkedin.com/in/vincelg/', + provider: 'LINKEDIN' + } + ] +} + + + + // { + // "state": "active", + // "role": "maintainer", + // "url": "https://api.github.com/organizations/24939410/team/2780234/memberships/Eomm" + // } 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 }) } From c1124f485094493e8910d0dd317c75ef06793892 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Sun, 5 Oct 2025 16:11:08 +0200 Subject: [PATCH 2/3] chore: npm logs --- commands/onboard.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/commands/onboard.js b/commands/onboard.js index d5c8a5a..28e279a 100644 --- a/commands/onboard.js +++ b/commands/onboard.js @@ -32,6 +32,20 @@ export default async function onboard ({ client, logger }, { org, username, join 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) { From 7b19f60d4a24c14fbb15ac79c999b4d6ebbe24d2 Mon Sep 17 00:00:00 2001 From: Manuel Spigolon Date: Sun, 5 Oct 2025 16:13:10 +0200 Subject: [PATCH 3/3] cleaning --- commands/raw-data.js | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 commands/raw-data.js diff --git a/commands/raw-data.js b/commands/raw-data.js deleted file mode 100644 index b890529..0000000 --- a/commands/raw-data.js +++ /dev/null @@ -1,21 +0,0 @@ -{ - user: 'zekth', - lastPR: 2025-03-13T21:29:08.000Z, - lastIssue: 2025-02-18T12:54:59.000Z, - lastCommit: 2024-09-26T07:00:00.000Z, - socialAccounts: [ - { - displayName: 'in/vincelg', - url: 'https://www.linkedin.com/in/vincelg/', - provider: 'LINKEDIN' - } - ] -} - - - - // { - // "state": "active", - // "role": "maintainer", - // "url": "https://api.github.com/organizations/24939410/team/2780234/memberships/Eomm" - // }