|
| 1 | +import argparse |
| 2 | +import os |
| 3 | +import shutil |
| 4 | +import subprocess |
| 5 | +import sys |
| 6 | +from typing import NoReturn, Optional, List |
| 7 | + |
| 8 | +from .Check import Check, REPO_ROOT |
| 9 | +from ci_tools.functions import get_package_from_repo |
| 10 | +from ci_tools.logging import logger |
| 11 | + |
| 12 | +# Chronus is pinned as a dev dependency in .github/chronus/package.json with |
| 13 | +# a committed lockfile so both the top-level version and all transitive |
| 14 | +# dependencies are reproducible. |
| 15 | +_CHRONUS_INSTALL_DIR = os.path.join(".github", "chronus") |
| 16 | +_CHRONUS_BIN_NAME = "chronus.cmd" if os.name == "nt" else "chronus" |
| 17 | +_CHRONUS_BIN_PATH = os.path.join(_CHRONUS_INSTALL_DIR, "node_modules", ".bin", _CHRONUS_BIN_NAME) |
| 18 | + |
| 19 | +_FALLBACK_CHANGE_KINDS = ["breaking changes", "features added", "deprecation", "fix", "dependencies", "internal"] |
| 20 | + |
| 21 | + |
| 22 | +def _load_change_kinds() -> List[str]: |
| 23 | + """Read valid change kinds from ``.chronus/config.yaml``. |
| 24 | +
|
| 25 | + Falls back to a hardcoded list if the config file is missing or |
| 26 | + cannot be parsed (e.g. pyyaml not installed). |
| 27 | + """ |
| 28 | + config_path = os.path.join(REPO_ROOT, ".chronus", "config.yaml") |
| 29 | + try: |
| 30 | + import yaml |
| 31 | + |
| 32 | + with open(config_path) as f: |
| 33 | + config = yaml.safe_load(f) |
| 34 | + kinds = list(config.get("changeKinds", {}).keys()) |
| 35 | + if kinds: |
| 36 | + return kinds |
| 37 | + except Exception: |
| 38 | + pass |
| 39 | + return list(_FALLBACK_CHANGE_KINDS) |
| 40 | + |
| 41 | + |
| 42 | +_CHANGE_KINDS = _load_change_kinds() |
| 43 | + |
| 44 | + |
| 45 | +def _add_package_argument(parser: argparse.ArgumentParser, action: str) -> None: |
| 46 | + """Add the common ``package`` positional argument to a subparser.""" |
| 47 | + parser.add_argument( |
| 48 | + "package", |
| 49 | + nargs="?", |
| 50 | + default=None, |
| 51 | + help=(f"Package path (e.g. sdk/storage/azure-storage-blob) to {action}. "), |
| 52 | + ) |
| 53 | + |
| 54 | + |
| 55 | +class changelog(Check): |
| 56 | + """Manage changelogs with Chronus. |
| 57 | +
|
| 58 | + Wraps Chronus CLI commands (add, verify, create, status) so they can be |
| 59 | + invoked through the ``azpysdk`` CLI. Commands can be run from the |
| 60 | + repository root **or** from within a package directory — the tool will |
| 61 | + detect the package path automatically when possible. |
| 62 | + """ |
| 63 | + |
| 64 | + def __init__(self) -> None: |
| 65 | + super().__init__() |
| 66 | + self._parser: Optional[argparse.ArgumentParser] = None |
| 67 | + |
| 68 | + def register( |
| 69 | + self, subparsers: "argparse._SubParsersAction", parent_parsers: Optional[List[argparse.ArgumentParser]] = None |
| 70 | + ) -> None: |
| 71 | + # parent_parsers intentionally unused — changelog commands operate at |
| 72 | + # the repository level via Chronus, not on individual packages. |
| 73 | + p = subparsers.add_parser( |
| 74 | + "changelog", |
| 75 | + help="Manage changelogs with Chronus (add, verify, create, status)", |
| 76 | + ) |
| 77 | + self._parser = p |
| 78 | + sub = p.add_subparsers(title="changelog commands", dest="changelog_command") |
| 79 | + |
| 80 | + # changelog add |
| 81 | + add_p = sub.add_parser("add", help="Add a chronus change entry for modified packages") |
| 82 | + _add_package_argument(add_p, "add an entry for") |
| 83 | + add_p.add_argument( |
| 84 | + "--kind", |
| 85 | + "-k", |
| 86 | + choices=_CHANGE_KINDS, |
| 87 | + default=None, |
| 88 | + help="Kind of change (e.g. breaking changes, features added, fix). If omitted, chronus will prompt interactively.", |
| 89 | + ) |
| 90 | + add_p.add_argument( |
| 91 | + "--message", |
| 92 | + "-m", |
| 93 | + default=None, |
| 94 | + help="Short description of the change. If omitted, chronus will prompt interactively.", |
| 95 | + ) |
| 96 | + add_p.set_defaults(func=self._run_add) |
| 97 | + |
| 98 | + # changelog verify |
| 99 | + verify_p = sub.add_parser("verify", help="Verify all modified packages have change entries") |
| 100 | + verify_p.set_defaults(func=self._run_verify) |
| 101 | + |
| 102 | + # changelog create |
| 103 | + create_p = sub.add_parser("create", help="Generate CHANGELOG.md from pending chronus entries") |
| 104 | + _add_package_argument(create_p, "generate changelog for") |
| 105 | + create_p.set_defaults(func=self._run_create) |
| 106 | + |
| 107 | + # changelog status |
| 108 | + status_p = sub.add_parser("status", help="Show a summary of pending changes and resulting version bumps") |
| 109 | + _add_package_argument(status_p, "show status for") |
| 110 | + status_p.set_defaults(func=self._run_status) |
| 111 | + |
| 112 | + p.set_defaults(func=self._no_subcommand) |
| 113 | + |
| 114 | + # Internal helpers |
| 115 | + |
| 116 | + def _no_subcommand(self, args: argparse.Namespace) -> int: |
| 117 | + """Print help when no changelog subcommand is provided.""" |
| 118 | + if self._parser: |
| 119 | + self._parser.print_help() |
| 120 | + return 1 |
| 121 | + |
| 122 | + @staticmethod |
| 123 | + def _is_chronus_installed() -> bool: |
| 124 | + """Return ``True`` if Chronus is installed locally in *node_modules*.""" |
| 125 | + return os.path.isfile(os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH)) |
| 126 | + |
| 127 | + def _ensure_chronus_installed(self) -> None: |
| 128 | + """Verify Chronus is installed locally, offering to install if not. |
| 129 | +
|
| 130 | + Runs ``npm ci`` against ``.github/chronus`` so only the exact |
| 131 | + versions recorded in ``package-lock.json`` are installed. |
| 132 | + Raises ``SystemExit`` if the user declines or installation fails. |
| 133 | + """ |
| 134 | + if self._is_chronus_installed(): |
| 135 | + return |
| 136 | + |
| 137 | + install_dir = os.path.join(REPO_ROOT, _CHRONUS_INSTALL_DIR) |
| 138 | + |
| 139 | + npm = shutil.which("npm") |
| 140 | + if not npm: |
| 141 | + self._fail_no_npm(install_dir) |
| 142 | + |
| 143 | + self._prompt_or_autoinstall(install_dir) |
| 144 | + self._npm_ci(npm, install_dir) |
| 145 | + |
| 146 | + @staticmethod |
| 147 | + def _fail_no_npm(install_dir: str) -> NoReturn: |
| 148 | + logger.error( |
| 149 | + "Chronus is not installed and npm was not found on PATH.\n" |
| 150 | + "Please install Node.js (LTS) from https://nodejs.org/ then run:\n\n" |
| 151 | + f" cd {install_dir}\n" |
| 152 | + " npm ci\n" |
| 153 | + ) |
| 154 | + raise SystemExit(1) |
| 155 | + |
| 156 | + @staticmethod |
| 157 | + def _prompt_or_autoinstall(install_dir: str) -> None: |
| 158 | + """Prompt for installation interactively, or check env var in CI.""" |
| 159 | + if sys.stdin.isatty(): |
| 160 | + print( |
| 161 | + f"\nChronus is not installed locally. It is pinned as a dev dependency\n" |
| 162 | + f"in {os.path.join(install_dir, 'package.json')}.\n" |
| 163 | + ) |
| 164 | + answer = input(f"Run 'npm ci' in {_CHRONUS_INSTALL_DIR} to install it? [Y/n] ").strip().lower() |
| 165 | + if answer not in ("", "y", "yes"): |
| 166 | + logger.info("Skipped Chronus installation.") |
| 167 | + raise SystemExit(1) |
| 168 | + elif not os.environ.get("AZPYSDK_AUTO_INSTALL"): |
| 169 | + logger.error( |
| 170 | + "Chronus is not installed and running in non-interactive mode.\n" |
| 171 | + "Set AZPYSDK_AUTO_INSTALL=1 to allow automatic installation, or run:\n\n" |
| 172 | + f" cd {install_dir}\n" |
| 173 | + " npm ci\n" |
| 174 | + ) |
| 175 | + raise SystemExit(1) |
| 176 | + else: |
| 177 | + logger.info("AZPYSDK_AUTO_INSTALL set — running 'npm ci' automatically.") |
| 178 | + |
| 179 | + def _npm_ci(self, npm: str, install_dir: str) -> None: |
| 180 | + """Run ``npm ci`` and verify Chronus was installed.""" |
| 181 | + logger.info(f"Running: npm ci (cwd: {install_dir})") |
| 182 | + rc = subprocess.call([npm, "ci"], cwd=install_dir) |
| 183 | + if rc != 0: |
| 184 | + logger.error("'npm ci' failed. Please resolve npm errors and try again.") |
| 185 | + raise SystemExit(rc) |
| 186 | + |
| 187 | + if not self._is_chronus_installed(): |
| 188 | + logger.error( |
| 189 | + "'npm ci' succeeded but Chronus was not found in node_modules.\n" |
| 190 | + f"Expected: {os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH)}\n" |
| 191 | + f"Please verify that {os.path.join(_CHRONUS_INSTALL_DIR, 'package.json')} " |
| 192 | + "lists @chronus/chronus as a dependency." |
| 193 | + ) |
| 194 | + raise SystemExit(1) |
| 195 | + |
| 196 | + @staticmethod |
| 197 | + def _resolve_package(package_arg: Optional[str]) -> Optional[str]: |
| 198 | + """Resolve a package argument to a Chronus package name.""" |
| 199 | + if not package_arg: |
| 200 | + return None |
| 201 | + # Resolve relative paths (e.g. ".") to absolute so get_package_from_repo |
| 202 | + # doesn't accidentally glob against the repo root. |
| 203 | + target = os.path.abspath(package_arg) if os.path.exists(package_arg) else package_arg |
| 204 | + try: |
| 205 | + parsed = get_package_from_repo(target, REPO_ROOT) |
| 206 | + return parsed.name if parsed else package_arg |
| 207 | + except RuntimeError: |
| 208 | + return package_arg # passthrough for unresolvable names |
| 209 | + |
| 210 | + def _run_chronus(self, chronus_args: List[str]) -> int: |
| 211 | + """Run a chronus command from the repository root.""" |
| 212 | + self._ensure_chronus_installed() |
| 213 | + cmd = [os.path.join(REPO_ROOT, _CHRONUS_BIN_PATH), *chronus_args] |
| 214 | + logger.info(f"Running: {' '.join(cmd)}") |
| 215 | + return subprocess.call(cmd, cwd=REPO_ROOT) |
| 216 | + |
| 217 | + # Subcommand handlers |
| 218 | + |
| 219 | + def _run_add(self, args: argparse.Namespace) -> int: |
| 220 | + """Run ``chronus add`` to interactively add a change entry.""" |
| 221 | + chronus_args = ["add"] |
| 222 | + package = self._resolve_package(args.package) |
| 223 | + if package: |
| 224 | + chronus_args.append(package) |
| 225 | + if args.kind: |
| 226 | + chronus_args.extend(["--kind", args.kind]) |
| 227 | + if args.message: |
| 228 | + chronus_args.extend(["--message", args.message]) |
| 229 | + return self._run_chronus(chronus_args) |
| 230 | + |
| 231 | + def _run_verify(self, args: argparse.Namespace) -> int: |
| 232 | + """Run ``chronus verify`` to check for missing change entries.""" |
| 233 | + return self._run_chronus(["verify"]) |
| 234 | + |
| 235 | + def _run_create(self, args: argparse.Namespace) -> int: |
| 236 | + """Run ``chronus changelog`` to generate CHANGELOG.md files.""" |
| 237 | + package = self._resolve_package(args.package) |
| 238 | + if not package: |
| 239 | + logger.error( |
| 240 | + "No package specified and could not detect one from the current directory.\n" |
| 241 | + "Either run from within a package directory (e.g. sdk/core/azure-core) or\n" |
| 242 | + "pass the package path explicitly:\n\n" |
| 243 | + " azpysdk changelog create sdk/core/azure-core\n" |
| 244 | + ) |
| 245 | + return 1 |
| 246 | + |
| 247 | + rc = self._run_chronus(["changelog", "--package", package]) |
| 248 | + if rc != 0: |
| 249 | + logger.info( |
| 250 | + "Hint: if Chronus reported 'No release action found', it means there are no\n" |
| 251 | + "pending change entries for this package. Run 'azpysdk changelog add' first to\n" |
| 252 | + "create a change entry, then re-run 'azpysdk changelog create'." |
| 253 | + ) |
| 254 | + return rc |
| 255 | + |
| 256 | + def _run_status(self, args: argparse.Namespace) -> int: |
| 257 | + """Run ``chronus status`` to show pending changes.""" |
| 258 | + chronus_args = ["status"] |
| 259 | + package = self._resolve_package(args.package) |
| 260 | + if package: |
| 261 | + chronus_args.extend(["--only", package]) |
| 262 | + return self._run_chronus(chronus_args) |
0 commit comments