# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for :mod:`repomatic.templating` and the bundled templates."""
from __future__ import annotations
import inspect
import re
from importlib.resources import files
from pathlib import Path
from string import Template
import pytest
import yaml
from click_extra import Context
from repomatic import __version__
from repomatic.cli.main import repomatic
from repomatic.config import config_reference
from repomatic.deps.shippable import RELEASE_READY_SENTENCE
from repomatic.deps.sync import OPERATIONS_BY_NAME
from repomatic.frontmatter import split_frontmatter
from repomatic.release.protocol import (
CHANGELOG_COMMIT_PREFIX,
MANUAL_VERSION_BUMP_COMMIT_PREFIXES,
VERSION_BUMP_COMMIT_PREFIXES,
)
from repomatic.templating import (
FOOTER_TEMPLATE,
PrTemplate,
_parse_template,
_unescape_dollars,
get_template_names,
render_template,
)
from tests.conftest import docs_heading_anchors
PROGRAMMATIC_TEMPLATES = frozenset({
"available-admonition",
"broken-links-issue",
"development-warning",
"generated-footer",
"github-releases",
"immutable-releases",
"pr-metadata",
"refresh-tip",
"release-notes",
"release-sync-report",
"setup-guide",
"setup-guide-branch-ruleset",
"setup-guide-cloudflare-pages",
"setup-guide-cloudflare-r2",
"setup-guide-dependabot",
"setup-guide-fork-pr-approval",
"setup-guide-notifications-pat",
"setup-guide-pages-source",
"setup-guide-pypi-trusted-publisher",
"setup-guide-sha-pinning-required",
"setup-guide-token",
"setup-guide-verify",
"setup-guide-virustotal",
"unavailable-admonition",
"unsubscribe-report",
"upgrade-invite",
"yanked-admonition",
})
"""Templates rendered from Python code, not via the ``--template`` CLI flag."""
CLI_TEMPLATES = tuple(sorted(set(get_template_names()) - PROGRAMMATIC_TEMPLATES))
"""Templates that front a pull request or an issue `pr-sync` and `pr-body` open."""
REFERENCE_WORKFLOWS = (
".github/workflows/autofix.yaml",
".github/workflows/changelog.yaml",
# The squashed-release command runs in the build lane, not the
# release.yaml entry (which only calls the lanes and publishes).
".github/workflows/_release-build.yaml",
# The scan-virustotal --template reference lives in the engine lane: that
# job scans the assets a release publishes.
".github/workflows/_release-engine.yaml",
# The sample-metrics --template reference lives in its own schedule-only
# workflow, which `repomatic init` only materializes for a repository that
# opted into an accumulating store.
".github/workflows/metrics.yaml",
# Upstream-only bumpers, whose write domain exists only in this repository.
".github/workflows/self-maintenance.yaml",
)
"""Workflow files that reference PR body templates via ``--template``."""
COMMAND_TEMPLATES: dict[str, str] = {
"bump-version": "bump-version",
"squashed-release": "squashed-release",
}
"""Templates a command renders on its own, keyed by the command a workflow runs.
Each command opens its pull request or issue itself, so no `--template`
argument names the template in the workflow.
"""
WORKFLOWS_DOCS_URL = "https://repomatic.net/workflows"
"""Hosted workflows reference that template ``docs:`` fields deep-link into."""
# -- Parsing --------------------------------------------------------------------
[docs]
@pytest.mark.parametrize(
("text", "expected"),
[
(r"no dollars here", "no dollars here"),
(r"\$var", "$var"),
(r"prefix \$x and \$y suffix", "prefix $x and $y suffix"),
(r"already $fine", "already $fine"),
(r"double \\$x", r"double \$x"),
],
)
def test_unescape_dollars(text, expected):
r"""``\$`` sequences are converted back to ``$``."""
assert _unescape_dollars(text) == expected
[docs]
def test_parse_template_unescapes_body():
r"""Body ``\$`` placeholders are unescaped at parse time."""
raw = "---\ntitle: Test\n---\nHello \\$name"
_meta, body = _parse_template(raw)
assert "$name" in body
assert r"\$" not in body
[docs]
def test_parse_template_unescapes_no_frontmatter():
r"""Templates without frontmatter also get ``\$`` unescaped."""
raw = "Hello \\$world"
_meta, body = _parse_template(raw)
assert body == "Hello $world"
[docs]
def test_get_template_names():
"""Every template file is discovered once, under its stem."""
names = get_template_names()
for expected in ("bump-version", "pr-metadata", "prepare-release", "fix-typos"):
assert expected in names
# Counted off the directory rather than written here: a literal turns
# adding a template into a failure of a test that has nothing to say about
# it, while still catching a name discovered twice or not at all.
assert len(names) == len(set(names))
assert set(names) == {
item.name.removesuffix(".noformat").removesuffix(".md")
for item in files("repomatic.templates").iterdir()
if item.name.endswith((".md", ".md.noformat"))
}
# -- Loading --------------------------------------------------------------------
[docs]
def test_load_reads_the_frontmatter_once():
"""A bundled template loads once per process, as one shared instance."""
assert PrTemplate.load("bump-version") is PrTemplate.load("bump-version")
[docs]
def test_load_template_frontmatter():
"""Frontmatter is parsed correctly from a parameterized template."""
template = PrTemplate.load("bump-version")
assert template.name == "bump-version"
assert template.args == ("version", "part")
assert template.body.startswith("Merge this pull request")
[docs]
def test_load_template_title_only_frontmatter():
"""Static templates have frontmatter with title but no args."""
template = PrTemplate.load("fix-typos")
assert "title" in template.meta
assert template.args == ()
assert template.body.startswith("> [!TIP]")
[docs]
def test_load_template_external_file(tmp_path):
"""A {class}`~pathlib.Path` argument loads the template from disk."""
template_path = tmp_path / "custom.md.noformat"
template_path.write_text(
"---\n"
"args: [version]\n"
'title: "Update package to v$version"\n'
"---\n\n"
"### Update\n\n"
"Bumped to `$version`.\n",
encoding="UTF-8",
)
template = PrTemplate.load(template_path)
# The name is the file's stem, shorn of both extensions.
assert template.name == "custom"
assert template.args == ("version",)
assert template.meta["title"] == "Update package to v$version"
assert template.body.startswith("### Update")
[docs]
def test_load_template_external_file_missing(tmp_path):
"""Pointing at a non-existent file raises FileNotFoundError."""
missing = tmp_path / "does-not-exist.md"
with pytest.raises(FileNotFoundError, match="does not exist"):
PrTemplate.load(missing)
[docs]
def test_load_unknown_bundled_template():
"""A name no bundled template carries raises FileNotFoundError."""
with pytest.raises(FileNotFoundError, match="papaya"):
PrTemplate.load("papaya")
[docs]
def test_render_template_external_file(tmp_path):
"""External templates render with substitution like packaged ones."""
template_path = tmp_path / "external.md"
template_path.write_text(
"---\n"
"args: [channel]\n"
'title: "Update $channel package"\n'
'commit_message: "Sync $channel"\n'
"---\n\n"
"### Update\n\nChannel: $channel.\n",
encoding="UTF-8",
)
rendered = render_template(template_path, channel="Nix")
assert "Channel: Nix." in rendered
template = PrTemplate.load(template_path)
assert template.title(channel="Nix") == "Update Nix package"
assert template.commit_message(channel="Nix") == "Sync Nix"
[docs]
def test_render_title_no_title_returns_empty(tmp_path):
"""A template without a `title` field renders to an empty string."""
template_path = tmp_path / "bodyless.md"
template_path.write_text("### Notice\n\nNothing to report.\n", encoding="UTF-8")
template = PrTemplate.load(template_path)
assert template.title() == ""
# With neither `commit_message` nor `title`, the commit message is empty.
assert template.commit_message() == ""
[docs]
@pytest.mark.parametrize(
("labels", "expected"),
(
("labels: [🍈 fruit, 🍋 citrus]", ("🍈 fruit", "🍋 citrus")),
("labels: 🍈 fruit", ("🍈 fruit",)),
("title: Harvest", ()),
),
)
def test_labels_accept_a_list_or_a_string(tmp_path, labels, expected):
"""The `labels:` key is a YAML list or a single string."""
template_path = tmp_path / "harvest.md"
template_path.write_text(f"---\n{labels}\n---\nCrates.\n", encoding="UTF-8")
assert PrTemplate.load(template_path).labels == expected
[docs]
@pytest.mark.parametrize(
("declaration", "expected"),
(("draft: true", True), ('draft: "true"', True), ("title: Harvest", False)),
)
def test_draft_accepts_both_spellings(tmp_path, declaration, expected):
"""`draft: true` holds the pull request in draft, quoted or not."""
template_path = tmp_path / "harvest.md"
template_path.write_text(f"---\n{declaration}\n---\nCrates.\n", encoding="UTF-8")
assert PrTemplate.load(template_path).draft is expected
[docs]
def test_template_args_parameterized():
"""Parameterized templates report their required arguments."""
assert PrTemplate.load("bump-version").args == ("version", "part")
assert PrTemplate.load("prepare-release").args == (
"changes_review",
"dev_release_review",
"release_readiness",
"version",
)
[docs]
def test_template_args_static():
"""Static templates report no required arguments."""
assert PrTemplate.load("fix-typos").args == ()
assert PrTemplate.load("format-json").args == ()
[docs]
def test_render_title_static():
"""Static templates have a literal title."""
assert PrTemplate.load("fix-typos").title() == "Fix typos"
assert PrTemplate.load("format-python").title() == "Format Python"
[docs]
def test_render_title_parameterized():
"""Parameterized templates substitute variables in the title."""
title = PrTemplate.load("bump-version").title(version="1.2.0", part="minor")
assert title == "[changelog] Bump minor version to `v1.2.0`"
title = PrTemplate.load("prepare-release").title(version="5.8.1")
assert title == "Release `v5.8.1`"
[docs]
def test_render_commit_message_falls_back_to_title():
"""Templates without explicit commit_message fall back to title."""
assert PrTemplate.load("fix-typos").commit_message() == "Fix typos"
assert PrTemplate.load("format-markdown").commit_message() == "Format Markdown"
[docs]
def test_render_commit_message_explicit():
"""Templates without explicit commit_message fall back to title with backticks."""
assert PrTemplate.load("format-pyproject").commit_message() == (
"Format `pyproject.toml`"
)
assert PrTemplate.load("sync-gitignore").commit_message() == "Sync `.gitignore`"
[docs]
def test_render_commit_message_parameterized():
"""Parameterized templates substitute variables in commit_message.
The bump-version message must start with a MANUAL_VERSION_BUMP_COMMIT_PREFIXES
member: that is the emission half of the invariant the workflow gates match
against (see test_changelog_prefix_is_the_machinery_invariant).
"""
msg = PrTemplate.load("bump-version").commit_message(version="1.2.0", part="minor")
assert msg == "[changelog] Bump minor version to `v1.2.0`"
assert any(msg.startswith(p) for p in MANUAL_VERSION_BUMP_COMMIT_PREFIXES)
msg = PrTemplate.load("prepare-release").commit_message(version="5.8.1")
assert msg == "Release `v5.8.1`"
# -- Footer ---------------------------------------------------------------------
# -- Bundled template content ---------------------------------------------------
[docs]
def test_render_bump_version():
"""Bump version template includes part, version, and merge instructions."""
result = render_template("bump-version", version="1.2.0", part="minor")
assert "bump the minor part" in result
assert "## To bump version to `v1.2.0`" in result
assert "Ready for review" in result
assert "Rebase and merge" in result
assert "bump-version" in result
assert "changelog-yaml-jobs" in result
[docs]
def test_render_prepare_release():
"""Prepare release template includes version, links, and caution admonition."""
dev_review = (
"1. Review the [`v5.8.1.dev0` GitHub release]"
"(https://github.com/kdeldycke/repomatic/releases/tag/untagged-abc123)\n"
)
changes_review = (
"1. Review the full changes: [`v5.8.0...main`]"
"(https://github.com/kdeldycke/repomatic/compare/v5.8.0...main)\n"
)
result = render_template(
"prepare-release",
version="5.8.1",
dev_release_review=dev_review,
changes_review=changes_review,
release_readiness=RELEASE_READY_SENTENCE,
)
assert "## How-to release `v5.8.1`" in result
assert "`v5.8.1` tag on `main`" in result
assert "`v5.8.1` release" in result
# Both review steps render ahead of the merge instructions.
assert "GitHub release]" in result
assert "Review the full changes" in result
assert result.index("GitHub release]") < result.index("Ready for review")
assert result.index("Review the full changes") < result.index("Ready for review")
# The template owns the squash-merge safeguard alone, at warning level;
# `[!CAUTION]` is reserved for the readiness banner, which is absent here.
assert "[!WARNING]" in result
assert "[!CAUTION]" not in result
assert "Squash and merge" in result
assert "https://repomatic.net/workflows#freeze-and-unfreeze-commits" in result
assert "PyPI" in result
assert "prepare-release" in result
assert "changelog-yaml-jobs" in result
assert "release-yaml-jobs" in result
[docs]
def test_render_prepare_release_without_review_steps():
"""Empty review fragments degrade the checklist to the merge instructions."""
result = render_template(
"prepare-release",
version="5.8.1",
dev_release_review="",
changes_review="",
release_readiness=RELEASE_READY_SENTENCE,
)
assert "## How-to release `v5.8.1`" in result
assert "Ready for review" in result
assert "Rebase and merge" in result
# No dangling review links survive when the data is unavailable.
assert "GitHub release]" not in result
assert "Review the full changes" not in result
[docs]
@pytest.mark.parametrize(
("template", "needles"),
[
# Templates whose job reads `[tool.repomatic]`: they list the keys
# under a Configuration section. `test_template_config_options_are_real_keys`
# separately proves each listed key exists and is anchored, so only the
# roster is asserted here.
pytest.param(
"sync-bumpversion",
("## ⚙️ Configuration", "[tool.repomatic]", "bumpversion.sync"),
id="sync-bumpversion",
),
pytest.param(
"sync-gitignore",
(
"## ⚙️ Configuration",
"[tool.repomatic]",
"gitignore.extra-categories",
"gitignore.extra-content",
"gitignore.location",
),
id="sync-gitignore",
),
pytest.param(
"sync-mailmap",
("## ⚙️ Configuration", "[tool.repomatic]", "mailmap.sync"),
id="sync-mailmap",
),
pytest.param(
"sync-repomatic-upgrade",
(
"## ⚙️ Configuration",
"[tool.repomatic]",
"minimum-release-age",
"upstream-pin.sync",
),
id="sync-repomatic-upgrade",
),
pytest.param(
"update-dep-graph",
("## ⚙️ Configuration", "[tool.repomatic]", "dependency-graph.output"),
id="update-dep-graph",
),
pytest.param(
"update-docs",
(
"## ⚙️ Configuration",
"[tool.repomatic]",
"docs.apidoc-exclude",
"docs.update-script",
),
id="update-docs",
),
# Templates whose job is driven by a third-party tool instead: they
# point at that tool's own config table from a tip admonition.
pytest.param("fix-typos", ("[!TIP]", "[tool.typos]"), id="fix-typos"),
pytest.param("format-json", ("[!TIP]", "[tool.biome]"), id="format-json"),
pytest.param(
"format-markdown", ("[!TIP]", "[tool.mdformat]"), id="format-markdown"
),
pytest.param(
"format-pyproject",
("[!TIP]", "[tool.pyproject-fmt]"),
id="format-pyproject",
),
pytest.param(
"format-python",
("[!TIP]", "[tool.ruff]", "[tool.autopep8]"),
id="format-python",
),
],
)
def test_render_surfaces_the_config_surface(template: str, needles: tuple[str, ...]):
"""Each job's PR body points the reader at the knobs that job obeys."""
result = render_template(template)
for needle in needles:
assert needle in result, f"{template} body does not mention {needle!r}"
[docs]
def test_render_upgrade_title():
"""The upgrade pull request is titled after the release it adopts."""
title = PrTemplate.load("sync-repomatic-upgrade").title(upgrade_version="7.15.0")
assert title == "Upgrade `repomatic` to `v7.15.0`"
[docs]
def test_render_upgrade_invite():
"""The invitation names both releases and hands over the launcher verbatim."""
body = render_template(
"upgrade-invite",
previous="v7.14.0",
adopted="v7.15.0",
command="claude --model opus '/repomatic-upgrade v7.14.0 v7.15.0'",
)
assert "## 🧭 Upgrade review" in body
assert "moves `repomatic` from `v7.14.0` to `v7.15.0`" in body
assert (
"```shell\nclaude --model opus '/repomatic-upgrade v7.14.0 v7.15.0'\n```"
in body
)
assert "Generated with" not in body
[docs]
def test_dependabot_step_names_the_downstream_updaters():
"""The setup step names the updaters a downstream repository runs.
`sync-tool-versions` rewrites repomatic's own tool registry, so it runs
upstream alone and replaces nothing Dependabot does downstream.
"""
body = render_template(
"setup-guide-dependabot",
repo_url="https://example.com/orchard",
repo_slug="kevin/orchard",
)
assert "`sync-workflow-pins`" in body
assert "sync-tool-versions" not in body
# -- Markdown structure ---------------------------------------------------------
TABLE_ROW_PLACEHOLDER = re.compile(r"^\|.*\$\{?\w+", re.MULTILINE)
"""A placeholder inside a table row of a template body."""
[docs]
@pytest.mark.parametrize("name", get_template_names())
def test_no_placeholder_splices_into_a_table_row(name):
"""A multi-line value spliced into one row's pipes breaks the table.
Its first line takes the row's leading cell, and its last line gains the
row's trailing ones. A table whose row count varies is rendered whole in
Python, or its rows are spliced below the delimiter row, never inside one.
"""
assert not TABLE_ROW_PLACEHOLDER.search(PrTemplate.load(name).body)
# -- Template conformance -------------------------------------------------------
# Config-option references in PR body templates, written as
# ``- [`key`](…/configuration#anchor)`` bullets under "## ⚙️ Configuration".
CONFIG_OPTION_BULLET = re.compile(
r"- \[`(?P<key>[^`]+)`\]"
r"\(https://repomatic\.net/configuration#(?P<anchor>[^)]+)\)"
)
VALID_CONFIG_KEYS = frozenset(row[0].strip("`") for row in config_reference())
"""Every `[tool.repomatic]` key, dotted and nested-expanded, from the schema."""
[docs]
@pytest.mark.parametrize("name", get_template_names())
def test_template_config_options_are_real_keys(name: str) -> None:
"""Every option a template lists is a real key, anchored and sorted.
Each ``- [`key`](…/configuration#anchor)`` bullet must name a live
`[tool.repomatic]` key, link to its matching anchor, and appear in
alphabetical order, so a renamed or mistyped option cannot survive in a PR
body template.
"""
body = PrTemplate.load(name).body
options = [(m["key"], m["anchor"]) for m in CONFIG_OPTION_BULLET.finditer(body)]
for key, anchor in options:
assert key in VALID_CONFIG_KEYS, (
f"{name} lists unknown [tool.repomatic] option `{key}`"
)
assert anchor == key.replace(".", "-"), (
f"{name} links `{key}` to #{anchor}, expected #{key.replace('.', '-')}"
)
keys = [key for key, _ in options]
assert keys == sorted(keys), f"{name} config options are not sorted: {keys}"
SWITCH_KEY_RE = re.compile(r'exit_if_disabled\(ctx, "(?P<key>[\w.-]+)"\)')
"""The `[tool.repomatic]` key a command hands to `exit_if_disabled`."""
[docs]
def test_templates_list_the_switch_of_their_command() -> None:
"""A pull request body that lists options names the one that stops it.
A command gated by `exit_if_disabled` opens no pull request once its switch
is off, so the option list of that pull request is where a maintainer looks
for the switch. Only a template named after its command is read: `audit`
and `sync-binaries` fill a template of another name. A template with no
option list is left alone, like the `setup-guide` issue body.
"""
templates = set(get_template_names())
checked = 0
missing = []
ctx = Context(repomatic)
for name in repomatic.list_commands(ctx):
command = repomatic.get_command(ctx, name)
if name not in templates or command is None or command.callback is None:
continue
switch = SWITCH_KEY_RE.search(inspect.getsource(command.callback))
body = PrTemplate.load(name).body
listed = {match["key"] for match in CONFIG_OPTION_BULLET.finditer(body)}
if not switch or not listed:
continue
checked += 1
if switch["key"] not in listed:
missing.append(f"{name}: {switch['key']}")
# An empty population would make the loop above prove nothing.
assert checked
assert not missing, (
"These templates list the options of their command without the one that"
f" turns it off: {missing}"
)
def _collect_template_references() -> set[str]:
"""Scan reference workflows for the templates their steps render.
A `--template <name>` argument names one, and so does a run of a command of
{data}`COMMAND_TEMPLATES`. A `sync-deps --pr-sync` call renders the template
of each updater it names, which shares its name.
"""
repo_root = Path(__file__).resolve().parent.parent
pattern = re.compile(r"--template\s+([\w-]+)")
command = re.compile(r"repomatic\s+([\w-]+)")
refs: set[str] = set()
for rel_path in REFERENCE_WORKFLOWS:
content = (repo_root / rel_path).read_text(encoding="UTF-8")
refs.update(pattern.findall(content))
refs.update(
COMMAND_TEMPLATES[name]
for name in command.findall(content)
if name in COMMAND_TEMPLATES
)
for job in (yaml.safe_load(content).get("jobs") or {}).values():
for step in job.get("steps") or ():
run = step.get("run") or ""
if "repomatic sync-deps" in run and "--pr-sync" in run:
refs.update(set(run.split()) & set(OPERATIONS_BY_NAME))
return refs
def _template_package_items(
*,
exclude: frozenset[str] = frozenset(),
) -> list[tuple[str, str]]:
"""Return ``(filename, name)`` pairs for every file in the templates package.
:param exclude: Template names to skip.
"""
items = []
for item in files("repomatic.templates").iterdir():
# Only packaged template files count: skip directories and hidden or
# dunder entries, since local tool droppings (like a `.DS_Store` file
# or a `.claude/` directory) land in the same source directory.
if not item.is_file():
continue
filename = getattr(item, "name", str(item))
if filename.startswith((".", "__")):
continue
# .md.noformat files are renamed .md files hidden from mdformat.
if filename.endswith(".md.noformat"):
name = filename.removesuffix(".md.noformat")
elif filename.endswith(".md"):
name = filename.removesuffix(".md")
else:
name = filename
if name in exclude:
continue
items.append((filename, name))
return sorted(items)
[docs]
@pytest.mark.parametrize(
("filename", "name"),
_template_package_items(exclude=PROGRAMMATIC_TEMPLATES),
ids=[pair[1] for pair in _template_package_items(exclude=PROGRAMMATIC_TEMPLATES)],
)
def test_template_file_policy(filename, name):
"""Each PR template file must be a valid ``.md`` file with correct frontmatter."""
# Must be a markdown file. ``.md.noformat`` is a markdown template hidden
# from mdformat (its ``string.Template`` placeholders confuse the list
# parser); see :data:`repomatic.templating.TEMPLATE_SUFFIXES`.
assert filename.endswith((".md", ".md.noformat")), (
f"Template file {filename!r} is not a .md or .md.noformat file"
)
# Must parse without errors.
template = PrTemplate.load(name)
meta, body = template.meta, template.body
# Frontmatter must have a non-empty 'title'.
assert "title" in meta, f"Template {name!r} is missing 'title' in frontmatter"
assert meta["title"], f"Template {name!r} has an empty 'title'"
# If frontmatter has 'args', it must be a list. The placeholders it names
# are matched against the text by test_placeholders_match_declared_args.
if "args" in meta:
assert isinstance(meta["args"], list), (
f"Template {name!r} 'args' must be a list, got {type(meta['args'])}"
)
# PR body sections render as h2: no h3 heading may sneak in.
assert "### " not in body, f"Template {name!r} body must use '##' section headings"
# Every CLI template deep-links its job's section of the workflows
# reference (surfaced as the metadata block's Documentation entry).
# Validating the anchor against the in-tree headings keeps a docs reword
# from orphaning it.
docs = meta.get("docs")
assert isinstance(docs, str) and docs.startswith(f"{WORKFLOWS_DOCS_URL}#"), (
f"Template {name!r} must declare a 'docs' deep link into {WORKFLOWS_DOCS_URL}"
)
anchor = docs.partition("#")[2]
assert anchor in docs_heading_anchors("workflows"), (
f"Template {name!r} docs anchor {anchor!r} matches no docs/workflows.md heading"
)
[docs]
@pytest.mark.parametrize("name", get_template_names())
def test_placeholders_match_declared_args(name):
"""A template's `$placeholders` and its `args:` frontmatter name one set.
`string.Template.get_identifiers()` lists the placeholders `substitute()`
fills, bare or braced, so a placeholder the text uses but never declares
fails here rather than as a `KeyError` in the job rendering it, and a
declared arg no text references fails the same way. `is_valid()` rejects
the stray `$` that `substitute()` would raise on.
"""
template = PrTemplate.load(name)
texts = [
template.body,
*(value for value in template.meta.values() if isinstance(value, str)),
]
used: set[str] = set()
for text in texts:
parsed = Template(text)
assert parsed.is_valid(), f"Template {name!r} holds an invalid placeholder"
used.update(parsed.get_identifiers())
assert used == set(template.args), (
f"Template {name!r} declares {sorted(template.args)}"
f" but its text renders {sorted(used)}"
)
FRONTMATTER_KEY_ORDER = ["args", "title", "commit_message", "docs", "footer"]
"""Canonical ordering of keys within template frontmatter."""
[docs]
@pytest.mark.parametrize(
("filename", "name"),
_template_package_items(),
ids=[pair[1] for pair in _template_package_items()],
)
def test_frontmatter_key_ordering(filename, name):
"""Frontmatter keys must follow the canonical order in `FRONTMATTER_KEY_ORDER`.
`yaml.safe_load` preserves document order in the mapping it builds, so the
parsed keys read back in the order the template spells them.
"""
template_dir = files("repomatic.templates")
raw = template_dir.joinpath(filename).read_text(encoding="UTF-8")
meta, _body = split_frontmatter(raw)
known = [k for k in meta if k in FRONTMATTER_KEY_ORDER]
expected = [k for k in FRONTMATTER_KEY_ORDER if k in known]
assert known == expected, (
f"Template {name!r} frontmatter keys are {known}, expected order {expected}"
)
[docs]
def test_templates_match_workflow_references():
"""Every CLI template must be referenced by ``--template`` in a workflow file."""
template_names = set(CLI_TEMPLATES)
workflow_refs = _collect_template_references()
unreferenced = template_names - workflow_refs
assert not unreferenced, "Templates not referenced in any workflow: " + ", ".join(
sorted(unreferenced)
)
missing_templates = workflow_refs - template_names
assert not missing_templates, (
"Workflow --template references with no template file: "
+ ", ".join(sorted(missing_templates))
)
# Commit-subject tokens that make GitHub Actions skip a workflow run. They
# match anywhere in the message, not just at the start, and a skipped required
# check sits in "Pending" forever rather than failing, blocking the merge. See
# docs/commit-messages.md.
CI_SKIP_TOKENS: frozenset[str] = frozenset({
"[skip ci]",
"[ci skip]",
"[no ci]",
"[skip actions]",
"[actions skip]",
"skip-checks:true",
"skip-checks: true",
})
def _commit_message(name: str) -> str:
"""The commit message a template renders with every argument filled."""
template = PrTemplate.load(name)
return template.commit_message(**dict.fromkeys(template.args, "x"))
[docs]
@pytest.mark.parametrize("name", get_template_names())
def test_template_commit_message_carries_no_ci_skip_token(name: str) -> None:
"""No generated commit message may silently skip CI.
Every `sync-*`, `format-*` and `fix-*` job commits a message rendered
from a template, in this repository and in every downstream one. A skip
token anywhere in that message stops the run, and because the required
check is then never reported, the pull request blocks on a "Pending"
status instead of failing visibly.
"""
lowered = _commit_message(name).lower()
for token in CI_SKIP_TOKENS:
assert token not in lowered, (
f"template {name!r} renders a commit message containing {token!r}, "
"which would skip CI for the job's own pull request"
)
[docs]
@pytest.mark.parametrize("name", get_template_names())
def test_template_commit_subject_claims_no_bracket_prefix(name: str) -> None:
"""A `[bracketed]` prefix is reserved for machine-parsed mechanisms.
The one family templates may emit is
{data}`repomatic.release.protocol.CHANGELOG_COMMIT_PREFIX`, which
{data}`repomatic.release.protocol.VERSION_BUMP_COMMIT_PREFIXES` matches back
(`bump-version` is the template that earns it). Any other bracket would
either collide with that matcher or invent a label nothing reads. See
docs/commit-messages.md.
"""
message = _commit_message(name)
subject = message.splitlines()[0] if message else ""
assert not subject.startswith("[") or subject.startswith(CHANGELOG_COMMIT_PREFIX), (
f"template {name!r} starts its commit subject with a bracket prefix "
f"({subject!r}) other than {CHANGELOG_COMMIT_PREFIX!r}: brackets are "
"reserved for parsed mechanisms"
)
[docs]
def test_only_the_changelog_prefix_is_bracketed() -> None:
"""The parsed-prefix set stays a single bracket family.
`docs/commit-messages.md` tells readers that `[changelog] ` is the only
bracket prefix any machine reads, carried by every version-machinery
commit. This pins that claim to the code, so a second bracket namespace
fails here and forces the documentation to follow.
"""
for prefix in VERSION_BUMP_COMMIT_PREFIXES:
assert prefix.startswith(CHANGELOG_COMMIT_PREFIX), (
f"{prefix!r} opens a second bracket-prefixed commit contract: "
"document it in docs/commit-messages.md before extending this "
"assertion"
)