Skip to content

Commit 34dfc23

Browse files
authored
fix(SA-675): Google GitHub sync fixes (#787)
* fix(SA-675): filter out suspended and archived Google users - Add query parameter to filter suspended users at API level - Add secondary filter in formatUserList for archived users - Include suspended/archived fields in API response - Add tests for suspended/archived user filtering * fix(SA-675): exit 0 when membership changes are successfully applied Previously, any mismatch would trigger non-zero exit even when ADD_USERS and REMOVE_USERS were enabled and changes were made. Now only exits non-zero when mismatch exists AND changes were not configured to be applied (dry-run mode). * feat(SA-675): add error handling and surface API errors - Add OperationError interface to track individual failures - Add OperationResult interface to collect successes and errors - Wrap GitHub API calls in try/catch blocks - Parse specific error codes (422, 403, 404) with helpful messages - Surface max user count errors (422) clearly - Collect all errors and display summary at end of run - Exit with non-zero when errors occur * feat(SA-675): add Slack notifications for membership changes - Add slack.ts module with notification functionality - Add config options: SLACK_WEBHOOK_URL, SLACK_NOTIFY_ON_ERROR, SLACK_NOTIFY_ON_CHANGE, SLACK_NOTIFY_ALWAYS - Update action.yml with new Slack inputs - Send notifications on errors (default), changes (opt-in), or always - Format messages with users added, removed, and any errors * chore(SA-675): update Node.js to LTS 18.20.0 Node 15.12.0 was EOL. Updated to Node 18 LTS for security and compatibility. Major npm package updates (octokit, jest, eslint) require careful testing due to breaking API changes and should be addressed in a follow-up PR. * chore(SA-675): fix lint warnings in test files - Remove unused slack import from index.spec.ts - Remove unused consoleSpy variable from slack.spec.ts - Add eslint-disable comments for necessary any types in test mocks * chore(SA-675): update deprecated GitHub Actions to v4 - actions/checkout: v3.5.2 → v4.2.2 - actions/setup-node: v3.6.0 → v4.1.0 - actions/cache: v3.3.1 → v4.2.3 Fixes CI failure due to deprecated actions/cache version being blocked by GitHub as of Dec 2024.
1 parent 08ab2ed commit 34dfc23

File tree

14 files changed

+636
-105
lines changed

14 files changed

+636
-105
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ jobs:
1414
runs-on: ubuntu-latest
1515

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

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

23-
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1
23+
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
2424
with:
2525
path: node_modules
2626
key: npm-${{ hashFiles('package-lock.json') }}

.nvmrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
16.20.0
1+
18.20.0

action.yml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,21 @@ inputs:
4141
github-actor:
4242
description: github actor to use to pull the docker image github.actor is probably fine
4343
required: true
44+
slack-webhook-url:
45+
description: 'Slack webhook URL for notifications'
46+
required: false
47+
slack-notify-on-error:
48+
description: 'Send Slack notification when errors occur (default: true)'
49+
required: false
50+
default: 'true'
51+
slack-notify-on-change:
52+
description: 'Send Slack notification when membership changes are made'
53+
required: false
54+
default: 'false'
55+
slack-notify-always:
56+
description: 'Always send Slack notification regardless of changes'
57+
required: false
58+
default: 'false'
4459
runs:
4560
using: "composite"
4661
steps:
@@ -63,6 +78,10 @@ runs:
6378
-e GITHUB_INSTALLATION_ID="$GITHUB_INSTALLATION_ID" \
6479
-e GITHUB_PRIVATE_KEY="$GITHUB_PRIVATE_KEY" \
6580
-e IGNORED_USERS="$IGNORED_USERS" \
81+
-e SLACK_WEBHOOK_URL="$SLACK_WEBHOOK_URL" \
82+
-e SLACK_NOTIFY_ON_ERROR="$SLACK_NOTIFY_ON_ERROR" \
83+
-e SLACK_NOTIFY_ON_CHANGE="$SLACK_NOTIFY_ON_CHANGE" \
84+
-e SLACK_NOTIFY_ALWAYS="$SLACK_NOTIFY_ALWAYS" \
6685
docker.pkg.github.com/appvia/githubusermanager/githubusermanager:v1.0.5
6786
shell: bash
6887
env:
@@ -76,4 +95,8 @@ runs:
7695
GITHUB_INSTALLATION_ID: ${{ inputs.github-installation-id }}
7796
GITHUB_PRIVATE_KEY: ${{ inputs.github-private-key }}
7897
IGNORED_USERS: ${{ inputs.ignored-users }}
98+
SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }}
99+
SLACK_NOTIFY_ON_ERROR: ${{ inputs.slack-notify-on-error }}
100+
SLACK_NOTIFY_ON_CHANGE: ${{ inputs.slack-notify-on-change }}
101+
SLACK_NOTIFY_ALWAYS: ${{ inputs.slack-notify-always }}
79102

index.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getGithubUsersFromGoogle } from './src/google'
2-
import { getGithubUsersFromGithub, addUsersToGitHubOrg, removeUsersFromGitHubOrg } from './src/github'
2+
import { getGithubUsersFromGithub, addUsersToGitHubOrg, removeUsersFromGitHubOrg, OperationError } from './src/github'
33
import { config } from './src/config'
4+
import { notifySlack } from './src/slack'
45

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

1415
const usersNotInGoogle = new Set(Array.from(gitHubUsers).filter((x) => !googleUsers.has(x)))
16+
let unfixedMismatch = false
17+
const allErrors: OperationError[] = []
18+
const addedUsers: string[] = []
19+
const removedUsers: string[] = []
20+
1521
if (usersNotInGithub.size > 0) {
1622
console.log(`Users not in github: ${Array.from(usersNotInGithub).join(', ')}`)
17-
if (config.addUsers) await addUsersToGitHubOrg(usersNotInGithub)
23+
if (config.addUsers) {
24+
const result = await addUsersToGitHubOrg(usersNotInGithub)
25+
addedUsers.push(...result.success)
26+
if (result.errors.length > 0) {
27+
allErrors.push(...result.errors)
28+
}
29+
} else {
30+
unfixedMismatch = true
31+
}
1832
}
1933

2034
if (usersNotInGoogle.size > 0) {
2135
console.log(`Users not in google: ${Array.from(usersNotInGoogle).join(', ')}`)
22-
if (config.removeUsers) await removeUsersFromGitHubOrg(usersNotInGoogle)
36+
if (config.removeUsers) {
37+
const result = await removeUsersFromGitHubOrg(usersNotInGoogle)
38+
removedUsers.push(...result.success)
39+
if (result.errors.length > 0) {
40+
allErrors.push(...result.errors)
41+
}
42+
} else {
43+
unfixedMismatch = true
44+
}
45+
}
46+
47+
if (allErrors.length > 0) {
48+
console.error(`\n--- ERRORS SUMMARY ---`)
49+
for (const err of allErrors) {
50+
console.error(`[${err.operation.toUpperCase()}] ${err.user}: ${err.message}`)
51+
}
52+
console.error(`Total errors: ${allErrors.length}`)
2353
}
2454

25-
const exitCode = usersNotInGoogle.size > 0 || usersNotInGithub.size > 0 ? config.exitCodeOnMissmatch : 0
55+
await notifySlack(addedUsers, removedUsers, allErrors, config.githubOrg)
56+
57+
const hasErrors = allErrors.length > 0
58+
const exitCode = unfixedMismatch || hasErrors ? config.exitCodeOnMissmatch : 0
2659

2760
process.exit(exitCode)
2861
}

src/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ export const config = {
2929
get googleEmailAddress(): string {
3030
return process.env.GOOGLE_EMAIL_ADDRESS ?? ''
3131
},
32+
get slackWebhookUrl(): string | undefined {
33+
return process.env.SLACK_WEBHOOK_URL
34+
},
35+
get slackNotifyOnError(): boolean {
36+
return process.env.SLACK_NOTIFY_ON_ERROR?.toLowerCase() !== 'false'
37+
},
38+
get slackNotifyOnChange(): boolean {
39+
return process.env.SLACK_NOTIFY_ON_CHANGE?.toLowerCase() === 'true'
40+
},
41+
get slackNotifyAlways(): boolean {
42+
return process.env.SLACK_NOTIFY_ALWAYS?.toLowerCase() === 'true'
43+
},
3244
}
3345

3446
export interface googleCredentials {

src/github.ts

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,18 @@ import { createAppAuth } from '@octokit/auth-app'
22
import { Octokit } from '@octokit/rest'
33
import * as mod from './github'
44
import { config } from './config'
5-
import { GetResponseTypeFromEndpointMethod } from '@octokit/types'
5+
6+
export interface OperationError {
7+
user: string
8+
operation: 'add' | 'remove'
9+
message: string
10+
status?: number
11+
}
12+
13+
export interface OperationResult {
14+
success: string[]
15+
errors: OperationError[]
16+
}
617

718
export function getAuthenticatedOctokit(): Octokit {
819
return new Octokit({
@@ -57,47 +68,85 @@ export async function getUserIdFromUsername(username: string): Promise<number> {
5768
return user.data.id
5869
}
5970

60-
export async function addUsersToGitHubOrg(users: Set<string>): Promise<void> {
71+
export async function addUsersToGitHubOrg(users: Set<string>): Promise<OperationResult> {
72+
const result: OperationResult = { success: [], errors: [] }
6173
for (const user of users) {
62-
await mod.addUserToGitHubOrg(user)
74+
const outcome = await mod.addUserToGitHubOrg(user)
75+
if (outcome === true) {
76+
result.success.push(user)
77+
} else if (outcome !== false && 'error' in outcome) {
78+
result.errors.push(outcome.error)
79+
}
6380
}
81+
return result
6482
}
6583

6684
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
67-
export async function addUserToGitHubOrg(
68-
user: string,
69-
): Promise<GetResponseTypeFromEndpointMethod<typeof octokit.orgs.createInvitation> | boolean> {
85+
export async function addUserToGitHubOrg(user: string): Promise<{ error: OperationError } | boolean> {
7086
const octokit = mod.getAuthenticatedOctokit()
7187
if (config.ignoredUsers.includes(user.toLowerCase())) {
7288
console.log(`Ignoring add for ${user}`)
7389
return false
7490
}
75-
const userId = await mod.getUserIdFromUsername(user)
76-
console.log(`Inviting ${user} (${userId} to ${config.githubOrg})`)
77-
return await octokit.orgs.createInvitation({
78-
org: config.githubOrg,
79-
invitee_id: userId,
80-
})
91+
try {
92+
const userId = await mod.getUserIdFromUsername(user)
93+
console.log(`Inviting ${user} (${userId}) to ${config.githubOrg}`)
94+
await octokit.orgs.createInvitation({
95+
org: config.githubOrg,
96+
invitee_id: userId,
97+
})
98+
return true
99+
} catch (error) {
100+
const status = error?.status || error?.response?.status
101+
let message = error?.message || String(error)
102+
if (status === 422) {
103+
message = `Validation failed: ${message} (user may already be invited, or org is at max capacity)`
104+
} else if (status === 404) {
105+
message = `User not found: ${user}`
106+
} else if (status === 403) {
107+
message = `Permission denied or rate limited`
108+
}
109+
console.error(`Error adding ${user}: ${message}`)
110+
return { error: { user, operation: 'add', message, status } }
111+
}
81112
}
82113

83-
export async function removeUsersFromGitHubOrg(users: Set<string>): Promise<void> {
114+
export async function removeUsersFromGitHubOrg(users: Set<string>): Promise<OperationResult> {
115+
const result: OperationResult = { success: [], errors: [] }
84116
for (const user of users) {
85-
await mod.removeUserFromGitHubOrg(user)
117+
const outcome = await mod.removeUserFromGitHubOrg(user)
118+
if (outcome === true) {
119+
result.success.push(user)
120+
} else if (outcome !== false && 'error' in outcome) {
121+
result.errors.push(outcome.error)
122+
}
86123
}
124+
return result
87125
}
88126

89127
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
90-
export async function removeUserFromGitHubOrg(
91-
user: string,
92-
): Promise<GetResponseTypeFromEndpointMethod<typeof octokit.orgs.removeMembershipForUser> | boolean> {
128+
export async function removeUserFromGitHubOrg(user: string): Promise<{ error: OperationError } | boolean> {
93129
const octokit = mod.getAuthenticatedOctokit()
94130
if (config.ignoredUsers.includes(user.toLowerCase())) {
95131
console.log(`Ignoring remove for ${user}`)
96132
return false
97133
}
98-
console.log(`Removing user/invitation ${user} from ${config.githubOrg}`)
99-
return octokit.orgs.removeMembershipForUser({
100-
org: config.githubOrg,
101-
username: user,
102-
})
134+
try {
135+
console.log(`Removing user/invitation ${user} from ${config.githubOrg}`)
136+
await octokit.orgs.removeMembershipForUser({
137+
org: config.githubOrg,
138+
username: user,
139+
})
140+
return true
141+
} catch (error) {
142+
const status = error?.status || error?.response?.status
143+
let message = error?.message || String(error)
144+
if (status === 404) {
145+
message = `User not found or not a member: ${user}`
146+
} else if (status === 403) {
147+
message = `Permission denied or rate limited`
148+
}
149+
console.error(`Error removing ${user}: ${message}`)
150+
return { error: { user, operation: 'remove', message, status } }
151+
}
103152
}

src/google.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ export async function getGithubUsersFromGoogle(): Promise<Set<string>> {
3131
customer: 'my_customer',
3232
maxResults: 250,
3333
projection: 'custom',
34-
fields: 'users(customSchemas/Accounts/github(value))',
34+
query: 'isSuspended=false',
35+
fields: 'users(customSchemas/Accounts/github(value),suspended,archived)',
3536
customFieldMask: 'Accounts',
3637
})
3738

@@ -43,6 +44,7 @@ export async function getGithubUsersFromGoogle(): Promise<Set<string>> {
4344
export function formatUserList(users): Set<string> {
4445
return new Set(
4546
users
47+
.filter((user) => !user.suspended && !user.archived)
4648
.map((user) => user.customSchemas?.Accounts?.github?.map((account) => account.value?.toLowerCase()))
4749
.flat()
4850
.filter(Boolean),

0 commit comments

Comments
 (0)