from __future__ import annotations
import importlib.util
import re
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "inject_representation_disclosure.py"
def _load():
sys.path.insert(0, str(REPO_ROOT / "scripts"))
spec = importlib.util.spec_from_file_location("inject_representation_disclosure", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
module = _load()
SECTION = """## [unreleased]
### Representation changes
- *(normalize)* Move a thing ([#1234](https://example.invalid/1234))
### Other
- *(ci)* Something else ([#1235](https://example.invalid/1235))
"""
def test_the_value_stops_at_an_appended_coderabbit_block():
message = (
"fix(normalize): move a thing (#1234)\n\n"
"Body prose.\n\n"
"Representation-Change: 3 rows move of 500,004.\n"
"Previously accepted, so a real migration.\n\n"
"<!-- This is an auto-generated comment: release notes by coderabbit.ai -->\n"
"## Summary by CodeRabbit\n"
"- **Bug Fixes**\n"
)
value = module.disclosure_value(message)
assert value == "3 rows move of 500,004.\nPreviously accepted, so a real migration."
def test_the_value_stops_at_the_next_trailer():
message = (
"fix: x (#1)\n\nRepresentation-Change: 2 rows move.\ncontinued.\nCloses: #99\ntrailing\n"
)
assert module.disclosure_value(message) == "2 rows move.\ncontinued."
def test_a_single_line_value_is_unchanged():
assert module.disclosure_value("fix: x (#1)\n\nRepresentation-Change: none.\n") == "none."
def test_no_trailer_is_none():
assert module.disclosure_value("fix: x (#1)\n\nJust a body.\n") is None
def test_an_indented_trailer_is_not_a_trailer():
assert module.disclosure_value("fix: x (#1)\n\n Representation-Change: none.\n") is None
def test_the_disclosure_is_attached_under_its_bullet():
by_pr = {"1234": "fix: move a thing (#1234)\n\nRepresentation-Change: 3 rows move.\n"}
rewritten, changed, problems = module.inject(SECTION, by_pr)
assert problems == []
assert changed == ["#1234"]
assert " > 3 rows move." in rewritten
assert "- *(ci)* Something else ([#1235](https://example.invalid/1235))\n" in rewritten
assert rewritten.count(" > ") == 1
def test_a_second_run_changes_nothing():
by_pr = {"1234": "fix: move a thing (#1234)\n\nRepresentation-Change: 3 rows move.\n"}
once, _, _ = module.inject(SECTION, by_pr)
twice, changed, problems = module.inject(once, by_pr)
assert twice == once
assert changed == []
assert problems == []
@pytest.mark.parametrize(
("by_pr", "expected"),
[
({}, "no commit in this history cites it"),
(
{"1234": "fix: move a thing (#1234)\n\nNo trailer here.\n"},
"no\n`Representation-Change:` trailer",
),
],
)
def test_a_bullet_that_cannot_be_resolved_is_reported_not_skipped(by_pr, expected):
_, changed, problems = module.inject(SECTION, by_pr)
assert changed == []
assert len(problems) == 1
assert expected.replace("\n", " ") in problems[0].replace("\n", " ")
def test_an_edited_disclosure_is_reported_rather_than_trusted():
by_pr = {"1234": "fix: move a thing (#1234)\n\nRepresentation-Change: 3 rows move.\n"}
injected, _, _ = module.inject(SECTION, by_pr)
tampered = injected.replace(" > 3 rows move.", " > 9000 rows move, actually.")
_, changed, problems = module.inject(tampered, by_pr)
assert changed == []
assert len(problems) == 1
assert "no longer matches its `Representation-Change:` trailer" in problems[0]
assert "9000 rows move" in problems[0]
assert "3 rows move" in problems[0]
def test_a_trailer_edited_upstream_is_caught_on_the_next_run():
original = {"1234": "fix: move a thing (#1234)\n\nRepresentation-Change: 3 rows move.\n"}
injected, _, _ = module.inject(SECTION, original)
corrected = {"1234": "fix: move a thing (#1234)\n\nRepresentation-Change: 4 rows move.\n"}
_, changed, problems = module.inject(injected, corrected)
assert changed == []
assert len(problems) == 1
assert "no longer matches" in problems[0]
def test_stdout_and_check_are_refused_together():
with pytest.raises(SystemExit) as excinfo:
module.main(["--stdout", "--check"])
assert excinfo.value.code != 0
def test_write_mode_refuses_before_writing_when_a_bullet_is_unresolvable(tmp_path, monkeypatch):
two_bullets = SECTION.replace(
"- *(normalize)* Move a thing ([#1234](https://example.invalid/1234))",
"- *(normalize)* Move a thing ([#1234](https://example.invalid/1234))\n"
"- *(normalize)* Move another ([#4321](https://example.invalid/4321))",
)
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(two_bullets, encoding="utf-8")
before = changelog.read_text(encoding="utf-8")
monkeypatch.setattr(
module,
"squash_commits",
lambda _repo: {
"1234": "fix: move a thing (#1234)\n\nRepresentation-Change: 3 rows move.\n"
},
)
code = module.main(["--changelog", str(changelog)])
assert code == 2
assert changelog.read_text(encoding="utf-8") == before, "wrote a partially injected file"
TRAILERED = {"1234": "fix: move a thing (#1234)\n\nRepresentation-Change: 3 rows move.\n"}
@pytest.fixture
def one_correction(monkeypatch):
monkeypatch.setattr(
module,
"EDITORIAL_CORRECTIONS",
{"1234": ["**Editorial correction (#4321).** It was 4 rows, not 3."]},
)
def test_nothing_is_attached_for_a_pr_with_no_registered_correction():
rewritten, _, problems = module.inject(SECTION, TRAILERED)
assert problems == []
assert "Editorial correction" not in rewritten
def test_a_registered_correction_is_attached_below_the_trailers_own_words(one_correction):
rewritten, changed, problems = module.inject(SECTION, TRAILERED)
assert problems == []
assert changed == ["#1234"]
body = rewritten.splitlines()
start = body.index(" > 3 rows move.")
assert body[start + 1] == " >"
assert body[start + 2] == " > **Editorial correction (#4321).** It was 4 rows, not 3."
def test_a_correction_is_idempotent(one_correction):
once, _, _ = module.inject(SECTION, TRAILERED)
twice, changed, problems = module.inject(once, TRAILERED)
assert twice == once
assert changed == []
assert problems == []
@pytest.fixture
def two_corrections(monkeypatch):
monkeypatch.setattr(
module,
"EDITORIAL_CORRECTIONS",
{
"1234": [
"**Editorial correction (#4321).** It was 4 rows, not 3.",
"**Editorial correction (#5678).** The count excludes two declined rows.",
]
},
)
def test_two_corrections_are_each_separated_and_each_labelled(two_corrections):
rewritten, changed, problems = module.inject(SECTION, TRAILERED)
assert problems == []
assert changed == ["#1234"]
body = rewritten.splitlines()
start = body.index(" > 3 rows move.")
assert body[start + 1 : start + 6] == [
" >",
" > **Editorial correction (#4321).** It was 4 rows, not 3.",
" >",
" > **Editorial correction (#5678).** The count excludes two declined rows.",
"",
]
def test_two_corrections_are_idempotent(two_corrections):
once, _, _ = module.inject(SECTION, TRAILERED)
twice, changed, problems = module.inject(once, TRAILERED)
assert twice == once
assert changed == []
assert problems == []
def test_deleting_a_registered_correction_from_the_changelog_is_reported_as_drift(
one_correction,
):
injected, _, _ = module.inject(SECTION, TRAILERED)
tampered = "\n".join(
line for line in injected.splitlines() if "Editorial correction" not in line
)
_, changed, problems = module.inject(tampered + "\n", TRAILERED)
assert changed == []
assert len(problems) == 1
assert "no longer matches" in problems[0]
def test_every_registered_correction_names_itself_and_cites_an_issue():
for number, paragraphs in module.EDITORIAL_CORRECTIONS.items():
assert paragraphs, f"#{number} registers an empty correction"
for paragraph in paragraphs:
assert re.match(r"\*\*Editorial correction \(#\d+\)", paragraph), (
f"#{number}: a correction must open by naming itself editorial and citing "
f"the issue that raised it; got {paragraph[:60]!r}"
)
def _repository_is_shallow() -> bool:
probe = subprocess.run(
["git", "rev-parse", "--is-shallow-repository"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
)
return probe.stdout.strip() == "true"
def test_every_registered_correction_key_is_a_pr_number():
for number in module.EDITORIAL_CORRECTIONS:
assert number.isdigit(), (
f"{number!r} is registered in EDITORIAL_CORRECTIONS but is not a bare PR number, "
"so `bullet_pr_number` can never produce it and the correction is unreachable"
)
def test_every_registered_correction_names_a_pr_that_carries_a_trailer():
if _repository_is_shallow():
pytest.skip("shallow checkout: `git log` cannot see the commit a key names")
by_pr = module.squash_commits(REPO_ROOT)
for number in module.EDITORIAL_CORRECTIONS:
message = by_pr.get(number)
assert message is not None, (
f"#{number} has a registered editorial correction but no commit in this history "
"cites it -- check the PR number for a typo"
)
assert module.disclosure_value(message) is not None, (
f"#{number} has a registered editorial correction but its commit carries no "
"`Representation-Change:` trailer, so it can never appear under a "
"Representation changes bullet"
)
def test_a_multi_paragraph_disclosure_is_not_reported_as_drift_against_itself():
message = (
"fix: move a thing (#1234)\n\n"
"Representation-Change: 3 rows move.\n\n"
"Previously accepted, so a real migration.\n"
)
by_pr = {"1234": message}
once, changed, problems = module.inject(SECTION, by_pr)
assert changed == ["#1234"]
assert problems == []
assert " >\n" in once
twice, changed_again, problems_again = module.inject(once, by_pr)
assert twice == once
assert changed_again == []
assert problems_again == [], f"re-run reported drift against its own output: {problems_again}"