Add changelog CLI command and tests (see ChatGPT conversation 2025-12-08) https://chatgpt.com/share/69370663-4eb8-800f-bba9-4f5c42682450
This commit is contained in:
126
tests/e2e/test_integration_changelog_commands.py
Normal file
126
tests/e2e/test_integration_changelog_commands.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# tests/e2e/test_integration_changelog_commands.py
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
from test_integration_version_commands import (
|
||||
_load_pkgmgr_repo_dir,
|
||||
PROJECT_ROOT,
|
||||
)
|
||||
|
||||
|
||||
class TestIntegrationChangelogCommands(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
"""
|
||||
Versuche, das pkgmgr-Repository-Verzeichnis aus der Config zu laden.
|
||||
Wenn es im aktuellen Test-Container nicht existiert, merken wir uns
|
||||
None und überspringen repo-spezifische Tests später sauber.
|
||||
"""
|
||||
try:
|
||||
repo_dir = _load_pkgmgr_repo_dir()
|
||||
except Exception:
|
||||
repo_dir = None
|
||||
|
||||
if repo_dir is not None and not os.path.isdir(repo_dir):
|
||||
repo_dir = None
|
||||
|
||||
cls.pkgmgr_repo_dir = repo_dir
|
||||
|
||||
def _run_pkgmgr_changelog(
|
||||
self,
|
||||
extra_args: list[str] | None = None,
|
||||
cwd: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Helper that executes the pkgmgr CLI with the 'changelog' command
|
||||
via runpy, similar to the existing version integration tests.
|
||||
"""
|
||||
if extra_args is None:
|
||||
extra_args = []
|
||||
|
||||
cmd_repr = "pkgmgr changelog " + " ".join(extra_args)
|
||||
original_argv = list(sys.argv)
|
||||
original_cwd = os.getcwd()
|
||||
|
||||
try:
|
||||
if cwd is not None and os.path.isdir(cwd):
|
||||
os.chdir(cwd)
|
||||
|
||||
# Simulate CLI invocation: pkgmgr changelog <args...>
|
||||
sys.argv = ["pkgmgr", "changelog"] + list(extra_args)
|
||||
|
||||
try:
|
||||
runpy.run_module("pkgmgr.cli", run_name="__main__")
|
||||
except SystemExit as exc:
|
||||
code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||
if code != 0:
|
||||
print()
|
||||
print(f"[TEST] Command : {cmd_repr}")
|
||||
print(f"[TEST] Working directory: {os.getcwd()}")
|
||||
print(f"[TEST] Exit code : {code}")
|
||||
raise AssertionError(
|
||||
f"{cmd_repr!r} failed with exit code {code}. "
|
||||
"Scroll up to inspect the pkgmgr output before failure."
|
||||
) from exc
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
sys.argv = original_argv
|
||||
|
||||
def test_changelog_default_range_current_repo(self) -> None:
|
||||
"""
|
||||
Run 'pkgmgr changelog' inside the pkgmgr repo, using the default range
|
||||
(last two SemVer tags or fallback to full history).
|
||||
|
||||
Wird übersprungen, wenn das pkgmgr-Repo in dieser Umgebung
|
||||
nicht lokal vorhanden ist.
|
||||
"""
|
||||
if self.pkgmgr_repo_dir is None:
|
||||
self.skipTest(
|
||||
"pkgmgr repo directory not available in this environment; "
|
||||
"skipping repo-local changelog test."
|
||||
)
|
||||
|
||||
self._run_pkgmgr_changelog(extra_args=[], cwd=self.pkgmgr_repo_dir)
|
||||
|
||||
def test_changelog_explicit_range_head_history(self) -> None:
|
||||
"""
|
||||
Run 'pkgmgr changelog HEAD~5..HEAD' inside the pkgmgr repo.
|
||||
Selbst wenn HEAD~5 nicht existiert, sollte der Befehl den
|
||||
GitError intern behandeln und mit Exit-Code 0 beenden
|
||||
(es wird dann eine [ERROR]-Zeile gedruckt).
|
||||
|
||||
Wird übersprungen, wenn das pkgmgr-Repo nicht lokal vorhanden ist.
|
||||
"""
|
||||
if self.pkgmgr_repo_dir is None:
|
||||
self.skipTest(
|
||||
"pkgmgr repo directory not available in this environment; "
|
||||
"skipping repo-local changelog range test."
|
||||
)
|
||||
|
||||
self._run_pkgmgr_changelog(
|
||||
extra_args=["HEAD~5..HEAD"],
|
||||
cwd=self.pkgmgr_repo_dir,
|
||||
)
|
||||
|
||||
def test_changelog_all_repositories_default(self) -> None:
|
||||
"""
|
||||
Run 'pkgmgr changelog --all' from the project root to ensure
|
||||
that repository selection + changelog pipeline work in the
|
||||
multi-repo scenario.
|
||||
|
||||
Dieser Test ist robust, selbst wenn einige Repos aus der Config
|
||||
physisch nicht existieren: handle_changelog überspringt sie
|
||||
mit einer INFO-Meldung.
|
||||
"""
|
||||
self._run_pkgmgr_changelog(
|
||||
extra_args=["--all"],
|
||||
cwd=PROJECT_ROOT,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
142
tests/unit/pkgmgr/test_changelog.py
Normal file
142
tests/unit/pkgmgr/test_changelog.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.changelog import generate_changelog
|
||||
from pkgmgr.git_utils import GitError
|
||||
from pkgmgr.cli_core.commands.changelog import _find_previous_and_current_tag
|
||||
|
||||
|
||||
class TestGenerateChangelog(unittest.TestCase):
|
||||
@patch("pkgmgr.changelog.run_git")
|
||||
def test_generate_changelog_default_range_no_merges(self, mock_run_git) -> None:
|
||||
"""
|
||||
Default behaviour:
|
||||
- to_ref = HEAD
|
||||
- from_ref = None
|
||||
- include_merges = False -> adds --no-merges
|
||||
"""
|
||||
mock_run_git.return_value = "abc123 (HEAD -> main) Initial commit"
|
||||
|
||||
output = generate_changelog(cwd="/repo")
|
||||
|
||||
self.assertEqual(
|
||||
output,
|
||||
"abc123 (HEAD -> main) Initial commit",
|
||||
)
|
||||
mock_run_git.assert_called_once()
|
||||
args, kwargs = mock_run_git.call_args
|
||||
|
||||
# Command must start with git log and include our pretty format.
|
||||
self.assertEqual(args[0][0], "log")
|
||||
self.assertIn("--pretty=format:%h %d %s", args[0])
|
||||
self.assertIn("--no-merges", args[0])
|
||||
self.assertIn("HEAD", args[0])
|
||||
self.assertEqual(kwargs.get("cwd"), "/repo")
|
||||
|
||||
@patch("pkgmgr.changelog.run_git")
|
||||
def test_generate_changelog_with_range_and_merges(self, mock_run_git) -> None:
|
||||
"""
|
||||
Explicit range and include_merges=True:
|
||||
- from_ref/to_ref are combined into from..to
|
||||
- no --no-merges flag
|
||||
"""
|
||||
mock_run_git.return_value = "def456 (tag: v1.1.0) Some change"
|
||||
|
||||
output = generate_changelog(
|
||||
cwd="/repo",
|
||||
from_ref="v1.0.0",
|
||||
to_ref="v1.1.0",
|
||||
include_merges=True,
|
||||
)
|
||||
|
||||
self.assertEqual(output, "def456 (tag: v1.1.0) Some change")
|
||||
mock_run_git.assert_called_once()
|
||||
args, kwargs = mock_run_git.call_args
|
||||
|
||||
cmd = args[0]
|
||||
self.assertEqual(cmd[0], "log")
|
||||
self.assertIn("--pretty=format:%h %d %s", cmd)
|
||||
# include_merges=True -> no --no-merges flag
|
||||
self.assertNotIn("--no-merges", cmd)
|
||||
# Range must be exactly v1.0.0..v1.1.0
|
||||
self.assertIn("v1.0.0..v1.1.0", cmd)
|
||||
self.assertEqual(kwargs.get("cwd"), "/repo")
|
||||
|
||||
@patch("pkgmgr.changelog.run_git")
|
||||
def test_generate_changelog_giterror_returns_error_message(self, mock_run_git) -> None:
|
||||
"""
|
||||
If Git fails, we do NOT raise; instead we return a human readable error string.
|
||||
"""
|
||||
mock_run_git.side_effect = GitError("simulated git failure")
|
||||
|
||||
result = generate_changelog(cwd="/repo", from_ref="v0.1.0", to_ref="v0.2.0")
|
||||
|
||||
self.assertIn("[ERROR] Failed to generate changelog", result)
|
||||
self.assertIn("simulated git failure", result)
|
||||
self.assertIn("v0.1.0..v0.2.0", result)
|
||||
|
||||
@patch("pkgmgr.changelog.run_git")
|
||||
def test_generate_changelog_empty_output_returns_info(self, mock_run_git) -> None:
|
||||
"""
|
||||
Empty git log output -> informational message instead of empty string.
|
||||
"""
|
||||
mock_run_git.return_value = " \n "
|
||||
|
||||
result = generate_changelog(cwd="/repo", from_ref=None, to_ref="HEAD")
|
||||
|
||||
self.assertIn("[INFO] No commits found for range 'HEAD'", result)
|
||||
|
||||
|
||||
class TestFindPreviousAndCurrentTag(unittest.TestCase):
|
||||
def test_no_semver_tags_returns_none_none(self) -> None:
|
||||
tags = ["foo", "bar", "v1.2", "v1.2.3.4"] # all invalid for SemVer
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
||||
|
||||
self.assertIsNone(prev_tag)
|
||||
self.assertIsNone(cur_tag)
|
||||
|
||||
def test_latest_tags_when_no_target_given(self) -> None:
|
||||
"""
|
||||
When no target tag is given, the function should return:
|
||||
(second_latest_semver_tag, latest_semver_tag)
|
||||
based on semantic version ordering, not lexicographic order.
|
||||
"""
|
||||
tags = ["v1.0.0", "v1.2.0", "v1.1.0", "not-a-tag"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
||||
|
||||
self.assertEqual(prev_tag, "v1.1.0")
|
||||
self.assertEqual(cur_tag, "v1.2.0")
|
||||
|
||||
def test_single_semver_tag_returns_none_and_that_tag(self) -> None:
|
||||
tags = ["v0.1.0"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags)
|
||||
|
||||
self.assertIsNone(prev_tag)
|
||||
self.assertEqual(cur_tag, "v0.1.0")
|
||||
|
||||
def test_with_target_tag_in_the_middle(self) -> None:
|
||||
tags = ["v1.0.0", "v1.1.0", "v1.2.0"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v1.1.0")
|
||||
|
||||
self.assertEqual(prev_tag, "v1.0.0")
|
||||
self.assertEqual(cur_tag, "v1.1.0")
|
||||
|
||||
def test_with_target_tag_first_has_no_previous(self) -> None:
|
||||
tags = ["v1.0.0", "v1.1.0"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v1.0.0")
|
||||
|
||||
self.assertIsNone(prev_tag)
|
||||
self.assertEqual(cur_tag, "v1.0.0")
|
||||
|
||||
def test_unknown_target_tag_returns_none_none(self) -> None:
|
||||
tags = ["v1.0.0", "v1.1.0"]
|
||||
prev_tag, cur_tag = _find_previous_and_current_tag(tags, target_tag="v2.0.0")
|
||||
|
||||
self.assertIsNone(prev_tag)
|
||||
self.assertIsNone(cur_tag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user