diff --git a/pkgmgr/changelog.py b/pkgmgr/changelog.py new file mode 100644 index 0000000..1eaf3fa --- /dev/null +++ b/pkgmgr/changelog.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Helpers to generate changelog information from Git history. + +This module provides a small abstraction around `git log` so that +CLI commands can request a changelog between two refs (tags, branches, +commits) without dealing with raw subprocess calls. +""" + +from __future__ import annotations + +from typing import Optional + +from pkgmgr.git_utils import run_git, GitError + + +def generate_changelog( + cwd: str, + from_ref: Optional[str] = None, + to_ref: Optional[str] = None, + include_merges: bool = False, +) -> str: + """ + Generate a plain-text changelog between two Git refs. + + Parameters + ---------- + cwd: + Repository directory in which to run Git commands. + from_ref: + Optional starting reference (exclusive). If provided together + with `to_ref`, the range `from_ref..to_ref` is used. + If only `from_ref` is given, the range `from_ref..HEAD` is used. + to_ref: + Optional end reference (inclusive). If omitted, `HEAD` is used. + include_merges: + If False (default), merge commits are filtered out. + + Returns + ------- + str + The output of `git log` formatted as a simple text changelog. + If no commits are found or Git fails, an explanatory message + is returned instead of raising. + """ + # Determine the revision range + if to_ref is None: + to_ref = "HEAD" + + if from_ref: + rev_range = f"{from_ref}..{to_ref}" + else: + rev_range = to_ref + + # Use a custom pretty format that includes tags/refs (%d) + cmd = [ + "log", + "--pretty=format:%h %d %s", + ] + if not include_merges: + cmd.append("--no-merges") + cmd.append(rev_range) + + try: + output = run_git(cmd, cwd=cwd) + except GitError as exc: + # Do not raise to the CLI, return a human-readable error instead. + return ( + f"[ERROR] Failed to generate changelog in {cwd!r} " + f"for range {rev_range!r}:\n{exc}" + ) + + if not output.strip(): + return f"[INFO] No commits found for range {rev_range!r}." + + return output.strip() diff --git a/pkgmgr/cli_core/commands/__init__.py b/pkgmgr/cli_core/commands/__init__.py index c28ce05..d75e310 100644 --- a/pkgmgr/cli_core/commands/__init__.py +++ b/pkgmgr/cli_core/commands/__init__.py @@ -4,6 +4,7 @@ from .tools import handle_tools_command from .release import handle_release from .version import handle_version from .make import handle_make +from .changelog import handle_changelog __all__ = [ "handle_repos_command", @@ -12,4 +13,5 @@ __all__ = [ "handle_release", "handle_version", "handle_make", + "handle_changelog", ] diff --git a/pkgmgr/cli_core/commands/changelog.py b/pkgmgr/cli_core/commands/changelog.py new file mode 100644 index 0000000..2a4e38c --- /dev/null +++ b/pkgmgr/cli_core/commands/changelog.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import os +import sys +from typing import Any, Dict, List, Optional, Tuple + +from pkgmgr.cli_core.context import CLIContext +from pkgmgr.get_repo_dir import get_repo_dir +from pkgmgr.get_repo_identifier import get_repo_identifier +from pkgmgr.git_utils import get_tags +from pkgmgr.versioning import SemVer, extract_semver_from_tags +from pkgmgr.changelog import generate_changelog + + +Repository = Dict[str, Any] + + +def _find_previous_and_current_tag( + tags: List[str], + target_tag: Optional[str] = None, +) -> Tuple[Optional[str], Optional[str]]: + """ + Given a list of tags and an optional target tag, determine + (previous_tag, current_tag) on the SemVer axis. + + If target_tag is None: + - If there are at least two SemVer tags, return (prev, latest). + - If there is only one SemVer tag, return (None, latest). + - If there are no SemVer tags, return (None, None). + + If target_tag is given: + - If target_tag is not a SemVer tag or is unknown, return (None, None). + - Otherwise, return (previous_semver_tag, target_tag). + If there is no previous SemVer tag, previous_semver_tag is None. + """ + semver_pairs = extract_semver_from_tags(tags) + if not semver_pairs: + return None, None + + # Sort ascending by SemVer + semver_pairs.sort(key=lambda item: item[1]) + + tag_to_index = {tag: idx for idx, (tag, _ver) in enumerate(semver_pairs)} + + if target_tag is None: + if len(semver_pairs) == 1: + return None, semver_pairs[0][0] + prev_tag = semver_pairs[-2][0] + latest_tag = semver_pairs[-1][0] + return prev_tag, latest_tag + + # target_tag is specified + if target_tag not in tag_to_index: + return None, None + + idx = tag_to_index[target_tag] + current_tag = semver_pairs[idx][0] + if idx == 0: + return None, current_tag + + previous_tag = semver_pairs[idx - 1][0] + return previous_tag, current_tag + + +def handle_changelog( + args, + ctx: CLIContext, + selected: List[Repository], +) -> None: + """ + Handle the 'changelog' command. + + Behaviour: + - Without range: show changelog between the last two SemVer tags, + or from the single SemVer tag to HEAD, or from the beginning if + no tags exist. + - With RANGE of the form 'A..B': show changelog between A and B. + - With RANGE of the form 'vX.Y.Z': show changelog between the + previous SemVer tag and vX.Y.Z (or from start if there is none). + """ + + if not selected: + print("No repositories selected for changelog.") + sys.exit(1) + + range_arg: str = getattr(args, "range", "") or "" + + print("pkgmgr changelog") + print("=================") + + for repo in selected: + # Resolve repository directory + repo_dir = repo.get("directory") + if not repo_dir: + try: + repo_dir = get_repo_dir(ctx.repositories_base_dir, repo) + except Exception: + repo_dir = None + + identifier = get_repo_identifier(repo, ctx.all_repositories) + + if not repo_dir or not os.path.isdir(repo_dir): + print(f"\nRepository: {identifier}") + print("----------------------------------------") + print( + "[INFO] Skipped: repository directory does not exist " + "locally, changelog generation is not possible." + ) + continue + + print(f"\nRepository: {identifier}") + print(f"Path: {repo_dir}") + print("----------------------------------------") + + try: + tags = get_tags(cwd=repo_dir) + except Exception as exc: + print(f"[ERROR] Could not read git tags: {exc}") + tags = [] + + from_ref: Optional[str] = None + to_ref: Optional[str] = None + + if range_arg: + # Explicit range provided + if ".." in range_arg: + # Format: A..B + parts = range_arg.split("..", 1) + from_ref = parts[0] or None + to_ref = parts[1] or None + else: + # Single tag, compute previous + current + prev_tag, cur_tag = _find_previous_and_current_tag( + tags, + target_tag=range_arg, + ) + if cur_tag is None: + print( + f"[WARN] Tag {range_arg!r} not found or not a SemVer tag." + ) + print("[INFO] Falling back to full history.") + from_ref = None + to_ref = None + else: + from_ref = prev_tag + to_ref = cur_tag + else: + # No explicit range: last two SemVer tags (or fallback) + prev_tag, cur_tag = _find_previous_and_current_tag(tags) + from_ref = prev_tag + to_ref = cur_tag # may be None if no tags + + changelog_text = generate_changelog( + cwd=repo_dir, + from_ref=from_ref, + to_ref=to_ref, + include_merges=False, + ) + + if from_ref or to_ref: + ref_desc = f"{from_ref or ''}..{to_ref or 'HEAD'}" + else: + ref_desc = "" + + print(f"Range: {ref_desc}") + print() + print(changelog_text) + print() diff --git a/pkgmgr/cli_core/dispatch.py b/pkgmgr/cli_core/dispatch.py index b5784e6..00c575f 100644 --- a/pkgmgr/cli_core/dispatch.py +++ b/pkgmgr/cli_core/dispatch.py @@ -7,12 +7,15 @@ from pkgmgr.cli_core.context import CLIContext from pkgmgr.cli_core.proxy import maybe_handle_proxy from pkgmgr.get_selected_repos import get_selected_repos -from pkgmgr.cli_core.commands.repos import handle_repos_command -from pkgmgr.cli_core.commands.tools import handle_tools_command -from pkgmgr.cli_core.commands.release import handle_release -from pkgmgr.cli_core.commands.version import handle_version -from pkgmgr.cli_core.commands.config import handle_config -from pkgmgr.cli_core.commands.make import handle_make +from pkgmgr.cli_core.commands import ( + handle_repos_command, + handle_tools_command, + handle_release, + handle_version, + handle_config, + handle_make, + handle_changelog, +) def dispatch_command(args, ctx: CLIContext) -> None: @@ -43,6 +46,7 @@ def dispatch_command(args, ctx: CLIContext) -> None: "release", "version", "make", + "changelog", ] if args.command in commands_with_selection: @@ -73,6 +77,8 @@ def dispatch_command(args, ctx: CLIContext) -> None: handle_release(args, ctx, selected) elif args.command == "version": handle_version(args, ctx, selected) + elif args.command == "changelog": + handle_changelog(args, ctx, selected) elif args.command == "config": handle_config(args, ctx) elif args.command == "make": diff --git a/pkgmgr/cli_core/parser.py b/pkgmgr/cli_core/parser.py index 71163c9..0eeefaf 100644 --- a/pkgmgr/cli_core/parser.py +++ b/pkgmgr/cli_core/parser.py @@ -306,6 +306,30 @@ def create_parser(description_text: str) -> argparse.ArgumentParser: ) add_identifier_arguments(version_parser) + + + # ------------------------------------------------------------ + # changelog + # ------------------------------------------------------------ + changelog_parser = subparsers.add_parser( + "changelog", + help=( + "Show changelog derived from Git history. " + "By default, shows the changes between the last two SemVer tags." + ), + ) + changelog_parser.add_argument( + "range", + nargs="?", + default="", + help=( + "Optional tag or range (e.g. v1.2.3 or v1.2.0..v1.2.3). " + "If omitted, the changelog between the last two SemVer " + "tags is shown." + ), + ) + add_identifier_arguments(changelog_parser) + # ------------------------------------------------------------ # list # ------------------------------------------------------------ diff --git a/tests/e2e/test_integration_changelog_commands.py b/tests/e2e/test_integration_changelog_commands.py new file mode 100644 index 0000000..be4380e --- /dev/null +++ b/tests/e2e/test_integration_changelog_commands.py @@ -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 + 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() diff --git a/tests/unit/pkgmgr/test_changelog.py b/tests/unit/pkgmgr/test_changelog.py new file mode 100644 index 0000000..79aa50b --- /dev/null +++ b/tests/unit/pkgmgr/test_changelog.py @@ -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()