From d66af46a24c54ebb68713d8486f3462782b60cd7 Mon Sep 17 00:00:00 2001 From: Marcus Ramberg Date: Fri, 16 Oct 2026 14:25:22 +0200 Subject: [PATCH] feat: allow specifying a team to sync all org members to We want this for giving everybody access to open PRs in shared repos like 'actions' or 'renovate-config' --- .env.example | 4 +- README.md | 4 ++ action.yml | 10 ++++ index.ts | 6 ++- src/config.ts | 6 +++ src/github.ts | 50 ++++++++++++++++++ tests/__snapshots__/github.spec.ts.snap | 43 +++++++++++++++ tests/github.spec.ts | 69 +++++++++++++++++++++++++ 8 files changed, 190 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 80cd645a8..5b34800ff 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,6 @@ GITHUB_ORG=myorg GITHUB_INSTALLATION_ID=123 GITHUB_APP_ID=123 GITHUB_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tXG5wcml2YXRla2V5Z29lc2hlcmVcbi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS1cbg== -IGNORED_USERS=user1,user2 \ No newline at end of file +IGNORED_USERS=user1,user2 +SYNC_TEAM=TRUE +GITHUB_TEAM_SLUG=remarkable \ No newline at end of file diff --git a/README.md b/README.md index 8a0054b90..c365c8408 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ jobs: github-installation-id: 12345 github-private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} ignored-users: user1,user2 + sync-team: 'true' + team-slug: 'remarkable' github-token: ${{ secrets.GITHUB_TOKEN }} github-actor: ${{ github.actor }} ``` @@ -141,3 +143,5 @@ updates: | `GITHUB_INSTALLATION_ID` | Github App Installation ID | `15627551` | `null` | | `GITHUB_PRIVATE_KEY` | Base64'd private key as downloaded from github application registration step | `Zm9vCg==` | `null` | | `IGNORED_USERS` | Comma separated list of user ids to totally ignore always, useful for owners of an org you don't want accidentally removed | `owner1,owner2` | `null` | +| `SYNC_TEAM` | Set to TRUE to ensure every org member is added to the team named in `GITHUB_TEAM_SLUG` | `TRUE` | `false` | +| `GITHUB_TEAM_SLUG` | Slug of the team every org member should be added to, e.g. to grant PR rights to shared repos | `remarkable` | `null` | diff --git a/action.yml b/action.yml index 9138845c6..5315c906b 100644 --- a/action.yml +++ b/action.yml @@ -38,6 +38,12 @@ inputs: ignored-users: description: 'Comma separated list of user ids to totally ignore always, useful for owners of an org you do not want accidentally removed' required: false + sync-team: + description: 'Set to TRUE to ensure every org member is a member of the team given in `team-slug`' + required: false + team-slug: + description: 'Slug of the team that every org member should be added to (e.g. remarkable)' + required: false runs: using: 'composite' steps: @@ -55,6 +61,8 @@ runs: -e GITHUB_INSTALLATION_ID="$GITHUB_INSTALLATION_ID" \ -e GITHUB_PRIVATE_KEY="$GITHUB_PRIVATE_KEY" \ -e IGNORED_USERS="$IGNORED_USERS" \ + -e SYNC_TEAM="$SYNC_TEAM" \ + -e GITHUB_TEAM_SLUG="$GITHUB_TEAM_SLUG" \ ghcr.io/remarkable/githubusermanager/githubusermanager:v1.4.0 shell: bash env: @@ -69,3 +77,5 @@ runs: GITHUB_INSTALLATION_ID: ${{ inputs.github-installation-id }} GITHUB_PRIVATE_KEY: ${{ inputs.github-private-key }} IGNORED_USERS: ${{ inputs.ignored-users }} + SYNC_TEAM: ${{ inputs.sync-team }} + GITHUB_TEAM_SLUG: ${{ inputs.team-slug }} diff --git a/index.ts b/index.ts index 64a586b0e..c3600b0ec 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,5 @@ import { getGithubUsersFromGoogle } from './src/google.js' -import { addUsersToGitHubOrg, getGithubUsersFromGithub, removeUsersFromGitHubOrg } from './src/github.js' +import { addUsersToGitHubOrg, getGithubUsersFromGithub, removeUsersFromGitHubOrg, syncTeamMembers } from './src/github.js' import { config } from './src/config.js' export async function run(): Promise { @@ -31,6 +31,10 @@ export async function run(): Promise { } } + if (config.syncTeam) { + await syncTeamMembers() + } + const exitCode = usersNotInGoogle.size > 0 || usersNotInGithub.size > 0 ? config.exitCodeOnMissmatch : 0 // oxlint-disable-next-line no-process-exit diff --git a/src/config.ts b/src/config.ts index 087b69d51..d2cdc097b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,6 +17,12 @@ export const config = { get githubPrivateKey(): string { return Buffer.from(process.env.GITHUB_PRIVATE_KEY, 'base64').toString('utf8') }, + get githubTeamSlug(): string { + return process.env.GITHUB_TEAM_SLUG ?? '' + }, + get syncTeam(): boolean { + return process.env.SYNC_TEAM?.toLowerCase() === 'true' + }, get googleCredentials(): googleCredentials { return JSON.parse(Buffer.from(process.env.GOOGLE_CREDENTIALS, 'base64').toString('utf8')) }, diff --git a/src/github.ts b/src/github.ts index 351e9cc56..739bb400c 100644 --- a/src/github.ts +++ b/src/github.ts @@ -74,6 +74,56 @@ export async function addUserToGitHubOrg( }) } +export async function getTeamMembers(): Promise> { + const octokit = mod.getAuthenticatedOctokit() + const members = await octokit.paginate(octokit.teams.listMembersInOrg, { + org: config.githubOrg, + team_slug: config.githubTeamSlug, + }) + return formatUserList(members) +} + +export async function addUsersToTeam(users: Set): Promise { + for (const user of users) { + await mod.addUserToTeam(user) + } +} + +export async function addUserToTeam( + user: string, +): Promise { + if (config.ignoredUsers.includes(user.toLowerCase())) { + console.log(`Ignoring team add for ${user}`) + return false + } + const octokit = mod.getAuthenticatedOctokit() + console.log(`Adding ${user} to team ${config.githubTeamSlug}`) + return octokit.teams.addOrUpdateMembershipForUserInOrg({ + org: config.githubOrg, + team_slug: config.githubTeamSlug, + username: user, + }) +} + +export async function syncTeamMembers(): Promise { + const octokit = mod.getAuthenticatedOctokit() + + const members = await octokit.paginate(octokit.orgs.listMembers, { + org: config.githubOrg, + }) + const orgMembers = formatUserList(members) + + const teamMembers = await mod.getTeamMembers() + + const usersNotInTeam = new Set([...orgMembers].filter((x) => !teamMembers.has(x))) + if (usersNotInTeam.size > 0) { + console.log(`Users not in team ${config.githubTeamSlug}: ${[...usersNotInTeam].join(', ')}`) + await mod.addUsersToTeam(usersNotInTeam) + } else { + console.log(`All org members already in team ${config.githubTeamSlug}`) + } +} + export async function removeUsersFromGitHubOrg(users: Set): Promise { for (const user of users) { await mod.removeUserFromGitHubOrg(user) diff --git a/tests/__snapshots__/github.spec.ts.snap b/tests/__snapshots__/github.spec.ts.snap index 28c0ab14f..afca1f161 100644 --- a/tests/__snapshots__/github.spec.ts.snap +++ b/tests/__snapshots__/github.spec.ts.snap @@ -19,6 +19,26 @@ exports[`github integration > addUserToGitHubOrg 1`] = ` } `; +exports[`github integration > addUserToTeam 1`] = ` +[MockFunction] { + "calls": [ + [ + { + "org": "myorg", + "team_slug": "remarkable", + "username": "foo", + }, + ], + ], + "results": [ + { + "type": "return", + "value": Promise {}, + }, + ], +} +`; + exports[`github integration > addUsersToGitHubOrg 1`] = ` [MockFunction addUserToGitHubOrg] { "calls": [ @@ -42,6 +62,29 @@ exports[`github integration > addUsersToGitHubOrg 1`] = ` } `; +exports[`github integration > addUsersToTeam 1`] = ` +[MockFunction addUserToTeam] { + "calls": [ + [ + "foo", + ], + [ + "bar", + ], + ], + "results": [ + { + "type": "return", + "value": Promise {}, + }, + { + "type": "return", + "value": Promise {}, + }, + ], +} +`; + exports[`github integration > getAuthenticatedOctokit 1`] = ` [MockFunction] { "calls": [ diff --git a/tests/github.spec.ts b/tests/github.spec.ts index d5ecf0db4..5ae1a7be2 100644 --- a/tests/github.spec.ts +++ b/tests/github.spec.ts @@ -106,4 +106,73 @@ describe('github integration', () => { const response = [{ login: 'chrisns' }, { login: 'chrisns' }, { login: 'foo' }, {}] return expect(mod.formatUserList(response)).toEqual(new Set(['chrisns', 'foo'])) }) + + it('addUserToTeam skip ignore', async () => { + vi.spyOn(config, 'ignoredUsers', 'get').mockReturnValue(['foo']) + await expect(mod.addUserToTeam('foo')).resolves.toBe(false) + }) + + it('addUserToTeam', async () => { + const fakeOctokit = { + teams: { + addOrUpdateMembershipForUserInOrg: vi.fn<() => Promise>().mockResolvedValue(true), + }, + } + vi.spyOn(config, 'githubOrg', 'get').mockReturnValue('myorg') + vi.spyOn(config, 'githubTeamSlug', 'get').mockReturnValue('remarkable') + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + await mod.addUserToTeam('foo') + return expect(fakeOctokit.teams.addOrUpdateMembershipForUserInOrg).toMatchSnapshot() + }) + + it('addUsersToTeam', async () => { + const users = new Set(['foo', 'bar']) + vi.spyOn(mod, 'addUserToTeam').mockResolvedValue(false) + await mod.addUsersToTeam(users) + return expect(mod.addUserToTeam).toMatchSnapshot() + }) + + it('getTeamMembers', async () => { + const fakeOctokit = { + teams: { listMembersInOrg: vi.fn() }, + paginate: vi + .fn<() => Promise<{ login: string }[]>>() + .mockResolvedValue([{ login: 'chrisns' }, { login: 'foo' }]), + } + vi.spyOn(config, 'githubTeamSlug', 'get').mockReturnValue('remarkable') + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + await expect(mod.getTeamMembers()).resolves.toEqual(new Set(['chrisns', 'foo'])) + }) + + it('syncTeamMembers adds org members missing from team', async () => { + const fakeOctokit = { + orgs: { listMembers: vi.fn() }, + paginate: vi + .fn<() => Promise<{ login: string }[]>>() + .mockResolvedValue([{ login: 'chrisns' }, { login: 'foo' }, { login: 'bar' }]), + } + vi.spyOn(config, 'githubTeamSlug', 'get').mockReturnValue('remarkable') + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + vi.spyOn(mod, 'getTeamMembers').mockResolvedValue(new Set(['chrisns'])) + const addSpy = vi.spyOn(mod, 'addUsersToTeam').mockResolvedValue() + await mod.syncTeamMembers() + expect(addSpy).toHaveBeenCalledWith(new Set(['foo', 'bar'])) + }) + + it('syncTeamMembers no-op when all present', async () => { + const fakeOctokit = { + orgs: { listMembers: vi.fn() }, + paginate: vi.fn<() => Promise<{ login: string }[]>>().mockResolvedValue([{ login: 'chrisns' }]), + } + vi.spyOn(config, 'githubTeamSlug', 'get').mockReturnValue('remarkable') + // @ts-expect-error mock service isn't a complete implementation, so being lazy and just doing the bare minimum + vi.spyOn(mod, 'getAuthenticatedOctokit').mockReturnValue(fakeOctokit) + vi.spyOn(mod, 'getTeamMembers').mockResolvedValue(new Set(['chrisns'])) + const addSpy = vi.spyOn(mod, 'addUsersToTeam').mockResolvedValue() + await mod.syncTeamMembers() + expect(addSpy).not.toHaveBeenCalled() + }) })