Skip to content

Commit 1dfb02f

Browse files
committed
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
1 parent 0271c06 commit 1dfb02f

File tree

6 files changed

+335
-0
lines changed

6 files changed

+335
-0
lines changed

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getGithubUsersFromGoogle } from './src/google'
22
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()
@@ -14,11 +15,14 @@ export async function run(): Promise<void> {
1415
const usersNotInGoogle = new Set(Array.from(gitHubUsers).filter((x) => !googleUsers.has(x)))
1516
let unfixedMismatch = false
1617
const allErrors: OperationError[] = []
18+
const addedUsers: string[] = []
19+
const removedUsers: string[] = []
1720

1821
if (usersNotInGithub.size > 0) {
1922
console.log(`Users not in github: ${Array.from(usersNotInGithub).join(', ')}`)
2023
if (config.addUsers) {
2124
const result = await addUsersToGitHubOrg(usersNotInGithub)
25+
addedUsers.push(...result.success)
2226
if (result.errors.length > 0) {
2327
allErrors.push(...result.errors)
2428
}
@@ -31,6 +35,7 @@ export async function run(): Promise<void> {
3135
console.log(`Users not in google: ${Array.from(usersNotInGoogle).join(', ')}`)
3236
if (config.removeUsers) {
3337
const result = await removeUsersFromGitHubOrg(usersNotInGoogle)
38+
removedUsers.push(...result.success)
3439
if (result.errors.length > 0) {
3540
allErrors.push(...result.errors)
3641
}
@@ -47,6 +52,8 @@ export async function run(): Promise<void> {
4752
console.error(`Total errors: ${allErrors.length}`)
4853
}
4954

55+
await notifySlack(addedUsers, removedUsers, allErrors, config.githubOrg)
56+
5057
const hasErrors = allErrors.length > 0
5158
const exitCode = unfixedMismatch || hasErrors ? config.exitCodeOnMissmatch : 0
5259

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/slack.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import * as https from 'https'
2+
import { URL } from 'url'
3+
import { config } from './config'
4+
import { OperationError } from './github'
5+
6+
export interface SlackMessage {
7+
text: string
8+
blocks?: SlackBlock[]
9+
}
10+
11+
export interface SlackBlock {
12+
type: string
13+
text?: { type: string; text: string }
14+
elements?: { type: string; text: string }[]
15+
}
16+
17+
export async function sendSlackNotification(message: SlackMessage): Promise<boolean> {
18+
const webhookUrl = config.slackWebhookUrl
19+
if (!webhookUrl) {
20+
return false
21+
}
22+
23+
return new Promise((resolve) => {
24+
try {
25+
const url = new URL(webhookUrl)
26+
const postData = JSON.stringify(message)
27+
28+
const options = {
29+
hostname: url.hostname,
30+
port: 443,
31+
path: url.pathname,
32+
method: 'POST',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
'Content-Length': Buffer.byteLength(postData),
36+
},
37+
}
38+
39+
const req = https.request(options, (res) => {
40+
if (res.statusCode === 200) {
41+
resolve(true)
42+
} else {
43+
console.error(`Slack notification failed: ${res.statusCode}`)
44+
resolve(false)
45+
}
46+
})
47+
48+
req.on('error', (error) => {
49+
console.error(`Slack notification error: ${error}`)
50+
resolve(false)
51+
})
52+
53+
req.write(postData)
54+
req.end()
55+
} catch (error) {
56+
console.error(`Slack notification error: ${error}`)
57+
resolve(false)
58+
}
59+
})
60+
}
61+
62+
export function formatMembershipUpdate(
63+
added: string[],
64+
removed: string[],
65+
errors: OperationError[],
66+
org: string,
67+
): SlackMessage {
68+
const hasChanges = added.length > 0 || removed.length > 0
69+
const hasErrors = errors.length > 0
70+
71+
let emoji = ':white_check_mark:'
72+
if (hasErrors && !hasChanges) {
73+
emoji = ':x:'
74+
} else if (hasErrors) {
75+
emoji = ':warning:'
76+
}
77+
78+
const blocks: SlackBlock[] = [
79+
{
80+
type: 'header',
81+
text: { type: 'plain_text', text: `${emoji} GitHub Org Sync: ${org}` },
82+
},
83+
]
84+
85+
if (added.length > 0) {
86+
blocks.push({
87+
type: 'section',
88+
text: { type: 'mrkdwn', text: `*Users Added:* ${added.join(', ')}` },
89+
})
90+
}
91+
92+
if (removed.length > 0) {
93+
blocks.push({
94+
type: 'section',
95+
text: { type: 'mrkdwn', text: `*Users Removed:* ${removed.join(', ')}` },
96+
})
97+
}
98+
99+
if (errors.length > 0) {
100+
const errorText = errors.map((e) => `• [${e.operation}] ${e.user}: ${e.message}`).join('\n')
101+
blocks.push({
102+
type: 'section',
103+
text: { type: 'mrkdwn', text: `*Errors:*\n${errorText}` },
104+
})
105+
}
106+
107+
if (!hasChanges && !hasErrors) {
108+
blocks.push({
109+
type: 'section',
110+
text: { type: 'mrkdwn', text: 'No changes required - all users in sync.' },
111+
})
112+
}
113+
114+
return {
115+
text: `GitHub Org Sync: ${added.length} added, ${removed.length} removed, ${errors.length} errors`,
116+
blocks,
117+
}
118+
}
119+
120+
export async function notifySlack(
121+
added: string[],
122+
removed: string[],
123+
errors: OperationError[],
124+
org: string,
125+
): Promise<void> {
126+
const hasChanges = added.length > 0 || removed.length > 0
127+
const hasErrors = errors.length > 0
128+
129+
const shouldNotify =
130+
(hasErrors && config.slackNotifyOnError) || (hasChanges && config.slackNotifyOnChange) || config.slackNotifyAlways
131+
132+
if (!shouldNotify) {
133+
return
134+
}
135+
136+
const message = formatMembershipUpdate(added, removed, errors, org)
137+
const sent = await sendSlackNotification(message)
138+
if (sent) {
139+
console.log('Slack notification sent')
140+
}
141+
}

tests/index.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
jest.mock('../src/google')
22
jest.mock('../src/github')
3+
jest.mock('../src/slack')
34
import * as google from '../src/google'
45
import * as github from '../src/github'
6+
import * as slack from '../src/slack'
57
import * as mod from '../index'
68

79
let processExitSpy

0 commit comments

Comments
 (0)