|
| 1 | +name: "Slack notification" |
| 2 | +description: "Resolve Slack recipients from GitHub logins and send a Block Kit message via chat.postMessage" |
| 3 | + |
| 4 | +inputs: |
| 5 | + bot-token: |
| 6 | + description: 'Slack bot token (secrets.SLACK_GHBOT_TOKEN). Needs users:read, users.profile:read, chat:write.' |
| 7 | + required: true |
| 8 | + mode: |
| 9 | + description: >- |
| 10 | + Who to notify: |
| 11 | + "channel" (post to channel-id), |
| 12 | + "author" (DM the PR author), |
| 13 | + "contributors" (DM PR author + requested reviewers + pusher, or the actor on workflow_dispatch). |
| 14 | + required: true |
| 15 | + blocks: |
| 16 | + description: >- |
| 17 | + Block Kit blocks as a JSON array (string). May contain placeholders substituted per send: |
| 18 | + {{ROLE}} (recipient role), {{MENTION}} (resolved actor mention, channel mode), |
| 19 | + and any {{KEY}} from the `values` input. All substitutions are JSON-escaped. |
| 20 | + required: true |
| 21 | + values: |
| 22 | + description: >- |
| 23 | + JSON object of placeholder KEY -> raw string, injected JSON-safely into `blocks` as {{KEY}}. |
| 24 | + Build each value with toJSON() so arbitrary text (e.g. PR titles) stays valid JSON. |
| 25 | + required: false |
| 26 | + default: '{}' |
| 27 | + channel-id: |
| 28 | + description: 'Target channel ID for mode=channel.' |
| 29 | + required: false |
| 30 | + default: 'C067BD0377F' |
| 31 | + github-field-id: |
| 32 | + description: 'Slack custom profile field ID holding the GitHub username.' |
| 33 | + required: false |
| 34 | + default: 'Xf0A2BPU8U77' |
| 35 | + mention-actor: |
| 36 | + description: 'mode=channel only: resolve the actor and expose {{MENTION}} as a real Slack ping.' |
| 37 | + required: false |
| 38 | + default: 'false' |
| 39 | + actor: |
| 40 | + description: 'GitHub actor (used by mode=contributors/channel).' |
| 41 | + required: false |
| 42 | + default: ${{ github.actor }} |
| 43 | + event-name: |
| 44 | + description: 'Triggering event name (drives mode=contributors branching).' |
| 45 | + required: false |
| 46 | + default: ${{ github.event_name }} |
| 47 | + pr-author: |
| 48 | + description: 'PR author login (mode=author/contributors).' |
| 49 | + required: false |
| 50 | + default: ${{ github.event.pull_request.user.login }} |
| 51 | + requested-reviewers: |
| 52 | + description: 'Requested reviewers as JSON (mode=contributors).' |
| 53 | + required: false |
| 54 | + default: ${{ toJSON(github.event.pull_request.requested_reviewers) }} |
| 55 | + |
| 56 | +runs: |
| 57 | + using: composite |
| 58 | + steps: |
| 59 | + # Resolve the recipient list. For author/contributors modes, each GitHub login is mapped to a |
| 60 | + # Slack user ID by scanning workspace profiles and matching the custom GitHub profile field. |
| 61 | + # Unresolved and bot users are dropped with a warning. Outputs `recipients` JSON and, for |
| 62 | + # channel mode with mention-actor, `actor_mention`. |
| 63 | + - name: Resolve Slack recipients |
| 64 | + id: resolve |
| 65 | + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 66 | + env: |
| 67 | + SLACK_BOT_TOKEN: ${{ inputs.bot-token }} |
| 68 | + SLACK_GITHUB_FIELD_ID: ${{ inputs.github-field-id }} |
| 69 | + MODE: ${{ inputs.mode }} |
| 70 | + CHANNEL_ID: ${{ inputs.channel-id }} |
| 71 | + MENTION_ACTOR: ${{ inputs.mention-actor }} |
| 72 | + ACTOR: ${{ inputs.actor }} |
| 73 | + EVENT_NAME: ${{ inputs.event-name }} |
| 74 | + PR_AUTHOR: ${{ inputs.pr-author }} |
| 75 | + REQUESTED_REVIEWERS: ${{ inputs.requested-reviewers }} |
| 76 | + with: |
| 77 | + script: | |
| 78 | + const token = process.env.SLACK_BOT_TOKEN; |
| 79 | + const fieldId = process.env.SLACK_GITHUB_FIELD_ID; |
| 80 | + const mode = process.env.MODE; |
| 81 | + const eventName = process.env.EVENT_NAME; |
| 82 | + const actor = process.env.ACTOR; |
| 83 | + const mentionActor = process.env.MENTION_ACTOR === 'true'; |
| 84 | +
|
| 85 | + // Scan the workspace once and resolve a set of GitHub logins -> Slack user IDs. |
| 86 | + async function resolveLogins(targets) { |
| 87 | + const want = new Set([...targets].map(t => t.toLowerCase())); |
| 88 | + const resolved = new Map(); |
| 89 | + if (want.size === 0) return resolved; |
| 90 | + let cursor; |
| 91 | + outer: do { |
| 92 | + const params = new URLSearchParams({ limit: '200' }); |
| 93 | + if (cursor) params.set('cursor', cursor); |
| 94 | + const res = await fetch(`https://slack.com/api/users.list?${params}`, { |
| 95 | + headers: { Authorization: `Bearer ${token}` } |
| 96 | + }); |
| 97 | + const data = await res.json(); |
| 98 | + if (!data.ok) { core.setFailed(`Slack users.list error: ${data.error}`); return resolved; } |
| 99 | + for (const member of data.members) { |
| 100 | + if (member.deleted || member.is_bot) continue; |
| 101 | + const profileRes = await fetch(`https://slack.com/api/users.profile.get?user=${member.id}`, { |
| 102 | + headers: { Authorization: `Bearer ${token}` } |
| 103 | + }); |
| 104 | + const profileData = await profileRes.json(); |
| 105 | + if (!profileData.ok) continue; |
| 106 | + const ghField = profileData.profile?.fields?.[fieldId]?.value; |
| 107 | + if (!ghField) continue; |
| 108 | + const ghLower = ghField.toLowerCase(); |
| 109 | + if (want.has(ghLower)) { |
| 110 | + resolved.set(ghLower, member.id); |
| 111 | + if (resolved.size === want.size) break outer; |
| 112 | + } |
| 113 | + } |
| 114 | + cursor = data.response_metadata?.next_cursor; |
| 115 | + } while (cursor); |
| 116 | + return resolved; |
| 117 | + } |
| 118 | +
|
| 119 | + // 1. Desired recipients (logins + roles) per mode. Bots are skipped (no Slack profile). |
| 120 | + const wanted = []; |
| 121 | + const seen = new Set(); |
| 122 | + const add = (login, role) => { |
| 123 | + if (!login || login.endsWith('[bot]')) return; |
| 124 | + const key = login.toLowerCase(); |
| 125 | + if (seen.has(key)) return; |
| 126 | + seen.add(key); |
| 127 | + wanted.push({ github: key, role }); |
| 128 | + }; |
| 129 | +
|
| 130 | + if (mode === 'author') { |
| 131 | + add(process.env.PR_AUTHOR, 'Author'); |
| 132 | + } else if (mode === 'contributors') { |
| 133 | + if (eventName === 'workflow_dispatch') { |
| 134 | + add(actor, 'Trigger'); |
| 135 | + } else { |
| 136 | + add(process.env.PR_AUTHOR, 'Author'); |
| 137 | + for (const r of JSON.parse(process.env.REQUESTED_REVIEWERS || '[]')) add(r.login, 'Reviewer'); |
| 138 | + add(actor, 'Pusher'); |
| 139 | + } |
| 140 | + } else if (mode !== 'channel') { |
| 141 | + core.setFailed(`Unknown mode: ${mode}`); |
| 142 | + return; |
| 143 | + } |
| 144 | +
|
| 145 | + // 2. Resolve recipients + (optionally) the actor for a channel mention, in one scan. |
| 146 | + const targets = new Set(wanted.map(w => w.github)); |
| 147 | + if (mentionActor && actor) targets.add(actor.toLowerCase()); |
| 148 | + const resolved = await resolveLogins(targets); |
| 149 | +
|
| 150 | + // 3. Actor mention for channel posts (real ping if resolved, else plain login). |
| 151 | + let actorMention = ''; |
| 152 | + if (mentionActor) { |
| 153 | + const id = actor ? resolved.get(actor.toLowerCase()) : undefined; |
| 154 | + if (id) { actorMention = `<@${id}>`; } |
| 155 | + else { core.warning(`No Slack user found with GitHub username: ${actor}`); actorMention = actor || ''; } |
| 156 | + } |
| 157 | + core.setOutput('actor_mention', actorMention); |
| 158 | +
|
| 159 | + // 4. Final recipient list. |
| 160 | + let recipients; |
| 161 | + if (mode === 'channel') { |
| 162 | + recipients = [{ slack_id: process.env.CHANNEL_ID, role: 'Channel' }]; |
| 163 | + } else { |
| 164 | + recipients = []; |
| 165 | + for (const w of wanted) { |
| 166 | + const id = resolved.get(w.github); |
| 167 | + if (id) recipients.push({ slack_id: id, github: w.github, role: w.role }); |
| 168 | + else core.warning(`No Slack user found with GitHub username: ${w.github} (role: ${w.role})`); |
| 169 | + } |
| 170 | + } |
| 171 | + core.info(`Recipients: ${recipients.length}`); |
| 172 | + core.setOutput('recipients', JSON.stringify(recipients)); |
| 173 | +
|
| 174 | + - name: Send Slack notification |
| 175 | + if: steps.resolve.outputs.recipients != '' && steps.resolve.outputs.recipients != '[]' |
| 176 | + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 |
| 177 | + env: |
| 178 | + SLACK_BOT_TOKEN: ${{ inputs.bot-token }} |
| 179 | + RECIPIENTS: ${{ steps.resolve.outputs.recipients }} |
| 180 | + ACTOR_MENTION: ${{ steps.resolve.outputs.actor_mention }} |
| 181 | + BLOCKS: ${{ inputs.blocks }} |
| 182 | + VALUES: ${{ inputs.values }} |
| 183 | + with: |
| 184 | + script: | |
| 185 | + const token = process.env.SLACK_BOT_TOKEN; |
| 186 | + const recipients = JSON.parse(process.env.RECIPIENTS || '[]'); |
| 187 | + const template = process.env.BLOCKS; |
| 188 | + const actorMention = process.env.ACTOR_MENTION || ''; |
| 189 | + const values = JSON.parse(process.env.VALUES || '{}'); |
| 190 | +
|
| 191 | + if (recipients.length === 0) { core.info('No recipients; nothing to send'); return; } |
| 192 | +
|
| 193 | + // Insert `raw` in place of {{key}} inside a JSON string literal, JSON-escaped so quotes |
| 194 | + // and newlines in the value never break the surrounding JSON. |
| 195 | + const sub = (str, key, raw) => { |
| 196 | + const escaped = JSON.stringify(String(raw)).slice(1, -1); |
| 197 | + return str.split(`{{${key}}}`).join(escaped); |
| 198 | + }; |
| 199 | +
|
| 200 | + let failures = 0; |
| 201 | + for (const r of recipients) { |
| 202 | + let filled = template; |
| 203 | + for (const [k, v] of Object.entries(values)) filled = sub(filled, k, v); |
| 204 | + filled = sub(filled, 'ROLE', r.role || ''); |
| 205 | + filled = sub(filled, 'MENTION', actorMention); |
| 206 | +
|
| 207 | + let blocks; |
| 208 | + try { blocks = JSON.parse(filled); } |
| 209 | + catch (e) { core.setFailed(`Invalid blocks JSON after substitution: ${e.message}`); return; } |
| 210 | +
|
| 211 | + const res = await fetch('https://slack.com/api/chat.postMessage', { |
| 212 | + method: 'POST', |
| 213 | + headers: { |
| 214 | + Authorization: `Bearer ${token}`, |
| 215 | + 'Content-Type': 'application/json; charset=utf-8' |
| 216 | + }, |
| 217 | + body: JSON.stringify({ channel: r.slack_id, blocks }) |
| 218 | + }); |
| 219 | + const data = await res.json(); |
| 220 | + if (!data.ok) { |
| 221 | + core.warning(`Slack chat.postMessage failed for ${r.slack_id}: ${data.error}`); |
| 222 | + failures++; |
| 223 | + } else { |
| 224 | + core.info(`Notified ${r.slack_id} (${r.role})`); |
| 225 | + } |
| 226 | + } |
| 227 | +
|
| 228 | + if (failures > 0 && failures === recipients.length) { |
| 229 | + core.setFailed(`All ${failures} Slack notifications failed`); |
| 230 | + } |
0 commit comments