Compare commits

...

1 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
e135d39710 Release version 0.4.2 2025-12-09 00:03:46 +01:00
13 changed files with 332 additions and 260 deletions

View File

@@ -1,3 +1,8 @@
## [0.4.2] - 2025-12-09
* Wire pkgmgr release CLI to new helper and add unit tests (see ChatGPT conversation: https://chatgpt.com/share/69374f09-c760-800f-92e4-5b44a4510b62)
## [0.4.1] - 2025-12-08
* Add branch close subcommand and integrate release close/editor flow (ChatGPT: https://chatgpt.com/share/69374f09-c760-800f-92e4-5b44a4510b62)

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=0.4.1
pkgver=0.4.2
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')

6
debian/changelog vendored
View File

@@ -1,3 +1,9 @@
package-manager (0.4.2-1) unstable; urgency=medium
* Wire pkgmgr release CLI to new helper and add unit tests (see ChatGPT conversation: https://chatgpt.com/share/69374f09-c760-800f-92e4-5b44a4510b62)
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 00:03:46 +0100
package-manager (0.4.1-1) unstable; urgency=medium
* Add branch close subcommand and integrate release close/editor flow (ChatGPT: https://chatgpt.com/share/69374f09-c760-800f-92e4-5b44a4510b62)

View File

@@ -31,7 +31,7 @@
rec {
pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager";
version = "0.4.1";
version = "0.4.2";
# Use the git repo as source
src = ./.;

View File

@@ -1,5 +1,5 @@
Name: package-manager
Version: 0.4.1
Version: 0.4.2
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake

View File

@@ -1,12 +1,31 @@
# pkgmgr/cli_core/commands/release.py
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Release command wiring for the pkgmgr CLI.
This module implements the `pkgmgr release` subcommand on top of the
generic selection logic from cli_core.dispatch. It does not define its
own subparser; the CLI surface is configured in cli_core.parser.
Responsibilities:
- Take the parsed argparse.Namespace for the `release` command.
- Use the list of selected repositories provided by dispatch_command().
- Optionally list affected repositories when --list is set.
- For each selected repository, run pkgmgr.release.release(...) in
the context of that repository directory.
"""
from __future__ import annotations
import os
import sys
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from pkgmgr.cli_core.context import CLIContext
from pkgmgr.get_repo_dir import get_repo_dir
from pkgmgr import release as rel
from pkgmgr.get_repo_identifier import get_repo_identifier
from pkgmgr.release import release as run_release
Repository = Dict[str, Any]
@@ -18,59 +37,63 @@ def handle_release(
selected: List[Repository],
) -> None:
"""
Handle the 'release' command.
Handle the `pkgmgr release` subcommand.
Creates a release by incrementing the version and updating the changelog
in a single selected repository.
Important:
- Releases are strictly limited to exactly ONE repository.
- Using --all or specifying multiple identifiers for release does
not make sense and is therefore rejected.
- The --preview flag is respected and passed through to the release
implementation so that no changes are made in preview mode.
Flow:
1) Use the `selected` repositories as computed by dispatch_command().
2) If --list is given, print the identifiers of the selected repos
and return without running any release.
3) For each selected repository:
- Resolve its identifier and local directory.
- Change into that directory.
- Call pkgmgr.release.release(...) with the parsed options.
"""
if not selected:
print("No repositories selected for release.")
sys.exit(1)
print("[pkgmgr] No repositories selected for release.")
return
# List-only mode: show which repositories would be affected.
if getattr(args, "list", False):
print("[pkgmgr] Repositories that would be affected by this release:")
for repo in selected:
identifier = get_repo_identifier(repo, ctx.all_repositories)
print(f" - {identifier}")
return
for repo in selected:
identifier = get_repo_identifier(repo, ctx.all_repositories)
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
if not repo_dir or not os.path.isdir(repo_dir):
print(
f"[WARN] Skipping repository {identifier}: "
"local directory does not exist."
)
continue
if len(selected) > 1:
print(
"[ERROR] Release operations are limited to a single repository.\n"
"Do not use --all or multiple identifiers with 'pkgmgr release'."
f"[pkgmgr] Running release for repository {identifier} "
f"in '{repo_dir}'..."
)
sys.exit(1)
original_dir = os.getcwd()
repo = selected[0]
repo_dir: Optional[str] = repo.get("directory")
if not repo_dir:
repo_dir = get_repo_dir(ctx.repositories_base_dir, repo)
if not os.path.isdir(repo_dir):
print(
f"[ERROR] Repository directory does not exist locally: {repo_dir}"
)
sys.exit(1)
pyproject_path = os.path.join(repo_dir, "pyproject.toml")
changelog_path = os.path.join(repo_dir, "CHANGELOG.md")
print(
f"Releasing repository '{repo.get('repository')}' in '{repo_dir}'..."
)
os.chdir(repo_dir)
try:
rel.release(
pyproject_path=pyproject_path,
changelog_path=changelog_path,
release_type=args.release_type,
message=args.message,
preview=getattr(args, "preview", False),
)
finally:
os.chdir(original_dir)
# Change to repo directory and invoke the helper.
cwd_before = os.getcwd()
try:
os.chdir(repo_dir)
run_release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type=args.release_type,
message=args.message or None,
preview=getattr(args, "preview", False),
force=getattr(args, "force", False),
close=getattr(args, "close", False),
)
finally:
os.chdir(cwd_before)

View File

@@ -297,7 +297,6 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
help="Base branch to create the new branch from (default: main)",
)
# ------------------------------------------------------------
# release
# ------------------------------------------------------------
@@ -316,12 +315,32 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
release_parser.add_argument(
"-m",
"--message",
default="",
default=None,
help=(
"Optional release message to add to the changelog and tag."
),
)
# Generic selection / preview / list / extra_args
add_identifier_arguments(release_parser)
# Close current branch after successful release
release_parser.add_argument(
"--close",
action="store_true",
help=(
"Close the current branch after a successful release in each "
"repository, if it is not main/master."
),
)
# Force: skip preview+confirmation and run release directly
release_parser.add_argument(
"-f",
"--force",
action="store_true",
help=(
"Skip the interactive preview+confirmation step and run the "
"release directly."
),
)
# ------------------------------------------------------------
# version

View File

@@ -23,15 +23,14 @@ Additional behaviour:
phases:
1) Preview-only run (dry-run).
2) Interactive confirmation, then real release if confirmed.
This confirmation can be skipped with the `-f/--force` flag.
- If `-c/--close` is used and the current branch is not main/master,
This confirmation can be skipped with the `force=True` flag.
- If `close=True` is used and the current branch is not main/master,
the branch will be closed via branch_commands.close_branch() after
a successful release.
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
@@ -142,7 +141,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
encoding="utf-8",
) as tmp:
tmp_path = tmp.name
# Prefill with instructions as comments
tmp.write(
"# Write the changelog entry for this release.\n"
"# Lines starting with '#' will be ignored.\n"
@@ -152,7 +150,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
tmp.write(initial_message.strip() + "\n")
tmp.flush()
# Open editor (best-effort; fall back gracefully if not available)
try:
subprocess.call([editor, tmp_path])
except FileNotFoundError:
@@ -161,7 +158,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
"interactive changelog message."
)
# Read back content
try:
with open(tmp_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -171,7 +167,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
except OSError:
pass
# Filter out commented lines and return joined text
lines = [
line for line in content.splitlines()
if not line.strip().startswith("#")
@@ -197,14 +192,6 @@ def update_pyproject_version(
version = "X.Y.Z"
and replaces the version part with the given new_version string.
It does not try to parse the full TOML structure here. This keeps the
implementation small and robust as long as the version line follows
the standard pattern.
Behaviour:
- In normal mode: write the updated content back to the file.
- In preview mode: do NOT write, only report what would change.
"""
try:
with open(pyproject_path, "r", encoding="utf-8") as f:
@@ -242,13 +229,6 @@ def update_flake_version(
) -> None:
"""
Update the version in flake.nix, if present.
Looks for a line like:
version = "1.2.3";
and replaces the string inside the quotes. If the file does not
exist or no version line is found, this is treated as a non-fatal
condition and only a log message is printed.
"""
if not os.path.exists(flake_path):
print("[INFO] flake.nix not found, skipping.")
@@ -293,13 +273,6 @@ def update_pkgbuild_version(
Expects:
pkgver=1.2.3
pkgrel=1
Behaviour:
- Set pkgver to the new_version (e.g. 1.2.3).
- Reset pkgrel to 1.
If the file does not exist, this is non-fatal and only a log
message is printed.
"""
if not os.path.exists(pkgbuild_path):
print("[INFO] PKGBUILD not found, skipping.")
@@ -312,7 +285,6 @@ def update_pkgbuild_version(
print(f"[WARN] Could not read PKGBUILD: {exc}")
return
# Update pkgver
ver_pattern = r"^(pkgver\s*=\s*)(.+)$"
new_content, ver_count = re.subn(
ver_pattern,
@@ -323,9 +295,8 @@ def update_pkgbuild_version(
if ver_count == 0:
print("[WARN] No pkgver line found in PKGBUILD.")
new_content = content # revert to original if we didn't change anything
new_content = content
# Reset pkgrel to 1
rel_pattern = r"^(pkgrel\s*=\s*)(.+)$"
new_content, rel_count = re.subn(
rel_pattern,
@@ -354,19 +325,6 @@ def update_spec_version(
) -> None:
"""
Update the version in an RPM spec file, if present.
Assumes a file like 'package-manager.spec' with lines:
Version: 1.2.3
Release: 1%{?dist}
Behaviour:
- Set 'Version:' to new_version.
- Reset 'Release:' to '1' while preserving any macro suffix,
e.g. '1%{?dist}'.
If the file does not exist, this is non-fatal and only a log
message is printed.
"""
if not os.path.exists(spec_path):
print("[INFO] RPM spec file not found, skipping.")
@@ -379,7 +337,6 @@ def update_spec_version(
print(f"[WARN] Could not read spec file: {exc}")
return
# Update Version:
ver_pattern = r"^(Version:\s*)(.+)$"
new_content, ver_count = re.subn(
ver_pattern,
@@ -391,12 +348,10 @@ def update_spec_version(
if ver_count == 0:
print("[WARN] No 'Version:' line found in spec file.")
# Reset Release:
rel_pattern = r"^(Release:\s*)(.+)$"
def _release_repl(m: re.Match[str]) -> str: # type: ignore[name-defined]
rest = m.group(2).strip()
# Reset numeric prefix to "1" and keep any suffix (e.g. % macros).
match = re.match(r"^(\d+)(.*)$", rest)
if match:
suffix = match.group(2)
@@ -439,21 +394,11 @@ def update_changelog(
"""
Prepend a new release section to CHANGELOG.md with the new version,
current date, and a message.
Behaviour:
- If message is None and preview is False:
→ open $EDITOR (fallback 'nano') to let the user enter a message.
- If message is None and preview is True:
→ use a generic automated message.
- The resulting changelog entry is printed to stdout.
- Returns the final message text used.
"""
today = date.today().isoformat()
# Resolve message
if message is None:
if preview:
# Do not open editor in preview mode; keep it non-interactive.
message = "Automated release."
else:
print(
@@ -481,7 +426,6 @@ def update_changelog(
new_changelog = header + "\n" + changelog if changelog else header
# Show the entry that will be written
print("\n================ CHANGELOG ENTRY ================")
print(header.rstrip())
print("=================================================\n")
@@ -506,8 +450,6 @@ def update_changelog(
def _get_git_config_value(key: str) -> Optional[str]:
"""
Try to read a value from `git config --get <key>`.
Returns the stripped value or None if not set / on error.
"""
try:
result = subprocess.run(
@@ -526,12 +468,6 @@ def _get_git_config_value(key: str) -> Optional[str]:
def _get_debian_author() -> Tuple[str, str]:
"""
Determine the maintainer name/email for debian/changelog entries.
Priority:
1. DEBFULLNAME / DEBEMAIL
2. GIT_AUTHOR_NAME / GIT_AUTHOR_EMAIL
3. git config user.name / user.email
4. Fallback: 'Unknown Maintainer' / 'unknown@example.com'
"""
name = os.environ.get("DEBFULLNAME")
email = os.environ.get("DEBEMAIL")
@@ -563,12 +499,6 @@ def update_debian_changelog(
) -> None:
"""
Prepend a new entry to debian/changelog, if it exists.
The first line typically looks like:
package-name (1.2.3-1) unstable; urgency=medium
We generate a new stanza at the top with Debian-style version
'X.Y.Z-1'. If the file does not exist, this function does nothing.
"""
if not os.path.exists(debian_changelog_path):
print("[INFO] debian/changelog not found, skipping.")
@@ -576,15 +506,12 @@ def update_debian_changelog(
debian_version = f"{new_version}-1"
now = datetime.now().astimezone()
# Debian-like date string, e.g. "Mon, 08 Dec 2025 12:34:56 +0100"
date_str = now.strftime("%a, %d %b %Y %H:%M:%S %z")
author_name, author_email = _get_debian_author()
first_line = f"{package_name} ({debian_version}) unstable; urgency=medium"
body_line = (
message.strip() if message else f"Automated release {new_version}."
)
body_line = message.strip() if message else f"Automated release {new_version}."
stanza = (
f"{first_line}\n\n"
f" * {body_line}\n\n"
@@ -628,22 +555,8 @@ def _release_impl(
) -> None:
"""
Internal implementation that performs a single-phase release.
If `preview` is True:
- No files are written.
- No git commands are executed.
- Planned actions are printed.
If `preview` is False:
- Files are updated.
- Git commit, tag, and push are executed.
- If `close` is True and the current branch is not main/master,
the branch will be closed after a successful release.
"""
# 1) Determine the current version from Git tags.
current_ver = _determine_current_version()
# 2) Compute the next version.
new_ver = _bump_semver(current_ver, release_type)
new_ver_str = str(new_ver)
new_tag = new_ver.to_tag(with_prefix=True)
@@ -653,12 +566,9 @@ def _release_impl(
print(f"Current version: {current_ver}")
print(f"New version: {new_ver_str} ({release_type})")
# Determine repository root based on pyproject location
repo_root = os.path.dirname(os.path.abspath(pyproject_path))
# 2) Update files.
update_pyproject_version(pyproject_path, new_ver_str, preview=preview)
# Let update_changelog resolve or edit the message; capture it separately.
changelog_message = update_changelog(
changelog_path,
new_ver_str,
@@ -666,7 +576,6 @@ def _release_impl(
preview=preview,
)
# Additional packaging files (non-fatal if missing)
flake_path = os.path.join(repo_root, "flake.nix")
update_flake_version(flake_path, new_ver_str, preview=preview)
@@ -676,14 +585,12 @@ def _release_impl(
spec_path = os.path.join(repo_root, "package-manager.spec")
update_spec_version(spec_path, new_ver_str, preview=preview)
# Determine an effective message for tag & Debian changelog.
effective_message: Optional[str] = message
if effective_message is None and isinstance(changelog_message, str):
if changelog_message.strip():
effective_message = changelog_message.strip()
debian_changelog_path = os.path.join(repo_root, "debian", "changelog")
# Use repo directory name as a simple default for package name
package_name = os.path.basename(repo_root) or "package-manager"
update_debian_changelog(
debian_changelog_path,
@@ -693,7 +600,6 @@ def _release_impl(
preview=preview,
)
# 3) Git operations: stage, commit, tag, push.
commit_msg = f"Release version {new_ver_str}"
tag_msg = effective_message or commit_msg
@@ -703,7 +609,6 @@ def _release_impl(
branch = "main"
print(f"Releasing on branch: {branch}")
# Stage all relevant packaging files so they are included in the commit
files_to_add = [
pyproject_path,
changelog_path,
@@ -725,11 +630,11 @@ def _release_impl(
if close and branch not in ("main", "master"):
print(
f"[PREVIEW] Would also close branch {branch} after the release "
"(--close specified and branch is not main/master)."
"(close=True and branch is not main/master)."
)
elif close:
print(
f"[PREVIEW] --close specified but current branch is {branch}; "
f"[PREVIEW] close=True but current branch is {branch}; "
"no branch would be closed."
)
@@ -746,27 +651,26 @@ def _release_impl(
print(f"Release {new_ver_str} completed.")
# Optional: close the current branch after a successful release.
if close:
if branch in ("main", "master"):
print(
f"[INFO] --close specified but current branch is {branch}; "
f"[INFO] close=True but current branch is {branch}; "
"nothing to close."
)
return
print(
f"[INFO] Closing branch {branch} after successful release "
"(--close enabled and branch is not main/master)..."
"(close=True and branch is not main/master)..."
)
try:
close_branch(name=branch, base_branch="main", cwd=".")
except Exception as exc: # pragma: no cover - defensive
except Exception as exc: # pragma: no cover
print(f"[WARN] Failed to close branch {branch} automatically: {exc}")
# ---------------------------------------------------------------------------
# Public release entry point (with preview-first + confirmation logic)
# Public release entry point
# ---------------------------------------------------------------------------
@@ -786,29 +690,13 @@ def release(
- preview=True:
* Single-phase PREVIEW only.
* No files are changed, no git commands are executed.
* `force` is ignored in this mode.
- preview=False, force=True:
* Single-phase REAL release, no interactive preview.
* Files are changed and git commands are executed immediately.
- preview=False, force=False:
* Two-phase flow (intended default for interactive CLI use):
1) PREVIEW: dry-run, printing all planned actions.
2) Ask the user for confirmation:
"Proceed with the actual release? [y/N]: "
If confirmed, perform the REAL release.
Otherwise, abort without changes.
* In non-interactive environments (stdin not a TTY), the
confirmation step is skipped automatically and a single
REAL phase is executed, to avoid blocking on input().
The `close` flag controls whether the current branch should be
closed after a successful REAL release (only if it is not main/master).
* Two-phase flow (intended default for interactive CLI use).
"""
# Explicit preview mode: just do a single PREVIEW phase and exit.
if preview:
_release_impl(
pyproject_path=pyproject_path,
@@ -820,7 +708,6 @@ def release(
)
return
# Non-preview, but forced: run REAL release directly.
if force:
_release_impl(
pyproject_path=pyproject_path,
@@ -832,7 +719,6 @@ def release(
)
return
# Non-interactive environment? Skip confirmation to avoid blocking.
if not sys.stdin.isatty():
_release_impl(
pyproject_path=pyproject_path,
@@ -844,7 +730,6 @@ def release(
)
return
# Interactive two-phase flow:
print("[INFO] Running preview before actual release...\n")
_release_impl(
pyproject_path=pyproject_path,
@@ -855,7 +740,6 @@ def release(
close=close,
)
# Ask for confirmation
try:
answer = input("Proceed with the actual release? [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
@@ -875,78 +759,3 @@ def release(
preview=False,
close=close,
)
# ---------------------------------------------------------------------------
# CLI entry point for standalone use
# ---------------------------------------------------------------------------
def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="pkgmgr release helper")
parser.add_argument(
"release_type",
choices=["major", "minor", "patch"],
help="Type of release (major/minor/patch).",
)
parser.add_argument(
"-m",
"--message",
dest="message",
default=None,
help="Release message to use for changelog and tag.",
)
parser.add_argument(
"--pyproject",
dest="pyproject",
default="pyproject.toml",
help="Path to pyproject.toml (default: pyproject.toml)",
)
parser.add_argument(
"--changelog",
dest="changelog",
default="CHANGELOG.md",
help="Path to CHANGELOG.md (default: CHANGELOG.md)",
)
parser.add_argument(
"--preview",
action="store_true",
help=(
"Preview release changes without modifying files or running git. "
"This mode never executes the real release."
),
)
parser.add_argument(
"-f",
"--force",
dest="force",
action="store_true",
help=(
"Skip the interactive preview+confirmation step and run the "
"release directly."
),
)
parser.add_argument(
"-c",
"--close",
dest="close",
action="store_true",
help=(
"Close the current branch after a successful release, "
"if it is not main/master."
),
)
return parser.parse_args(argv)
if __name__ == "__main__":
args = _parse_args()
release(
pyproject_path=args.pyproject,
changelog_path=args.changelog,
release_type=args.release_type,
message=args.message,
preview=args.preview,
force=args.force,
close=args.close,
)

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "package-manager"
version = "0.4.1"
version = "0.4.2"
description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md"
requires-python = ">=3.11"

View File

View File

@@ -0,0 +1,206 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Unit tests for pkgmgr.cli_core.commands.release.
These tests focus on the wiring layer:
- Argument handling for the release command as defined by the
top-level parser (cli_core.parser.create_parser).
- Correct invocation of pkgmgr.release.release(...) for the
selected repositories.
- Behaviour of --preview, --list, --close, and -f/--force.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import List
from unittest.mock import patch, call
import argparse
import unittest
class TestReleaseCommand(unittest.TestCase):
"""
Tests for the `pkgmgr release` CLI wiring.
"""
def _make_ctx(self, all_repos: List[dict]) -> SimpleNamespace:
"""
Create a minimal CLIContext-like object for tests.
Only the attributes that handle_release() uses are provided.
"""
return SimpleNamespace(
config_merged={},
repositories_base_dir="/base/dir",
all_repositories=all_repos,
binaries_dir="/bin",
user_config_path="/tmp/config.yaml",
)
def _parse_release_args(self, argv: List[str]) -> argparse.Namespace:
"""
Build a real top-level parser and parse the given argv list
to obtain the Namespace for the `release` command.
"""
from pkgmgr.cli_core.parser import create_parser
parser = create_parser("test parser")
args = parser.parse_args(argv)
self.assertEqual(args.command, "release")
return args
@patch("pkgmgr.cli_core.commands.release.os.path.isdir", return_value=True)
@patch("pkgmgr.cli_core.commands.release.run_release")
@patch("pkgmgr.cli_core.commands.release.get_repo_dir")
@patch("pkgmgr.cli_core.commands.release.get_repo_identifier")
@patch("pkgmgr.cli_core.commands.release.os.chdir")
@patch("pkgmgr.cli_core.commands.release.os.getcwd", return_value="/cwd")
def test_release_with_close_and_message(
self,
mock_getcwd,
mock_chdir,
mock_get_repo_identifier,
mock_get_repo_dir,
mock_run_release,
mock_isdir,
) -> None:
"""
The release handler should call pkgmgr.release.release() with:
- release_type (e.g. minor)
- provided message
- preview flag
- force flag
- close flag
It must change into the repository directory and then back.
"""
from pkgmgr.cli_core.commands.release import handle_release
repo = {"name": "dummy-repo"}
selected = [repo]
ctx = self._make_ctx(selected)
mock_get_repo_identifier.return_value = "dummy-id"
mock_get_repo_dir.return_value = "/repos/dummy"
argv = [
"release",
"minor",
"dummy-id",
"-m",
"Close branch after minor release",
"--close",
"-f",
]
args = self._parse_release_args(argv)
handle_release(args, ctx, selected)
# We should have changed into the repo dir and then back.
mock_chdir.assert_has_calls(
[call("/repos/dummy"), call("/cwd")]
)
# And run_release should be invoked once with the expected parameters.
mock_run_release.assert_called_once_with(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="minor",
message="Close branch after minor release",
preview=False,
force=True,
close=True,
)
@patch("pkgmgr.cli_core.commands.release.os.path.isdir", return_value=True)
@patch("pkgmgr.cli_core.commands.release.run_release")
@patch("pkgmgr.cli_core.commands.release.get_repo_dir")
@patch("pkgmgr.cli_core.commands.release.get_repo_identifier")
@patch("pkgmgr.cli_core.commands.release.os.chdir")
@patch("pkgmgr.cli_core.commands.release.os.getcwd", return_value="/cwd")
def test_release_preview_mode(
self,
mock_getcwd,
mock_chdir,
mock_get_repo_identifier,
mock_get_repo_dir,
mock_run_release,
mock_isdir,
) -> None:
"""
In preview mode, the handler should pass preview=True to the
release helper and force=False by default.
"""
from pkgmgr.cli_core.commands.release import handle_release
repo = {"name": "dummy-repo"}
selected = [repo]
ctx = self._make_ctx(selected)
mock_get_repo_identifier.return_value = "dummy-id"
mock_get_repo_dir.return_value = "/repos/dummy"
argv = [
"release",
"patch",
"dummy-id",
"--preview",
]
args = self._parse_release_args(argv)
handle_release(args, ctx, selected)
mock_run_release.assert_called_once_with(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type="patch",
message=None,
preview=True,
force=False,
close=False,
)
@patch("pkgmgr.cli_core.commands.release.run_release")
@patch("pkgmgr.cli_core.commands.release.get_repo_dir")
@patch("pkgmgr.cli_core.commands.release.get_repo_identifier")
def test_release_list_mode_does_not_invoke_helper(
self,
mock_get_repo_identifier,
mock_get_repo_dir,
mock_run_release,
) -> None:
"""
When --list is provided, the handler should print the list of affected
repositories and must NOT invoke run_release().
"""
from pkgmgr.cli_core.commands.release import handle_release
repo1 = {"name": "repo-1"}
repo2 = {"name": "repo-2"}
selected = [repo1, repo2]
ctx = self._make_ctx(selected)
mock_get_repo_identifier.side_effect = ["id-1", "id-2"]
argv = [
"release",
"major",
"--list",
]
args = self._parse_release_args(argv)
handle_release(args, ctx, selected)
mock_run_release.assert_not_called()
self.assertEqual(
mock_get_repo_identifier.call_args_list,
[call(repo1, selected), call(repo2, selected)],
)
if __name__ == "__main__":
unittest.main()

View File

@@ -365,6 +365,7 @@ class TestUpdateDebianChangelog(unittest.TestCase):
class TestReleaseOrchestration(unittest.TestCase):
@patch("pkgmgr.release.sys.stdin.isatty", return_value=False)
@patch("pkgmgr.release._run_git_command")
@patch("pkgmgr.release.update_debian_changelog")
@patch("pkgmgr.release.update_spec_version")
@@ -387,6 +388,7 @@ class TestReleaseOrchestration(unittest.TestCase):
mock_update_spec,
mock_update_debian_changelog,
mock_run_git_command,
mock_isatty,
) -> None:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)
@@ -449,6 +451,7 @@ class TestReleaseOrchestration(unittest.TestCase):
self.assertIn("git push origin develop", git_calls)
self.assertIn("git push origin --tags", git_calls)
@patch("pkgmgr.release.sys.stdin.isatty", return_value=False)
@patch("pkgmgr.release._run_git_command")
@patch("pkgmgr.release.update_debian_changelog")
@patch("pkgmgr.release.update_spec_version")
@@ -471,6 +474,7 @@ class TestReleaseOrchestration(unittest.TestCase):
mock_update_spec,
mock_update_debian_changelog,
mock_run_git_command,
mock_isatty,
) -> None:
mock_determine_current_version.return_value = SemVer(1, 2, 3)
mock_bump_semver.return_value = SemVer(1, 2, 4)