Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ GITHUB_ORG=myorg
GITHUB_INSTALLATION_ID=123
GITHUB_APP_ID=123
GITHUB_PRIVATE_KEY=LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tXG5wcml2YXRla2V5Z29lc2hlcmVcbi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS1cbg==
IGNORED_USERS=user1,user2
IGNORED_USERS=user1,user2
SYNC_TEAM=TRUE
GITHUB_TEAM_SLUG=remarkable
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
```
Expand Down Expand Up @@ -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` |
10 changes: 10 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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 }}
6 changes: 5 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
Expand Down Expand Up @@ -31,6 +31,10 @@ export async function run(): Promise<void> {
}
}

if (config.syncTeam) {
await syncTeamMembers()
}

const exitCode = usersNotInGoogle.size > 0 || usersNotInGithub.size > 0 ? config.exitCodeOnMissmatch : 0

// oxlint-disable-next-line no-process-exit
Expand Down
6 changes: 6 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
},
Expand Down
50 changes: 50 additions & 0 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,56 @@ export async function addUserToGitHubOrg(
})
}

export async function getTeamMembers(): Promise<Set<string>> {
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<string>): Promise<void> {
for (const user of users) {
await mod.addUserToTeam(user)
}
}

export async function addUserToTeam(
user: string,
): Promise<RestEndpointMethodTypes['teams']['addOrUpdateMembershipForUserInOrg']['response'] | boolean> {
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<void> {
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<string>): Promise<void> {
for (const user of users) {
await mod.removeUserFromGitHubOrg(user)
Expand Down
43 changes: 43 additions & 0 deletions tests/__snapshots__/github.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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": [
Expand Down
69 changes: 69 additions & 0 deletions tests/github.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>>().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()
})
})
Loading