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));
+});