Files
pulse/scripts/release_control/release_note_visuals.py
2026-08-28 20:11:04 +01:00

420 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate and render the model-selected visual release-note plan."""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any
MAX_CAPTURES = 3
MAX_STEPS = 12
ALLOWED_LOCATOR_KINDS = {"role", "text", "label", "testid"}
ALLOWED_ACTIONS = {"click", "wait"}
ALLOWED_ROLES = {
"button",
"checkbox",
"dialog",
"heading",
"link",
"menuitem",
"option",
"radio",
"row",
"tab",
}
ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
class PlanError(ValueError):
pass
def _text(value: Any, field: str, *, maximum: int, required: bool = True) -> str:
if not isinstance(value, str):
raise PlanError(f"{field} must be a string")
value = value.strip()
if required and not value:
raise PlanError(f"{field} must not be empty")
if len(value) > maximum:
raise PlanError(f"{field} must be {maximum} characters or fewer")
if any(ord(character) < 32 for character in value):
raise PlanError(f"{field} must be one line without control characters")
if ";" in value or "\u2014" in value:
raise PlanError(f"{field} must not contain semicolons or em dashes")
return value
def _public_text(value: Any, field: str, *, maximum: int, required: bool = True) -> str:
value = _text(value, field, maximum=maximum, required=required)
if any(character in value for character in ("[", "]", "<", ">", "|")):
raise PlanError(f"{field} must be plain text without Markdown or HTML delimiters")
return value
def _locator(value: Any, field: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise PlanError(f"{field} must be an object")
allowed = {"kind", "value", "role", "name", "exact", "nth"}
unknown = set(value) - allowed
if unknown:
raise PlanError(f"{field} has unsupported fields: {', '.join(sorted(unknown))}")
kind = value.get("kind")
if kind not in ALLOWED_LOCATOR_KINDS:
raise PlanError(f"{field}.kind must be one of {', '.join(sorted(ALLOWED_LOCATOR_KINDS))}")
normalized: dict[str, Any] = {"kind": kind}
if kind == "role":
role = _text(value.get("role"), f"{field}.role", maximum=32)
if role not in ALLOWED_ROLES:
raise PlanError(f"{field}.role is not supported")
normalized["role"] = role
normalized["name"] = _text(value.get("name"), f"{field}.name", maximum=120)
else:
normalized["value"] = _text(value.get("value"), f"{field}.value", maximum=160)
exact = value.get("exact", True)
if not isinstance(exact, bool):
raise PlanError(f"{field}.exact must be a boolean")
normalized["exact"] = exact
nth = value.get("nth", 0)
if not isinstance(nth, int) or isinstance(nth, bool) or not 0 <= nth <= 20:
raise PlanError(f"{field}.nth must be an integer from 0 to 20")
normalized["nth"] = nth
return normalized
def _state(value: Any, field: str) -> dict[str, Any]:
if not isinstance(value, dict):
raise PlanError(f"{field} must be an object")
unknown = set(value) - {"route", "steps", "ready"}
if unknown:
raise PlanError(f"{field} has unsupported fields: {', '.join(sorted(unknown))}")
route = _text(value.get("route"), f"{field}.route", maximum=240)
if (
not route.startswith("/")
or route.startswith("//")
or "://" in route
or "\\" in route
):
raise PlanError(f"{field}.route must be a same-origin absolute path")
steps = value.get("steps", [])
if not isinstance(steps, list) or len(steps) > MAX_STEPS:
raise PlanError(f"{field}.steps must be a list with at most {MAX_STEPS} entries")
normalized_steps = []
for index, step in enumerate(steps):
step_field = f"{field}.steps[{index}]"
if not isinstance(step, dict):
raise PlanError(f"{step_field} must be an object")
unknown_step = set(step) - {"action", "locator"}
if unknown_step:
raise PlanError(
f"{step_field} has unsupported fields: {', '.join(sorted(unknown_step))}"
)
action = step.get("action")
if action not in ALLOWED_ACTIONS:
raise PlanError(f"{step_field}.action must be click or wait")
normalized_steps.append(
{"action": action, "locator": _locator(step.get("locator"), f"{step_field}.locator")}
)
if value.get("ready") is None:
raise PlanError(f"{field}.ready must identify visible content in the captured view")
return {
"route": route,
"steps": normalized_steps,
"ready": _locator(value["ready"], f"{field}.ready"),
}
def validate_plan(raw: Any) -> dict[str, Any]:
if not isinstance(raw, dict):
raise PlanError("visual plan must be a JSON object")
unknown = set(raw) - {"schema_version", "decision", "captures"}
if unknown:
raise PlanError(f"visual plan has unsupported fields: {', '.join(sorted(unknown))}")
if raw.get("schema_version") != 1:
raise PlanError("visual plan schema_version must be 1")
decision = _text(raw.get("decision"), "visual plan decision", maximum=500)
captures = raw.get("captures")
if not isinstance(captures, list) or len(captures) > MAX_CAPTURES:
raise PlanError(f"visual plan captures must be a list with at most {MAX_CAPTURES} entries")
normalized_captures = []
seen_ids: set[str] = set()
for index, capture in enumerate(captures):
field = f"captures[{index}]"
if not isinstance(capture, dict):
raise PlanError(f"{field} must be an object")
unknown_capture = set(capture) - {
"id",
"title",
"description",
"viewport",
"before",
"after",
}
if unknown_capture:
raise PlanError(
f"{field} has unsupported fields: {', '.join(sorted(unknown_capture))}"
)
capture_id = _text(capture.get("id"), f"{field}.id", maximum=48)
if not ID_PATTERN.fullmatch(capture_id):
raise PlanError(f"{field}.id must be lower-case words separated by hyphens")
if capture_id in seen_ids:
raise PlanError(f"duplicate capture id: {capture_id}")
seen_ids.add(capture_id)
viewport = capture.get("viewport")
if not isinstance(viewport, dict) or set(viewport) != {"width", "height"}:
raise PlanError(f"{field}.viewport must contain only width and height")
width = viewport.get("width")
height = viewport.get("height")
if not isinstance(width, int) or isinstance(width, bool) or not 320 <= width <= 1920:
raise PlanError(f"{field}.viewport.width must be an integer from 320 to 1920")
if not isinstance(height, int) or isinstance(height, bool) or not 568 <= height <= 1440:
raise PlanError(f"{field}.viewport.height must be an integer from 568 to 1440")
before = capture.get("before")
normalized_captures.append(
{
"id": capture_id,
"title": _public_text(capture.get("title"), f"{field}.title", maximum=90),
"description": _public_text(
capture.get("description", ""),
f"{field}.description",
maximum=240,
required=False,
),
"viewport": {"width": width, "height": height},
"before": None if before is None else _state(before, f"{field}.before"),
"after": _state(capture.get("after"), f"{field}.after"),
}
)
return {
"schema_version": 1,
"decision": decision,
"captures": normalized_captures,
}
def load_plan(path: str) -> dict[str, Any]:
if path == "-":
raw_text = sys.stdin.read()
else:
raw_text = Path(path).read_text(encoding="utf-8")
try:
raw = json.loads(raw_text)
except json.JSONDecodeError as exc:
raise PlanError(f"visual plan is not valid JSON: {exc}") from exc
return validate_plan(raw)
def asset_names(plan: dict[str, Any]) -> list[str]:
names: list[str] = []
for capture in plan["captures"]:
if capture["before"] is not None:
names.append(f"release-note-{capture['id']}-before.png")
names.append(f"release-note-{capture['id']}-now.png")
return names
def json_schema() -> dict[str, Any]:
locator_position = {
"exact": {"type": "boolean"},
"nth": {"type": "integer", "minimum": 0, "maximum": 20},
}
locator = {
"anyOf": [
{
"type": "object",
"additionalProperties": False,
"required": ["kind", "role", "name", "exact", "nth"],
"properties": {
"kind": {"const": "role", "type": "string"},
"role": {"enum": sorted(ALLOWED_ROLES), "type": "string"},
"name": {"type": "string", "minLength": 1, "maxLength": 120},
**locator_position,
},
},
{
"type": "object",
"additionalProperties": False,
"required": ["kind", "value", "exact", "nth"],
"properties": {
"kind": {
"enum": sorted(ALLOWED_LOCATOR_KINDS - {"role"}),
"type": "string",
},
"value": {"type": "string", "minLength": 1, "maxLength": 160},
**locator_position,
},
},
]
}
state = {
"type": "object",
"additionalProperties": False,
"required": ["route", "steps", "ready"],
"properties": {
"route": {"type": "string", "minLength": 1, "maxLength": 240},
"steps": {
"type": "array",
"maxItems": MAX_STEPS,
"items": {
"type": "object",
"additionalProperties": False,
"required": ["action", "locator"],
"properties": {
"action": {"enum": sorted(ALLOWED_ACTIONS)},
"locator": locator,
},
},
},
"ready": locator,
},
}
return {
"type": "object",
"additionalProperties": False,
"required": ["schema_version", "decision", "captures"],
"properties": {
"schema_version": {"const": 1, "type": "integer"},
"decision": {"type": "string", "minLength": 1, "maxLength": 500},
"captures": {
"type": "array",
"maxItems": MAX_CAPTURES,
"items": {
"type": "object",
"additionalProperties": False,
"required": [
"id",
"title",
"description",
"viewport",
"before",
"after",
],
"properties": {
"id": {
"type": "string",
"maxLength": 48,
"pattern": ID_PATTERN.pattern,
},
"title": {"type": "string", "minLength": 1, "maxLength": 90},
"description": {"type": "string", "maxLength": 240},
"viewport": {
"type": "object",
"additionalProperties": False,
"required": ["width", "height"],
"properties": {
"width": {
"type": "integer",
"minimum": 320,
"maximum": 1920,
},
"height": {
"type": "integer",
"minimum": 568,
"maximum": 1440,
},
},
},
"before": {"anyOf": [state, {"type": "null"}]},
"after": state,
},
},
},
},
}
def render_markdown(plan: dict[str, Any], repository: str, tag: str) -> str:
if not plan["captures"]:
return ""
base = f"https://github.com/{repository}/releases/download/{tag}"
lines = ["## See the difference", ""]
for capture in plan["captures"]:
lines.extend([f"### {capture['title']}", ""])
if capture["description"]:
lines.extend([capture["description"], ""])
now_name = f"release-note-{capture['id']}-now.png"
if capture["before"] is None:
lines.extend(
[f"![{capture['title']}]({base}/{now_name})", ""]
)
continue
before_name = f"release-note-{capture['id']}-before.png"
lines.extend(
[
"| Before | Now |",
"| --- | --- |",
(
f"| ![{capture['title']} before]({base}/{before_name}) "
f"| ![{capture['title']} now]({base}/{now_name}) |"
),
"",
]
)
return "\n".join(lines).rstrip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
validate_parser = subparsers.add_parser("validate")
validate_parser.add_argument("--plan", required=True)
validate_parser.add_argument("--output")
count_parser = subparsers.add_parser("count")
count_parser.add_argument("--plan", required=True)
before_count_parser = subparsers.add_parser("before-count")
before_count_parser.add_argument("--plan", required=True)
subparsers.add_parser("schema")
assets_parser = subparsers.add_parser("assets")
assets_parser.add_argument("--plan", required=True)
render_parser = subparsers.add_parser("render")
render_parser.add_argument("--plan", required=True)
render_parser.add_argument("--repository", required=True)
render_parser.add_argument("--tag", required=True)
render_parser.add_argument("--output")
args = parser.parse_args()
try:
if args.command == "schema":
sys.stdout.write(json.dumps(json_schema(), separators=(",", ":")) + "\n")
return 0
plan = load_plan(args.plan)
if args.command == "validate":
output = json.dumps(plan, indent=2) + "\n"
elif args.command == "count":
output = f"{len(plan['captures'])}\n"
elif args.command == "before-count":
output = f"{sum(capture['before'] is not None for capture in plan['captures'])}\n"
elif args.command == "assets":
output = "".join(f"{name}\n" for name in asset_names(plan))
else:
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", args.repository):
raise PlanError("repository must be in owner/name form")
if not re.fullmatch(r"v[0-9A-Za-z][0-9A-Za-z._-]*", args.tag):
raise PlanError("tag is not a safe release tag")
output = render_markdown(plan, args.repository, args.tag)
if getattr(args, "output", None):
Path(args.output).write_text(output, encoding="utf-8")
else:
sys.stdout.write(output)
return 0
except (OSError, PlanError) as exc:
print(f"release-note visuals: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())