From 24807d03131466d1566e330d18b85a25bd946e79 Mon Sep 17 00:00:00 2001 From: Andrew Date: Mon, 26 Oct 2026 15:11:26 +0100 Subject: [PATCH] fix(slack-notify): render star bullets and link the cut head message to its full text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changelog lists written with `*` reached Slack as literal asterisks: only `-` items became bullets. A head message cut at 4,000 characters ended in `… (truncated)`; the new `cut-link` input names where the full text is, and the release flows pass the PR (started, cancelled) or the GitHub release (completed). --- .github/workflows/release-complete.yml | 1 + .github/workflows/release-pr-cancel.yml | 1 + .github/workflows/release-pr-refresh.yml | 1 + .github/workflows/release-start.yml | 1 + contracts/interfaces.json | 4 ++++ steps/slack-notify/action.yml | 5 +++++ steps/slack-notify/slackNotify.js | 24 +++++++++++++++--------- steps/slack-notify/slackNotify.test.js | 20 +++++++++++++++++++- 8 files changed, 47 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release-complete.yml b/.github/workflows/release-complete.yml index 7fb9af7..9f7b1e3 100644 --- a/.github/workflows/release-complete.yml +++ b/.github/workflows/release-complete.yml @@ -91,6 +91,7 @@ jobs: slack-bot-token: ${{ fromJSON(steps.op.outputs.secrets).SLACK_BOT_TOKEN }} slack-channel-id: ${{ fromJSON(steps.op.outputs.secrets).SLACK_CHANNEL_ID }} update-ts: ${{ steps.thread.outputs.ts }} + cut-link: "[the release](${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag }})" message: | šŸŽ‰ *Release ${{ inputs.tag }} — Completed (${{ inputs.env }})* āœ…${{ fromJSON(steps.op.outputs.secrets).SLACK_CODEOWNERS_GROUP_ID != '' && format(' ', fromJSON(steps.op.outputs.secrets).SLACK_CODEOWNERS_GROUP_ID) || '' }} ${{ inputs.url != '' && format('*{0}:* {1}', inputs.env, inputs.url) || '' }} diff --git a/.github/workflows/release-pr-cancel.yml b/.github/workflows/release-pr-cancel.yml index 87dad9f..83a223e 100644 --- a/.github/workflows/release-pr-cancel.yml +++ b/.github/workflows/release-pr-cancel.yml @@ -119,6 +119,7 @@ jobs: slack-bot-token: ${{ fromJSON(steps.op.outputs.secrets).SLACK_BOT_TOKEN }} slack-channel-id: ${{ fromJSON(steps.op.outputs.secrets).SLACK_CHANNEL_ID }} update-ts: ${{ steps.thread.outputs.ts }} + cut-link: "[the PR](${{ github.event.pull_request.html_url }})" message: | šŸ›‘ *${{ github.event.pull_request.title }} — Cancelled*${{ fromJSON(steps.op.outputs.secrets).SLACK_CODEOWNERS_GROUP_ID != '' && format(' ', fromJSON(steps.op.outputs.secrets).SLACK_CODEOWNERS_GROUP_ID) || '' }} *PR:* [${{ github.event.pull_request.title }}](${{ github.event.pull_request.html_url }}) diff --git a/.github/workflows/release-pr-refresh.yml b/.github/workflows/release-pr-refresh.yml index 19a4d1e..5bb57b0 100644 --- a/.github/workflows/release-pr-refresh.yml +++ b/.github/workflows/release-pr-refresh.yml @@ -193,6 +193,7 @@ jobs: slack-bot-token: ${{ fromJSON(steps.op.outputs.secrets).SLACK_BOT_TOKEN }} slack-channel-id: ${{ fromJSON(steps.op.outputs.secrets).SLACK_CHANNEL_ID }} update-ts: ${{ steps.markers.outputs.ts }} + cut-link: "[the PR](${{ github.event.pull_request.html_url }})" message: | šŸš€ *${{ github.event.pull_request.title }} — Started*${{ fromJSON(steps.op.outputs.secrets).SLACK_CODEOWNERS_GROUP_ID != '' && format(' ', fromJSON(steps.op.outputs.secrets).SLACK_CODEOWNERS_GROUP_ID) || '' }} *PR:* ${{ github.event.pull_request.html_url }} diff --git a/.github/workflows/release-start.yml b/.github/workflows/release-start.yml index fd5405a..2a6e666 100644 --- a/.github/workflows/release-start.yml +++ b/.github/workflows/release-start.yml @@ -493,6 +493,7 @@ jobs: slack-bot-token: ${{ fromJSON(steps.op.outputs.secrets).SLACK_BOT_TOKEN }} slack-channel-id: ${{ fromJSON(steps.op.outputs.secrets).SLACK_CHANNEL_ID }} update-ts: ${{ steps.slack.outputs.ts }} + cut-link: "[the PR](${{ steps.pr.outputs.url }})" message: | šŸš€ *Release ${{ steps.branch.outputs.label }} — Started*${{ fromJSON(steps.op.outputs.secrets).SLACK_CODEOWNERS_GROUP_ID != '' && format(' ', fromJSON(steps.op.outputs.secrets).SLACK_CODEOWNERS_GROUP_ID) || '' }} *PR:* ${{ steps.pr.outputs.url }} diff --git a/contracts/interfaces.json b/contracts/interfaces.json index 1602658..4342fe1 100644 --- a/contracts/interfaces.json +++ b/contracts/interfaces.json @@ -1300,6 +1300,10 @@ }, "slack-notify": { "inputs": { + "cut-link": { + "required": false, + "default": "" + }, "message": { "required": true }, diff --git a/steps/slack-notify/action.yml b/steps/slack-notify/action.yml index e2150bd..4006449 100644 --- a/steps/slack-notify/action.yml +++ b/steps/slack-notify/action.yml @@ -19,6 +19,10 @@ inputs: description: Slack message ts to EDIT (chat.update) instead of posting a new message required: false default: "" + cut-link: + description: "Markdown link to the full text, e.g. `[the PR](url)`: a message over Slack's 4,000 characters is cut and ends with `… the rest is in ` (`… (truncated)` without one)" + required: false + default: "" outputs: ts: @@ -35,6 +39,7 @@ runs: SLACK_CHANNEL_ID: ${{ inputs.slack-channel-id }} SLACK_THREAD_TS: ${{ inputs.thread-ts }} SLACK_UPDATE_TS: ${{ inputs.update-ts }} + SLACK_CUT_LINK: ${{ inputs.cut-link }} MESSAGE: ${{ inputs.message }} run: | set -euo pipefail diff --git a/steps/slack-notify/slackNotify.js b/steps/slack-notify/slackNotify.js index 74eb03e..105ddca 100644 --- a/steps/slack-notify/slackNotify.js +++ b/steps/slack-notify/slackNotify.js @@ -17,20 +17,25 @@ const markdownToMrkdwn = (text) => { .replace(/\*\*(?=\S)([^*\n]+?)(?<=\S)\*\*/g, '*$1*') // Convert headers: ## Header -> *Header* .replace(/^#{1,6}\s+(.+)$/gm, '*$1*') - // Convert list items: - item -> • item - .replace(/^-\s+/gm, '• ') + // Convert list items, either marker, any nesting: - item / * item -> • item + .replace(/^(\s*)[-*]\s+/gm, '$1• ') ); }; // chat.update rejects `text` over 4,000 characters (`msg_too_long`), and a long // release changelog gets there. The body is cut at the last line break that fits, -// so no mrkdwn link is split, and the cut is marked. A short closing paragraph -// survives the cut: release flags such as the DB-migration warning live there. +// so no mrkdwn link is split, and the cut is marked — with a link to the full text +// (the PR, the GitHub release) when the caller passes one. A short closing +// paragraph survives the cut: release flags such as the DB-migration warning live +// there. const MAX_TEXT_LENGTH = 4000; const MAX_KEPT_TAIL_LENGTH = 500; const TRUNCATION_MARK = '\n… (truncated)'; -const truncateText = (text, limit = MAX_TEXT_LENGTH) => { +const truncationMark = (cutLink) => + cutLink ? `\n… the rest is in ${cutLink}` : TRUNCATION_MARK; + +const truncateText = (text, limit = MAX_TEXT_LENGTH, mark = TRUNCATION_MARK) => { if (text.length <= limit) { return text; } @@ -43,9 +48,9 @@ const truncateText = (text, limit = MAX_TEXT_LENGTH) => { tailStart > 0 && trimmed.length - tailStart <= MAX_KEPT_TAIL_LENGTH ? trimmed.slice(tailStart) : ''; - const room = limit - TRUNCATION_MARK.length - tail.length; + const room = limit - mark.length - tail.length; const lineEnd = trimmed.lastIndexOf('\n', room); - return `${trimmed.slice(0, lineEnd > 0 ? lineEnd : room)}${TRUNCATION_MARK}${tail}`; + return `${trimmed.slice(0, lineEnd > 0 ? lineEnd : room)}${mark}${tail}`; }; // Sanitize untrusted values before logging to avoid log-forging/control chars. @@ -113,7 +118,7 @@ const sendMessage = (token, channel, text, threadTs, updateTs) => req.end(); }); -module.exports = { isValidSlackTs, markdownToMrkdwn, truncateText }; +module.exports = { isValidSlackTs, markdownToMrkdwn, truncateText, truncationMark }; // Standalone runner if (require.main === module) { @@ -138,7 +143,8 @@ if (require.main === module) { console.log('Sending Slack notification.'); const text = markdownToMrkdwn(message); - const fittedText = truncateText(text); + const cutLink = markdownToMrkdwn(process.env.SLACK_CUT_LINK || '').trim(); + const fittedText = truncateText(text, MAX_TEXT_LENGTH, truncationMark(cutLink)); if (fittedText.length < text.trimEnd().length) { console.log( `::warning::Slack message truncated from ${text.length} to ${fittedText.length} characters.`, diff --git a/steps/slack-notify/slackNotify.test.js b/steps/slack-notify/slackNotify.test.js index dea4b7e..8f04929 100644 --- a/steps/slack-notify/slackNotify.test.js +++ b/steps/slack-notify/slackNotify.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { markdownToMrkdwn, truncateText } = require('./slackNotify'); +const { markdownToMrkdwn, truncateText, truncationMark } = require('./slackNotify'); test('converts markdown bold to mrkdwn and leaves mrkdwn bold alone', () => { assert.equal( @@ -18,6 +18,13 @@ test('converts links, headers and list items', () => { ); }); +test('converts star bullets and nested items, and leaves bold alone', () => { + assert.equal( + markdownToMrkdwn('* **APP-1332**: repair tool ([c91d3a5](https://x.y/c))\n * nested\n*Header*'), + '• *APP-1332*: repair tool ()\n • nested\n*Header*', + ); +}); + test('keeps text within the limit unchanged', () => { assert.equal(truncateText('a\nb', 3), 'a\nb'); }); @@ -47,3 +54,14 @@ test('hard-cuts a single over-limit line', () => { assert.equal(truncated.length, 4000); assert.ok(truncated.endsWith('\n… (truncated)')); }); + +test('marks the cut with a link to the full text when given one', () => { + const mark = truncationMark(''); + assert.equal(mark, '\n… the rest is in '); + assert.equal(truncationMark(''), '\n… (truncated)'); + + const list = Array.from({ length: 200 }, () => '• item').join('\n'); + const truncated = truncateText(list, 100, mark); + assert.ok(truncated.length <= 100); + assert.ok(truncated.endsWith(mark)); +});