diff --git a/.github/workflows/team.yml b/.github/workflows/team.yml new file mode 100644 index 000000000000..099ea882aa7c --- /dev/null +++ b/.github/workflows/team.yml @@ -0,0 +1,81 @@ +name: Teams + +on: + schedule: + # Every Tuesday at 19:42 (randomly chosen) + - cron: '42 19 * * 1' + + # Allows manual trigger + workflow_dispatch: + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + sync: + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + id: team-token + with: + app-id: ${{ vars.OWNER_APP_ID }} + private-key: ${{ secrets.OWNER_APP_PRIVATE_KEY }} + permission-administration: read + permission-members: read + - name: Fetch source + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + sparse-checkout: | + ci/github-script + maintainers/github-teams.json + - name: Install dependencies + run: npm install bottleneck + - name: Synchronise teams + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.team-token.outputs.token }} + script: | + require('./ci/github-script/get-teams.js')({ + github, + context, + core, + outFile: "maintainers/github-teams.json" + }) + + # Use a GitHub App to create the PR so that CI gets triggered + - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + id: sync-token + with: + app-id: ${{ vars.NIXPKGS_CI_APP_ID }} + private-key: ${{ secrets.NIXPKGS_CI_APP_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - name: Get GitHub App User Git String + id: user + env: + GH_TOKEN: ${{ steps.sync-token.outputs.token }} + APP_SLUG: ${{ steps.sync-token.outputs.app-slug }} + run: | + name="${APP_SLUG}[bot]" + userId=$(gh api "/users/$name" --jq .id) + email="$userId+$name@users.noreply.github.com" + echo "git-string=$name <$email>" >> "$GITHUB_OUTPUT" + - name: Create Pull Request + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + token: ${{ steps.sync-token.outputs.token }} + add-paths: maintainers/github-teams.json + author: ${{ steps.user.outputs.git-string }} + committer: ${{ steps.user.outputs.git-string }} + commit-message: "maintainers/github-teams.json: Automated sync" + branch: github-team-sync + title: "maintainers/github-teams.json: Automated sync" + body: | + This is an automated PR to sync the GitHub teams with access to this repository to the `lib.teams` list. + + This PR can be merged without taking any further action. + diff --git a/ci/github-script/get-teams.js b/ci/github-script/get-teams.js new file mode 100755 index 000000000000..9b6d3333f91f --- /dev/null +++ b/ci/github-script/get-teams.js @@ -0,0 +1,82 @@ +const excludeTeams = [ + /^voters.*$/, + /^nixpkgs-maintainers$/, + /^nixpkgs-committers$/, +] + +module.exports = async ({ github, context, core, outFile }) => { + const withRateLimit = require('./withRateLimit.js') + const { writeFileSync } = require('node:fs') + const result = {} + await withRateLimit({ github, core }, async (_stats) => { + /// Turn an Array of users into an Object, mapping user.login -> user.id + function makeUserSet(users) { + // Sort in-place and build result by mutation + users.sort((a, b) => (a.login > b.login ? 1 : -1)) + + return users.reduce((acc, user) => { + acc[user.login] = user.id + return acc + }, {}) + } + + /// Process a list of teams and append to the result variable + async function processTeams(teams) { + for (const team of teams) { + core.notice(`Processing team ${team.slug}`) + if (!excludeTeams.some((regex) => team.slug.match(regex))) { + const members = makeUserSet( + await github.paginate(github.rest.teams.listMembersInOrg, { + org: context.repo.owner, + team_slug: team.slug, + role: 'member', + }), + ) + const maintainers = makeUserSet( + await github.paginate(github.rest.teams.listMembersInOrg, { + org: context.repo.owner, + team_slug: team.slug, + role: 'maintainer', + }), + ) + result[team.slug] = { + description: team.description, + id: team.id, + maintainers, + members, + name: team.name, + } + } + await processTeams( + await github.paginate(github.rest.teams.listChildInOrg, { + org: context.repo.owner, + team_slug: team.slug, + }), + ) + } + } + + const teams = await github.paginate(github.rest.repos.listTeams, { + owner: context.repo.owner, + repo: context.repo.repo, + }) + + await processTeams(teams) + }) + + // Sort the teams by team name + const sorted = Object.keys(result) + .sort() + .reduce((acc, key) => { + acc[key] = result[key] + return acc + }, {}) + + const json = `${JSON.stringify(sorted, null, 2)}\n` + + if (outFile) { + writeFileSync(outFile, json) + } else { + console.log(json) + } +} diff --git a/ci/github-script/run b/ci/github-script/run index 1d974cf5355f..2d5121b75b7e 100755 --- a/ci/github-script/run +++ b/ci/github-script/run @@ -83,4 +83,16 @@ program } }) +program + .command('get-teams') + .description('Fetch the list of teams with GitHub and output it to a file') + .argument('', 'Owner of the GitHub repository to label (Example: NixOS)') + .argument('', 'Name of the GitHub repository to label (Example: nixpkgs)') + .argument('[outFile]', 'Path to the output file (Example: github-teams.json). If not set, prints to stdout') + .action(async (owner, repo, outFile, options) => { + const getTeams = (await import('./get-teams.js')).default + // TODO: Refactor this file so we don't need to pass a PR + await run(getTeams, owner, repo, undefined, { ...options, outFile }) + }) + await program.parse()