@@ -279,93 +279,156 @@ jobs:
279279 runs-on : ubuntu-latest
280280
281281 steps :
282- - name : Map users
283- id : map-actor-to-slack
284- uses : icalia-actions/map-github-actor@e568d1dd6023e406a1db36db4e1e0b92d9dd7824 # v0.0.2
285- with :
286- actor-map : ${{ vars.SLACK_GITHUB_USERS_MAP }}
287- default-mapping : C067BD0377F
288-
289- - name : Generate payload variables
290- run : |
291- if [[ "${{ github.ref_name }}" == 'main' || "${{ github.ref_name }}" == 'maintenance' ]] ; then
292- echo "HEADER_MESSAGE=Tests failed against ${{ github.ref_name }} branch" >> $GITHUB_ENV
293- echo "SUMMARY_ICON=no_entry" >> $GITHUB_ENV
294- echo "SUMMARY_MESSAGE= Deployment to FFC environments will not happen until this issue is resolved." >> $GITHUB_ENV
295- echo "LAST_COMMIT_SHA=${{ github.sha}}" >> $GITHUB_ENV
296- echo "PR_LINK=*Branch:* ${{ github.ref_name }}" >> $GITHUB_ENV
297- else
298- echo "HEADER_MESSAGE=Tests failed against ${{ github.event.number }} pull request" >> $GITHUB_ENV
299- echo "SUMMARY_ICON=warning" >> $GITHUB_ENV
300- echo "SUMMARY_MESSAGE= Please resolve the problem before merging your changes into the main branch." >> $GITHUB_ENV
301- echo "LAST_COMMIT_SHA=${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV
302- echo "PR_LINK=*Pull request:* <https://github.com/FlowFuse/flowfuse/pull/${{ github.event.pull_request.number }}|${{ github.event.pull_request.number }}>" >> $GITHUB_ENV
303- fi
282+ # On pull_request failure: resolve the PR author's Slack ID by scanning Slack
283+ # profiles and matching the custom profile field (GitHub). Bot authors are skipped
284+ # silently; an unresolved author logs a warning and no notification is sent.
285+ # On push to main: target the gh-pipelines channel directly.
286+ # Outputs `recipients` as JSON: [{ slack_id, github?, role }].
287+ - name : Resolve Slack recipients
288+ id : resolve
289+ uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
290+ env :
291+ SLACK_BOT_TOKEN : ${{ secrets.SLACK_GHBOT_TOKEN }}
292+ SLACK_GITHUB_FIELD_ID : " Xf0A2BPU8U77"
293+ CHANNEL_ID : " C067BD0377F"
294+ PR_AUTHOR : ${{ github.event.pull_request.user.login }}
295+ ACTOR : ${{ github.actor }}
296+ EVENT_NAME : ${{ github.event_name }}
297+ with :
298+ script : |
299+ const token = process.env.SLACK_BOT_TOKEN;
300+ const fieldId = process.env.SLACK_GITHUB_FIELD_ID;
304301
305- - name : Send notification
306- uses : slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
307- with :
308- method : chat.postMessage
309- token : ${{ secrets.SLACK_GHBOT_TOKEN }}
310- payload : |
311- {
312- "channel": "C067BD0377F",
313- "blocks": [
314- {
315- "type": "header",
316- "text": {
317- "type": "plain_text",
318- "text": ":x: ${{ env.HEADER_MESSAGE }}",
319- "emoji": true
302+ // Resolve a GitHub login -> Slack user ID by scanning workspace profiles and
303+ // matching the custom profile field (GitHub). Returns undefined if not found.
304+ async function resolveSlackId(login) {
305+ const target = login.toLowerCase();
306+ let cursor;
307+ do {
308+ const params = new URLSearchParams({ limit: '200' });
309+ if (cursor) params.set('cursor', cursor);
310+ const res = await fetch(`https://slack.com/api/users.list?${params}`, {
311+ headers: { Authorization: `Bearer ${token}` }
312+ });
313+ const data = await res.json();
314+ if (!data.ok) { core.setFailed(`Slack users.list error: ${data.error}`); return; }
315+ for (const member of data.members) {
316+ if (member.deleted || member.is_bot) continue;
317+ const profileRes = await fetch(`https://slack.com/api/users.profile.get?user=${member.id}`, {
318+ headers: { Authorization: `Bearer ${token}` }
319+ });
320+ const profileData = await profileRes.json();
321+ if (!profileData.ok) continue;
322+ const ghField = profileData.profile?.fields?.[fieldId]?.value;
323+ if (!ghField) continue;
324+ if (ghField.toLowerCase() === target) return member.id;
320325 }
321- },
322- {
323- "type": "divider"
324- },
325- {
326- "type": "rich_text",
327- "elements": [
328- {
329- "type": "rich_text_section",
330- "elements": [
331- {
332- "type": "emoji",
333- "name": "${{ env.SUMMARY_ICON }}"
334- },
335- {
336- "type": "text",
337- "text": " ${{ env.SUMMARY_MESSAGE }}",
338- "style": {
339- "bold": true
340- }
341- }
342- ]
343- }
344- ]
345- },
346- {
347- "type": "divider"
348- },
349- {
350- "type": "section",
351- "fields": [
352- {
353- "type": "mrkdwn",
354- "text": "*Author:* <@${{ steps.map-actor-to-slack.outputs.actor-mapping }}>"
355- },
356- {
357- "type": "mrkdwn",
358- "text": "<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View failed workflow>"
359- },
360- {
361- "type": "mrkdwn",
362- "text": "*Last commit:* <${{ github.server_url }}/${{ github.repository }}/commit/${{ env.LAST_COMMIT_SHA }}|${{ env.LAST_COMMIT_SHA }}>"
363- },
364- {
365- "type": "mrkdwn",
366- "text": "${{ env.PR_LINK }}"
367- }
368- ]
326+ cursor = data.response_metadata?.next_cursor;
327+ } while (cursor);
328+ return undefined;
329+ }
330+
331+ // Push to main - notify the channel, mentioning the actor who pushed.
332+ if (process.env.EVENT_NAME !== 'pull_request') {
333+ const actor = process.env.ACTOR;
334+ const mention = actor ? await resolveSlackId(actor) : undefined;
335+ if (!mention) core.warning(`No Slack user found with GitHub username: ${actor}`);
336+ core.setOutput('recipients', JSON.stringify([{ slack_id: process.env.CHANNEL_ID, role: 'Channel', mention: mention || null }]));
337+ return;
338+ }
339+
340+ // Pull request - notify the author. Bot authors have no Slack profile: skip silently.
341+ const author = process.env.PR_AUTHOR;
342+ if (!author || author.endsWith('[bot]')) {
343+ core.setOutput('recipients', '[]');
344+ return;
345+ }
346+
347+ const slackId = await resolveSlackId(author);
348+ if (!slackId) {
349+ core.warning(`No Slack user found with GitHub username: ${author}`);
350+ core.setOutput('recipients', '[]');
351+ return;
352+ }
353+ core.setOutput('recipients', JSON.stringify([{ slack_id: slackId, github: author.toLowerCase(), role: 'Author' }]));
354+
355+ # Sends the failure notification to each recipient (DM to the PR author, or the channel
356+ # on main). chat.postMessage accepts a user ID or a channel ID as `channel`.
357+ - name : Send Slack notification
358+ if : steps.resolve.outputs.recipients != '' && steps.resolve.outputs.recipients != '[]'
359+ uses : actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
360+ env :
361+ SLACK_BOT_TOKEN : ${{ secrets.SLACK_GHBOT_TOKEN }}
362+ RECIPIENTS : ${{ steps.resolve.outputs.recipients }}
363+ EVENT_NAME : ${{ github.event_name }}
364+ REF_NAME : ${{ github.ref_name }}
365+ PR_NUMBER : ${{ github.event.pull_request.number }}
366+ COMMIT_SHA : ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
367+ ACTOR : ${{ github.actor }}
368+ SERVER_URL : ${{ github.server_url }}
369+ REPOSITORY : ${{ github.repository }}
370+ RUN_ID : ${{ github.run_id }}
371+ with :
372+ script : |
373+ const token = process.env.SLACK_BOT_TOKEN;
374+ const recipients = JSON.parse(process.env.RECIPIENTS);
375+ const isBranch = process.env.EVENT_NAME !== 'pull_request';
376+ const refName = process.env.REF_NAME;
377+ const prNumber = process.env.PR_NUMBER;
378+ const commitSha = process.env.COMMIT_SHA;
379+ const actor = process.env.ACTOR;
380+ const serverUrl = process.env.SERVER_URL;
381+ const repository = process.env.REPOSITORY;
382+ const runId = process.env.RUN_ID;
383+
384+ const headerText = isBranch
385+ ? `Tests failed against ${refName} branch`
386+ : `Tests failed against ${prNumber} pull request`;
387+ const summaryIcon = isBranch ? 'no_entry' : 'warning';
388+ const summaryText = isBranch
389+ ? ' Deployment to FFC environments will not happen until this issue is resolved.'
390+ : ' Please resolve the problem before merging your changes into the main branch.';
391+ const refLink = isBranch
392+ ? `*Branch:* ${refName}`
393+ : `*Pull request:* <${serverUrl}/${repository}/pull/${prNumber}|${prNumber}>`;
394+
395+ let failures = 0;
396+ for (const r of recipients) {
397+ // Channel posts mention the actor's Slack ID (a real ping) if resolved;
398+ // otherwise (and for DMs) show the plain GitHub login.
399+ const authorText = r.mention ? `<@${r.mention}>` : actor;
400+ const blocks = [
401+ { type: 'header', text: { type: 'plain_text', text: `:x: ${headerText}`, emoji: true } },
402+ { type: 'divider' },
403+ { type: 'rich_text', elements: [ { type: 'rich_text_section', elements: [
404+ { type: 'emoji', name: summaryIcon },
405+ { type: 'text', text: summaryText, style: { bold: true } }
406+ ] } ] },
407+ { type: 'divider' },
408+ { type: 'section', fields: [
409+ { type: 'mrkdwn', text: `*Author:* ${authorText}` },
410+ { type: 'mrkdwn', text: `<${serverUrl}/${repository}/actions/runs/${runId}|View failed workflow>` },
411+ { type: 'mrkdwn', text: `*Last commit:* <${serverUrl}/${repository}/commit/${commitSha}|${commitSha}>` },
412+ { type: 'mrkdwn', text: refLink }
413+ ] }
414+ ];
415+ const res = await fetch('https://slack.com/api/chat.postMessage', {
416+ method: 'POST',
417+ headers: {
418+ Authorization: `Bearer ${token}`,
419+ 'Content-Type': 'application/json; charset=utf-8'
420+ },
421+ body: JSON.stringify({ channel: r.slack_id, blocks })
422+ });
423+ const data = await res.json();
424+ if (!data.ok) {
425+ core.warning(`Slack chat.postMessage failed for ${r.slack_id}: ${data.error}`);
426+ failures++;
427+ } else {
428+ core.info(`Notified ${r.slack_id} (${r.role})`);
369429 }
370- ]
371- }
430+ }
431+
432+ if (failures > 0 && failures === recipients.length) {
433+ core.setFailed(`All ${failures} Slack notifications failed`);
434+ }
0 commit comments