176 lines
5.5 KiB
Python
176 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Build a Slack Block Kit payload from release-pipeline changelog JSON."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import html
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
from urllib.parse import quote
|
|
|
|
MAX_SECTION_TEXT_LENGTH = 3000
|
|
MAX_MESSAGE_BLOCKS = 50
|
|
|
|
SECTION_ORDER = (
|
|
("newFeatures", "New Features"),
|
|
("improvements", "Improvements"),
|
|
("bugFixes", "Bug Fixes"),
|
|
("images", "Image"),
|
|
# Keep the existing label stable for compatibility with recent Slack posts.
|
|
("oz_updates", "oz_updates"),
|
|
)
|
|
|
|
MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
|
|
SLACK_LINK_URL_SAFE_CHARS = "/:?&=#%+~@!$'()*;,[]"
|
|
|
|
|
|
def escape_slack_text(text: str) -> str:
|
|
"""Escape Slack control characters in ordinary mrkdwn text."""
|
|
return html.escape(text, quote=False)
|
|
|
|
|
|
def escape_slack_link_url(url: str) -> str:
|
|
"""Percent-encode Slack link delimiters before embedding a URL in mrkdwn."""
|
|
return quote(url, safe=SLACK_LINK_URL_SAFE_CHARS)
|
|
|
|
|
|
def slack_link(url: str, label: str) -> str:
|
|
"""Build a Slack mrkdwn link from an already-validated URL and label."""
|
|
return f"<{escape_slack_link_url(url)}|{escape_slack_text(label)}>"
|
|
|
|
|
|
def markdown_links_to_slack(text: str) -> str:
|
|
"""Convert standard Markdown links to Slack mrkdwn links."""
|
|
parts: list[str] = []
|
|
last_end = 0
|
|
for match in MARKDOWN_LINK_RE.finditer(text):
|
|
parts.append(escape_slack_text(text[last_end : match.start()]))
|
|
label, url = match.groups()
|
|
parts.append(slack_link(url, label))
|
|
last_end = match.end()
|
|
parts.append(escape_slack_text(text[last_end:]))
|
|
return "".join(parts)
|
|
|
|
|
|
def slack_lines(changelog: dict) -> list[str]:
|
|
"""Render non-empty changelog sections as Slack mrkdwn lines."""
|
|
lines: list[str] = []
|
|
for key, title in SECTION_ORDER:
|
|
values = changelog.get(key, [])
|
|
if not isinstance(values, list) or not values:
|
|
continue
|
|
lines.append(f"*{title}*")
|
|
for value in values:
|
|
lines.append(f" • {markdown_links_to_slack(str(value))}")
|
|
return lines
|
|
|
|
|
|
def split_overlong_line(line: str) -> Iterable[str]:
|
|
"""Split a pathological line so every Slack section remains valid."""
|
|
while len(line) > MAX_SECTION_TEXT_LENGTH - 1:
|
|
yield line[: MAX_SECTION_TEXT_LENGTH - 1]
|
|
line = line[MAX_SECTION_TEXT_LENGTH - 1 :]
|
|
yield line
|
|
|
|
|
|
def chunk_lines(lines: list[str]) -> list[str]:
|
|
"""Split text into section-sized chunks while retaining copy boundaries."""
|
|
chunks: list[str] = []
|
|
buffer = ""
|
|
for raw_line in lines:
|
|
for line in split_overlong_line(raw_line):
|
|
candidate = line if not buffer else f"{buffer}\n{line}"
|
|
# Reserve a character for a trailing newline. Keeping it in each
|
|
# section preserves a separator when Slack copies adjacent blocks.
|
|
if len(candidate) + 1 > MAX_SECTION_TEXT_LENGTH:
|
|
chunks.append(f"{buffer}\n")
|
|
buffer = line
|
|
else:
|
|
buffer = candidate
|
|
if buffer:
|
|
chunks.append(f"{buffer}\n")
|
|
return chunks
|
|
|
|
|
|
def artifact_link_block(markdown_artifact_url: str) -> dict | None:
|
|
"""Build a Slack block linking to the downloadable Markdown artifact."""
|
|
if not markdown_artifact_url:
|
|
return None
|
|
return {
|
|
"type": "section",
|
|
"text": {
|
|
"type": "mrkdwn",
|
|
"text": (
|
|
"Raw Markdown changelog: "
|
|
f"{slack_link(markdown_artifact_url, 'Download raw Markdown changelog artifact')}"
|
|
),
|
|
},
|
|
}
|
|
|
|
|
|
def build_payload(
|
|
changelog: dict, release_tag: str, markdown_artifact_url: str = ""
|
|
) -> dict:
|
|
"""Build a Block Kit message and reject payloads Slack cannot accept."""
|
|
chunks = chunk_lines(slack_lines(changelog))
|
|
if not chunks:
|
|
return {"blocks": []}
|
|
|
|
blocks = [
|
|
{
|
|
"type": "header",
|
|
"text": {
|
|
"type": "plain_text",
|
|
"text": f"Changelog for {release_tag}",
|
|
},
|
|
}
|
|
]
|
|
artifact_block = artifact_link_block(markdown_artifact_url)
|
|
if artifact_block is not None:
|
|
blocks.append(artifact_block)
|
|
blocks.extend(
|
|
{
|
|
"type": "section",
|
|
"expand": True,
|
|
"text": {"type": "mrkdwn", "text": chunk},
|
|
}
|
|
for chunk in chunks
|
|
)
|
|
|
|
if len(blocks) > MAX_MESSAGE_BLOCKS:
|
|
raise ValueError(
|
|
"Slack payload would require "
|
|
f"{len(blocks)} blocks, exceeding Slack's {MAX_MESSAGE_BLOCKS}-block limit"
|
|
)
|
|
return {"blocks": blocks}
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Build Slack payload JSON from changelog release JSON"
|
|
)
|
|
parser.add_argument("--input", required=True, help="Path to changelog JSON")
|
|
parser.add_argument("--release-tag", required=True, help="Release tag header text")
|
|
parser.add_argument(
|
|
"--markdown-artifact-url",
|
|
default="",
|
|
help="Download URL for the raw Markdown changelog artifact",
|
|
)
|
|
parser.add_argument("--output", required=True, help="Payload JSON output path")
|
|
args = parser.parse_args()
|
|
|
|
with open(args.input) as f:
|
|
changelog = json.load(f)
|
|
|
|
payload = build_payload(changelog, args.release_tag, args.markdown_artifact_url)
|
|
Path(args.output).write_text(json.dumps(payload, separators=(",", ":")) + "\n")
|
|
print(f"Built Slack payload with {len(payload['blocks'])} blocks", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|