Skip to content
Closed
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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 15.12.0
node-version: 18.20.0

- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: node_modules
key: npm-${{ hashFiles('package-lock.json') }}
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
16.20.0
18.20.0
23 changes: 23 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ inputs:
github-actor:
description: github actor to use to pull the docker image github.actor is probably fine
required: true
slack-webhook-url:
description: 'Slack webhook URL for notifications'
required: false
slack-notify-on-error:
description: 'Send Slack notification when errors occur (default: true)'
required: false
default: 'true'
slack-notify-on-change:
description: 'Send Slack notification when membership changes are made'
required: false
default: 'false'
slack-notify-always:
description: 'Always send Slack notification regardless of changes'
required: false
default: 'false'
runs:
using: "composite"
steps:
Expand All @@ -63,6 +78,10 @@ runs:
-e GITHUB_INSTALLATION_ID="$GITHUB_INSTALLATION_ID" \
-e GITHUB_PRIVATE_KEY="$GITHUB_PRIVATE_KEY" \
-e IGNORED_USERS="$IGNORED_USERS" \
-e SLACK_WEBHOOK_URL="$SLACK_WEBHOOK_URL" \
-e SLACK_NOTIFY_ON_ERROR="$SLACK_NOTIFY_ON_ERROR" \
-e SLACK_NOTIFY_ON_CHANGE="$SLACK_NOTIFY_ON_CHANGE" \
-e SLACK_NOTIFY_ALWAYS="$SLACK_NOTIFY_ALWAYS" \
docker.pkg.github.com/appvia/githubusermanager/githubusermanager:v1.0.5
shell: bash
env:
Expand All @@ -76,4 +95,8 @@ runs:
GITHUB_INSTALLATION_ID: ${{ inputs.github-installation-id }}
GITHUB_PRIVATE_KEY: ${{ inputs.github-private-key }}
IGNORED_USERS: ${{ inputs.ignored-users }}
SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }}
SLACK_NOTIFY_ON_ERROR: ${{ inputs.slack-notify-on-error }}
SLACK_NOTIFY_ON_CHANGE: ${{ inputs.slack-notify-on-change }}
SLACK_NOTIFY_ALWAYS: ${{ inputs.slack-notify-always }}

41 changes: 37 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getGithubUsersFromGoogle } from './src/google'
import { getGithubUsersFromGithub, addUsersToGitHubOrg, removeUsersFromGitHubOrg } from './src/github'
import { getGithubUsersFromGithub, addUsersToGitHubOrg, removeUsersFromGitHubOrg, OperationError } from './src/github'
import { config } from './src/config'
import { notifySlack } from './src/slack'

export async function run(): Promise<void> {
const googleUsers = await getGithubUsersFromGoogle()
Expand All @@ -12,17 +13,49 @@ export async function run(): Promise<void> {
const usersNotInGithub = new Set(Array.from(googleUsers).filter((x) => !gitHubUsers.has(x)))

const usersNotInGoogle = new Set(Array.from(gitHubUsers).filter((x) => !googleUsers.has(x)))
let unfixedMismatch = false
const allErrors: OperationError[] = []
const addedUsers: string[] = []
const removedUsers: string[] = []

if (usersNotInGithub.size > 0) {
console.log(`Users not in github: ${Array.from(usersNotInGithub).join(', ')}`)
if (config.addUsers) await addUsersToGitHubOrg(usersNotInGithub)
if (config.addUsers) {
const result = await addUsersToGitHubOrg(usersNotInGithub)
addedUsers.push(...result.success)
if (result.errors.length > 0) {
allErrors.push(...result.errors)
}
} else {
unfixedMismatch = true
}
}

if (usersNotInGoogle.size > 0) {
console.log(`Users not in google: ${Array.from(usersNotInGoogle).join(', ')}`)
if (config.removeUsers) await removeUsersFromGitHubOrg(usersNotInGoogle)
if (config.removeUsers) {
const result = await removeUsersFromGitHubOrg(usersNotInGoogle)
removedUsers.push(...result.success)
if (result.errors.length > 0) {
allErrors.push(...result.errors)
}
} else {
unfixedMismatch = true
}
}

if (allErrors.length > 0) {
console.error(`\n--- ERRORS SUMMARY ---`)
for (const err of allErrors) {
console.error(`[${err.operation.toUpperCase()}] ${err.user}: ${err.message}`)
}
console.error(`Total errors: ${allErrors.length}`)
}

const exitCode = usersNotInGoogle.size > 0 || usersNotInGithub.size > 0 ? config.exitCodeOnMissmatch : 0
await notifySlack(addedUsers, removedUsers, allErrors, config.githubOrg)

const hasErrors = allErrors.length > 0
const exitCode = unfixedMismatch || hasErrors ? config.exitCodeOnMissmatch : 0

process.exit(exitCode)
}
Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,18 @@ export const config = {
get googleEmailAddress(): string {
return process.env.GOOGLE_EMAIL_ADDRESS ?? ''
},
get slackWebhookUrl(): string | undefined {
return process.env.SLACK_WEBHOOK_URL
},
get slackNotifyOnError(): boolean {
return process.env.SLACK_NOTIFY_ON_ERROR?.toLowerCase() !== 'false'
},
get slackNotifyOnChange(): boolean {
return process.env.SLACK_NOTIFY_ON_CHANGE?.toLowerCase() === 'true'
},
get slackNotifyAlways(): boolean {
return process.env.SLACK_NOTIFY_ALWAYS?.toLowerCase() === 'true'
},
}

export interface googleCredentials {
Expand Down
93 changes: 71 additions & 22 deletions src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,18 @@ import { createAppAuth } from '@octokit/auth-app'
import { Octokit } from '@octokit/rest'
import * as mod from './github'
import { config } from './config'
import { GetResponseTypeFromEndpointMethod } from '@octokit/types'

export interface OperationError {
user: string
operation: 'add' | 'remove'
message: string
status?: number
}

export interface OperationResult {
success: string[]
errors: OperationError[]
}

export function getAuthenticatedOctokit(): Octokit {
return new Octokit({
Expand Down Expand Up @@ -57,47 +68,85 @@ export async function getUserIdFromUsername(username: string): Promise<number> {
return user.data.id
}

export async function addUsersToGitHubOrg(users: Set<string>): Promise<void> {
export async function addUsersToGitHubOrg(users: Set<string>): Promise<OperationResult> {
const result: OperationResult = { success: [], errors: [] }
for (const user of users) {
await mod.addUserToGitHubOrg(user)
const outcome = await mod.addUserToGitHubOrg(user)
if (outcome === true) {
result.success.push(user)
} else if (outcome !== false && 'error' in outcome) {
result.errors.push(outcome.error)
}
}
return result
}

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function addUserToGitHubOrg(
user: string,
): Promise<GetResponseTypeFromEndpointMethod<typeof octokit.orgs.createInvitation> | boolean> {
export async function addUserToGitHubOrg(user: string): Promise<{ error: OperationError } | boolean> {
const octokit = mod.getAuthenticatedOctokit()
if (config.ignoredUsers.includes(user.toLowerCase())) {
console.log(`Ignoring add for ${user}`)
return false
}
const userId = await mod.getUserIdFromUsername(user)
console.log(`Inviting ${user} (${userId} to ${config.githubOrg})`)
return await octokit.orgs.createInvitation({
org: config.githubOrg,
invitee_id: userId,
})
try {
const userId = await mod.getUserIdFromUsername(user)
console.log(`Inviting ${user} (${userId}) to ${config.githubOrg}`)
await octokit.orgs.createInvitation({
org: config.githubOrg,
invitee_id: userId,
})
return true
} catch (error) {
const status = error?.status || error?.response?.status
let message = error?.message || String(error)
if (status === 422) {
message = `Validation failed: ${message} (user may already be invited, or org is at max capacity)`
} else if (status === 404) {
message = `User not found: ${user}`
} else if (status === 403) {
message = `Permission denied or rate limited`
}
console.error(`Error adding ${user}: ${message}`)
return { error: { user, operation: 'add', message, status } }
}
}

export async function removeUsersFromGitHubOrg(users: Set<string>): Promise<void> {
export async function removeUsersFromGitHubOrg(users: Set<string>): Promise<OperationResult> {
const result: OperationResult = { success: [], errors: [] }
for (const user of users) {
await mod.removeUserFromGitHubOrg(user)
const outcome = await mod.removeUserFromGitHubOrg(user)
if (outcome === true) {
result.success.push(user)
} else if (outcome !== false && 'error' in outcome) {
result.errors.push(outcome.error)
}
}
return result
}

// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export async function removeUserFromGitHubOrg(
user: string,
): Promise<GetResponseTypeFromEndpointMethod<typeof octokit.orgs.removeMembershipForUser> | boolean> {
export async function removeUserFromGitHubOrg(user: string): Promise<{ error: OperationError } | boolean> {
const octokit = mod.getAuthenticatedOctokit()
if (config.ignoredUsers.includes(user.toLowerCase())) {
console.log(`Ignoring remove for ${user}`)
return false
}
console.log(`Removing user/invitation ${user} from ${config.githubOrg}`)
return octokit.orgs.removeMembershipForUser({
org: config.githubOrg,
username: user,
})
try {
console.log(`Removing user/invitation ${user} from ${config.githubOrg}`)
await octokit.orgs.removeMembershipForUser({
org: config.githubOrg,
username: user,
})
return true
} catch (error) {
const status = error?.status || error?.response?.status
let message = error?.message || String(error)
if (status === 404) {
message = `User not found or not a member: ${user}`
} else if (status === 403) {
message = `Permission denied or rate limited`
}
console.error(`Error removing ${user}: ${message}`)
return { error: { user, operation: 'remove', message, status } }
}
}
4 changes: 3 additions & 1 deletion src/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export async function getGithubUsersFromGoogle(): Promise<Set<string>> {
customer: 'my_customer',
maxResults: 250,
projection: 'custom',
fields: 'users(customSchemas/Accounts/github(value))',
query: 'isSuspended=false',
fields: 'users(customSchemas/Accounts/github(value),suspended,archived)',
customFieldMask: 'Accounts',
})

Expand All @@ -43,6 +44,7 @@ export async function getGithubUsersFromGoogle(): Promise<Set<string>> {
export function formatUserList(users): Set<string> {
return new Set(
users
.filter((user) => !user.suspended && !user.archived)
.map((user) => user.customSchemas?.Accounts?.github?.map((account) => account.value?.toLowerCase()))
.flat()
.filter(Boolean),
Expand Down
Loading
Loading