diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b04f9f9..f5ca1ac 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,2 @@ +github-teams/TEAMS/community-plugins-teams.yaml @backstage/community-plugins-maintainers * @backstage/reviewers diff --git a/github-teams/.gitignore b/github-teams/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/github-teams/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/github-teams/README.md b/github-teams/README.md new file mode 100644 index 0000000..065db09 --- /dev/null +++ b/github-teams/README.md @@ -0,0 +1,32 @@ +# Backstage GitHub Team Sync + +A script to sync GitHub teams and their members based on a YAML config file. + +## šŸ“¦ Requirements + +- Node.js v18+ +- A GitHub personal access token (set as `GITHUB_TOKEN` env var) + +## šŸš€ Usage + +```bash +node team-sync --config path/to/teams.yaml +``` + +Optional dry-run mode: + +```bash +node team-sync.js --config path/to/teams.yaml --dry-run +``` + +## šŸ› ļø Example Config (`teams.yaml`) + +```yaml +teams: + - name: community-plugins-$plugin + description: Maintainers of $plugin + parent: community-plugins-owners + members: + - GitHubUserA + - GitHubUserB +``` diff --git a/github-teams/TEAMS/community-plugins-teams.yaml b/github-teams/TEAMS/community-plugins-teams.yaml new file mode 100644 index 0000000..c66631b --- /dev/null +++ b/github-teams/TEAMS/community-plugins-teams.yaml @@ -0,0 +1,10 @@ +teams: + - name: community-plugins-rbac + parent: community-plugin-owners + description: Maintainers of community RBAC plugin + members: + - "AndrienkoAleksandr" + - "christoph-jerolimov" + - "divyanshiGupta" + - "PatAKnight" + - "dzemanov" diff --git a/github-teams/package.json b/github-teams/package.json new file mode 100644 index 0000000..02c10d5 --- /dev/null +++ b/github-teams/package.json @@ -0,0 +1,18 @@ +{ + "name": "team-sync", + "version": "0.1.0", + "description": "sync Backstage GitHub teams with YAML config", + "private": true, + "main": "team-sync.js", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/backstage/community.git" + }, + "license": "Apache-2.0", + "dependencies": { + "@octokit/rest": "^18.0.0", + "js-yaml": "^4.1.0" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" +} diff --git a/github-teams/team-sync.js b/github-teams/team-sync.js new file mode 100644 index 0000000..dc4b3ec --- /dev/null +++ b/github-teams/team-sync.js @@ -0,0 +1,177 @@ +#!/usr/bin/env node +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import fs from 'fs'; +import path from 'path'; +import yaml from 'js-yaml'; +import { Octokit } from '@octokit/rest'; +import { parseArgs } from 'node:util'; + +const { + values, +} = parseArgs({ + options: { + config: { type: 'string', short: 'c'}, + 'dry-run': { type: 'boolean', short: 'd', default: false }, + }, + strict: true, +}); + +const config = values.config; +const dryRun = values['dry-run']; + +if (!config) { + console.error('āŒ Missing required --config '); + process.exit(1); +} + +const configPath = path.resolve(config); +const GITHUB_ORG = 'backstage'; +const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); + +// read and parse the YAML config file +function readConfig(filePath) { + if (!fs.existsSync(filePath)) { + console.error(`āŒ Config file not found at: ${filePath}`); + process.exit(1); + } + + try { + const raw = fs.readFileSync(filePath, 'utf8'); + return yaml.load(raw); + } catch (err) { + console.error(`āŒ Failed to parse YAML: ${err.message}`); + process.exit(1); + } +} + +// fetch the id of the parent team +async function getTeamIdBySlug(slug) { + try { + const res = await octokit.rest.teams.getByName({ org: GITHUB_ORG, team_slug: slug }); + return res.data.id; + } catch (err) { + if (err.status === 404) { + console.warn(`āš ļø Parent team "${slug}" does not exist yet.`); + return null; + } + console.error(`āŒ Failed to get team ID for "${slug}": ${err.message}`); + return null; + } +} + +// fetch actual team members from GitHub +async function getActualMembers(teamSlug) { + try { + const res = await octokit.rest.teams.listMembersInOrg({ + org: GITHUB_ORG, + team_slug: teamSlug, + }); + return res.data.map(member => member.login); + } catch (err) { + if (err.status === 404) return []; + console.error(`āŒ Error fetching members of ${teamSlug}: ${err.message}`); + return []; + } +} + +// creates subteam if it does not exist +async function ensureTeamExists(team, parentTeamId) { + try { + await octokit.rest.teams.getByName({ org: GITHUB_ORG, team_slug: team.name }); + } catch (err) { + if (err.status === 404) { + if (dryRun) { + console.log(` šŸ› ļø (dry-run) Would create team "${team.name}"${team.parent ? ` under "${team.parent}"` : ''}`); + } else { + await octokit.rest.teams.create({ + org: GITHUB_ORG, + name: team.name, + description: team.description, + parent_team_id: parentTeamId || undefined, + }); + console.log(` šŸ› ļø Created team "${team.name}"`); + } + } else { + console.error(`āŒ Failed to check team "${team.name}": ${err.message}`); + } + } +} + +// syncs team members with the config +async function syncTeams(config) { + for (const team of config.teams) { + const teamSlug = team.name; + const expected = team.members || []; + + let parentTeamId = null; + if (team.parent) { + parentTeamId = await getTeamIdBySlug(team.parent); + } + + console.log(`\nšŸ”§ Syncing team "${teamSlug}":`); + await ensureTeamExists(team, parentTeamId); + + const actual = await getActualMembers(teamSlug); + + for (const user of expected) { + if (!actual.includes(user)) { + if (dryRun) { + console.log(` āž• (dry-run) Would add "${user}"`); + } else { + await octokit.rest.teams.addOrUpdateMembershipForUserInOrg({ + org: GITHUB_ORG, + team_slug: teamSlug, + username: user, + role: 'member', + }); + console.log(` āž• Added "${user}"`); + } + } else { + console.log(` āœ… "${user}" already a member`); + } + } + + const extras = actual.filter(user => !expected.includes(user)); + for (const user of extras) { + if (dryRun) { + console.log(` āž– (dry-run) Would remove "${user}"`); + } else { + await octokit.rest.teams.removeMembershipForUserInOrg({ + org: GITHUB_ORG, + team_slug: teamSlug, + username: user, + }); + console.log(` āž– Removed "${user}"`); + } + } + } + + console.log(`\nāœ… Team sync complete${dryRun ? ' (dry-run mode)' : ''}.`); +} + +async function main() { + if (!dryRun && !process.env.GITHUB_TOKEN) { + console.error('āŒ GITHUB_TOKEN environment variable is required.'); + process.exit(1); + } + + console.log(`šŸ“„ Using config: ${configPath}`); + const config = readConfig(configPath); + await syncTeams(config); +} + +main(); diff --git a/github-teams/yarn.lock b/github-teams/yarn.lock new file mode 100644 index 0000000..6ca7595 --- /dev/null +++ b/github-teams/yarn.lock @@ -0,0 +1,173 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@octokit/auth-token@^2.4.4": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.5.0.tgz#27c37ea26c205f28443402477ffd261311f21e36" + integrity sha512-r5FVUJCOLl19AxiuZD2VRZ/ORjp/4IN98Of6YJoJOkY75CIBuYfmiNHGrDwXr+aLGG55igl9QrxX3hbiXlLb+g== + dependencies: + "@octokit/types" "^6.0.3" + +"@octokit/core@^3.5.1": + version "3.6.0" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-3.6.0.tgz#3376cb9f3008d9b3d110370d90e0a1fcd5fe6085" + integrity sha512-7RKRKuA4xTjMhY+eG3jthb3hlZCsOwg3rztWh75Xc+ShDWOfDDATWbeZpAHBNRpm4Tv9WgBMOy1zEJYXG6NJ7Q== + dependencies: + "@octokit/auth-token" "^2.4.4" + "@octokit/graphql" "^4.5.8" + "@octokit/request" "^5.6.3" + "@octokit/request-error" "^2.0.5" + "@octokit/types" "^6.0.3" + before-after-hook "^2.2.0" + universal-user-agent "^6.0.0" + +"@octokit/endpoint@^6.0.1": + version "6.0.12" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.12.tgz#3b4d47a4b0e79b1027fb8d75d4221928b2d05658" + integrity sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA== + dependencies: + "@octokit/types" "^6.0.3" + is-plain-object "^5.0.0" + universal-user-agent "^6.0.0" + +"@octokit/graphql@^4.5.8": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-4.8.0.tgz#664d9b11c0e12112cbf78e10f49a05959aa22cc3" + integrity sha512-0gv+qLSBLKF0z8TKaSKTsS39scVKF9dbMxJpj3U0vC7wjNWFuIpL/z76Qe2fiuCbDRcJSavkXsVtMS6/dtQQsg== + dependencies: + "@octokit/request" "^5.6.0" + "@octokit/types" "^6.0.3" + universal-user-agent "^6.0.0" + +"@octokit/openapi-types@^12.11.0": + version "12.11.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-12.11.0.tgz#da5638d64f2b919bca89ce6602d059f1b52d3ef0" + integrity sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ== + +"@octokit/plugin-paginate-rest@^2.16.8": + version "2.21.3" + resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.21.3.tgz#7f12532797775640dbb8224da577da7dc210c87e" + integrity sha512-aCZTEf0y2h3OLbrgKkrfFdjRL6eSOo8komneVQJnYecAxIej7Bafor2xhuDJOIFau4pk0i/P28/XgtbyPF0ZHw== + dependencies: + "@octokit/types" "^6.40.0" + +"@octokit/plugin-request-log@^1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85" + integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA== + +"@octokit/plugin-rest-endpoint-methods@^5.12.0": + version "5.16.2" + resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-5.16.2.tgz#7ee8bf586df97dd6868cf68f641354e908c25342" + integrity sha512-8QFz29Fg5jDuTPXVtey05BLm7OB+M8fnvE64RNegzX7U+5NUXcOcnpTIK0YfSHBg8gYd0oxIq3IZTe9SfPZiRw== + dependencies: + "@octokit/types" "^6.39.0" + deprecation "^2.3.1" + +"@octokit/request-error@^2.0.5", "@octokit/request-error@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677" + integrity sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg== + dependencies: + "@octokit/types" "^6.0.3" + deprecation "^2.0.0" + once "^1.4.0" + +"@octokit/request@^5.6.0", "@octokit/request@^5.6.3": + version "5.6.3" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.6.3.tgz#19a022515a5bba965ac06c9d1334514eb50c48b0" + integrity sha512-bFJl0I1KVc9jYTe9tdGGpAMPy32dLBXXo1dS/YwSCTL/2nd9XeHsY616RE3HPXDVk+a+dBuzyz5YdlXwcDTr2A== + dependencies: + "@octokit/endpoint" "^6.0.1" + "@octokit/request-error" "^2.1.0" + "@octokit/types" "^6.16.1" + is-plain-object "^5.0.0" + node-fetch "^2.6.7" + universal-user-agent "^6.0.0" + +"@octokit/rest@^18.0.0": + version "18.12.0" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-18.12.0.tgz#f06bc4952fc87130308d810ca9d00e79f6988881" + integrity sha512-gDPiOHlyGavxr72y0guQEhLsemgVjwRePayJ+FcKc2SJqKUbxbkvf5kAZEWA/MKvsfYlQAMVzNJE3ezQcxMJ2Q== + dependencies: + "@octokit/core" "^3.5.1" + "@octokit/plugin-paginate-rest" "^2.16.8" + "@octokit/plugin-request-log" "^1.0.4" + "@octokit/plugin-rest-endpoint-methods" "^5.12.0" + +"@octokit/types@^6.0.3", "@octokit/types@^6.16.1", "@octokit/types@^6.39.0", "@octokit/types@^6.40.0": + version "6.41.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.41.0.tgz#e58ef78d78596d2fb7df9c6259802464b5f84a04" + integrity sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg== + dependencies: + "@octokit/openapi-types" "^12.11.0" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +before-after-hook@^2.2.0: + version "2.2.3" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.3.tgz#c51e809c81a4e354084422b9b26bad88249c517c" + integrity sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ== + +deprecation@^2.0.0, deprecation@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" + integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== + +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +node-fetch@^2.6.7: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + +once@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +universal-user-agent@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.1.tgz#15f20f55da3c930c57bddbf1734c6654d5fd35aa" + integrity sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ== + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==