Merge pull request #216 from BethGriggs/yaml-team-management

feat: sync GitHub teams from config via API with dry-run support
This commit is contained in:
Vincenzo Scamporlino 2025-07-01 20:14:25 +02:00 committed by GitHub
commit 7484bf4711
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 412 additions and 0 deletions

1
.github/CODEOWNERS vendored
View File

@ -1 +1,2 @@
github-teams/TEAMS/community-plugins-teams.yaml @backstage/community-plugins-maintainers
* @backstage/reviewers

1
github-teams/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules/

32
github-teams/README.md Normal file
View File

@ -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
```

View File

@ -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"

18
github-teams/package.json Normal file
View File

@ -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"
}

177
github-teams/team-sync.js Normal file
View File

@ -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 <path/to/file.yaml>');
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();

173
github-teams/yarn.lock Normal file
View File

@ -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==