blob: 5f554a6e22d410c6e3d456b6c25a8066f3554ce0 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Breaking declarations, release ranges and frozen candidate inputs."""
import base64
import json
import os
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import release_impact as impact
import release_lifecycle as release
class ImpactTests(unittest.TestCase):
def pull(self, body="", labeled=False):
return {
"body": body,
"labels": [{"name": "breaking-changes"}] if labeled else [],
}
def test_compatible_pr_needs_no_declaration(self):
for body in (
None,
"",
"Fix a typo",
"# Breaking changes\nNone",
"# Breaking changes\nNone.\n\n# AI Usage Statement\nNone",
"# Breaking changes\nN/A.",
Path(".github/pull_request_template.md").read_text(),
):
self.assertIsNone(
impact.breaking_declaration(self.pull(body), impact.package_names())
)
def test_scoped_breaking_declaration_preserves_migration(self):
body = "# Breaking changes\nAffected packages: core, bindings/java\n\nMigration:\n- Replace `old()` with `new()`.\n\n# AI Usage Statement\nNone"
body = body.replace("\n", "\r\n")
declaration = impact.breaking_declaration(
self.pull(body, True), impact.package_names()
)
self.assertEqual(
declaration,
{
"packages": ["bindings/java", "core"],
"migration": "- Replace `old()` with `new()`.",
},
)
def test_migration_can_contain_fenced_headings(self):
migration = "```bash\n# Update callers\nuse_new_api\n```"
body = f"# Breaking changes\nAffected packages: core\nMigration:\n{migration}\n# AI Usage Statement\nNone"
self.assertEqual(
impact.breaking_declaration(self.pull(body, True), impact.package_names())[
"migration"
],
migration,
)
def test_inconsistent_or_incomplete_declarations_fail(self):
valid = (
"# Breaking changes\nAffected packages: core\nMigration:\nUse the new API."
)
for body, labeled in (
("", True),
("# Breaking changes\nNone.", True),
(valid, False),
(valid.replace("core", "bindings/go"), True),
(valid.replace("core", "core, core"), True),
(valid.replace("Use the new API.", ""), True),
(valid.replace("Use the new API.", "TODO"), True),
(valid + "\n# Breaking changes\n", True),
):
with self.subTest(body=body), self.assertRaises(ValueError):
impact.breaking_declaration(
self.pull(body, labeled), impact.package_names()
)
def test_check_command_handles_real_event_file(self):
with tempfile.TemporaryDirectory() as directory:
event = Path(directory) / "event.json"
event.write_text(json.dumps({"pull_request": self.pull()}))
env = os.environ | {"GITHUB_EVENT_PATH": str(event)}
subprocess.run(
["python3", "scripts/release_impact.py", "check"],
env=env,
check=True,
capture_output=True,
)
event.write_text(json.dumps({"pull_request": self.pull(labeled=True)}))
result = subprocess.run(
["python3", "scripts/release_impact.py", "check"],
env=env,
check=False,
capture_output=True,
)
self.assertNotEqual(result.returncode, 0)
def test_collection_uses_exact_merged_main_commit(self):
sha = "a" * 40
pull = self.pull(
"# Breaking changes\nAffected packages: core\nMigration:\nUse the replacement.",
True,
) | {
"number": 1,
"html_url": "https://github.com/apache/opendal/pull/1",
"merged_at": "now",
"merge_commit_sha": sha,
"base": {"ref": "main", "repo": {"full_name": release.REPO}},
}
unrelated = pull | {"merge_commit_sha": "b" * 40}
with (
patch.object(impact, "source_commits", return_value=(sha, [sha])),
patch.object(release, "pages", return_value=[unrelated, pull]),
):
plan = impact.collect_plan("v0.59.1", sha, impact.package_names())
self.assertEqual(plan["pulls"][0]["breaking"]["packages"], ["core"])
with (
patch.object(impact, "source_commits", return_value=(sha, [sha])),
patch.object(release, "pages", return_value=[]),
self.assertRaisesRegex(ValueError, "expected one merged main PR"),
):
impact.collect_plan("v0.59.1", sha, impact.package_names())
def test_release_range_starts_at_published_source_despite_squashed_sync(self):
with tempfile.TemporaryDirectory() as directory:
def git(*args):
return subprocess.check_output(
["git", *args], cwd=directory, text=True
).strip()
git("init", "-q", "-b", "main")
git("config", "user.name", "Test")
git("config", "user.email", "test@example.invalid")
def commit(message):
git("add", ".")
git(
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-qm",
message,
)
return git("rev-parse", "HEAD")
base = commit("Released source")
git("checkout", "-qb", "candidate")
commit("Mechanical preparation")
git("-c", "tag.gpgsign=false", "tag", "v0.59.1")
plan_path = Path(directory) / impact.PLAN_PATH
plan_path.parent.mkdir()
plan_path.write_text(json.dumps({"source": base}))
commit("Candidate with a frozen plan")
git("-c", "tag.gpgsign=false", "tag", "v0.59.2")
git("checkout", "-q", "main")
fix = commit("Next PR")
sync = commit("Squashed version sync")
with patch.object(
release, "command", side_effect=lambda *args: git(*args[1:])
):
for baseline in ("v0.59.1", "v0.59.2"):
self.assertEqual(
impact.source_commits(baseline, sync), (sync, [fix, sync])
)
def test_prepare_passes_scoped_breaking_and_reuses_frozen_declarations(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "plan.json"
sha = "a" * 40
plan = {
"baseline": "v0.59.1",
"source": sha,
"pulls": [
{
"number": 1,
"url": "url",
"breaking": {
"packages": ["core"],
"migration": "Use replacement",
},
},
{
"number": 2,
"url": "url",
"breaking": {
"packages": ["core"],
"migration": "Update callers",
},
},
],
}
path.write_text(json.dumps(plan))
def run(args, **kwargs):
self.assertEqual(args.count("--breaking"), 1)
self.assertEqual(args[-2:], ["--breaking", "core"])
Path(args[args.index("--report") + 1]).write_text("[]")
with (
patch.object(impact, "PLAN_PATH", str(path)),
patch.object(release, "command", return_value=sha),
patch.object(impact, "collect_plan") as collect,
patch.object(impact.subprocess, "run", side_effect=run),
):
impact.prepare("v0.59.1", sha)
collect.assert_not_called()
self.assertEqual(json.loads(path.read_text())["pulls"], plan["pulls"])
def test_notice_reads_migration_from_candidate_not_live_pr(self):
candidate = release.Candidate("0.60.0-rc.1", "a" * 40)
plan = {
"versions": [
{
"package": "core",
"previous": "0.59.1",
"configured": "0.59.1",
"target": "0.60.0",
"breaking": True,
"public_dependency_reason": None,
}
],
"pulls": [
{
"number": 12,
"url": "https://github.com/apache/opendal/pull/12",
"breaking": {
"packages": ["core"],
"migration": "Use `new()` instead.",
},
}
],
}
content = {"content": base64.b64encode(json.dumps(plan).encode()).decode()}
with patch.object(release, "api", return_value=content) as api:
notes = release.version_notes(candidate)
self.assertIn("0.60.0", notes)
self.assertIn("Use `new()` instead.", notes)
self.assertIn(candidate.sha, api.call_args.args[0])
with (
patch.object(release, "version_notes", return_value=notes),
patch.object(
release, "discussion", return_value={"id": "discussion"}
) as discussion,
patch.object(release, "comment_once"),
):
release.notice(
candidate,
{"phase": "release_candidate_draft", "latest_revision_number": "00001"},
)
self.assertIn(notes, discussion.call_args.args[1])
if __name__ == "__main__":
unittest.main()